Merge branch 'feat/comment-creation-wiring'

Comment creation feature with backend integration:
- CommentService for API calls
- Shared auth interceptor (401 retry)
- Input validation (10k chars, emoji-aware)
- Tap-to-reply UI for nested comments
- 22 new tests

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

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

+11
lib/main.dart
···
import 'screens/home/main_shell_screen.dart';
import 'screens/home/post_detail_screen.dart';
import 'screens/landing_screen.dart';
+
import 'services/comment_service.dart';
import 'services/streamable_service.dart';
import 'services/vote_service.dart';
import 'widgets/loading_error_states.dart';
···
signOutHandler: authProvider.signOut,
);
+
// Initialize comment service with auth callbacks
+
// Comments go through the Coves backend (which proxies to PDS with DPoP)
+
final commentService = CommentService(
+
sessionGetter: () async => authProvider.session,
+
tokenRefresher: authProvider.refreshToken,
+
signOutHandler: authProvider.signOut,
+
);
+
runApp(
MultiProvider(
providers: [
···
(context) => CommentsProvider(
authProvider,
voteProvider: context.read<VoteProvider>(),
+
commentService: commentService,
),
update: (context, auth, vote, previous) {
// Reuse existing provider to maintain state across rebuilds
···
CommentsProvider(
auth,
voteProvider: vote,
+
commentService: commentService,
);
},
),
+128 -11
lib/providers/comments_provider.dart
···
import 'dart:async' show Timer, unawaited;
+
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import '../models/comment.dart';
+
import '../services/api_exceptions.dart';
+
import '../services/comment_service.dart';
import '../services/coves_api_service.dart';
import 'auth_provider.dart';
import 'vote_provider.dart';
···
this._authProvider, {
CovesApiService? apiService,
VoteProvider? voteProvider,
-
}) : _voteProvider = voteProvider {
+
CommentService? commentService,
+
}) : _voteProvider = voteProvider,
+
_commentService = commentService {
// Use injected service (for testing) or create new one (for production)
// Pass token getter, refresh handler, and sign out handler to API service
// for automatic fresh token retrieval and automatic token refresh on 401
···
_authProvider.addListener(_onAuthChanged);
}
+
/// Maximum comment length in characters (matches backend limit)
+
/// Note: This counts Unicode grapheme clusters, so emojis count correctly
+
static const int maxCommentLength = 10000;
+
/// Handle authentication state changes
///
/// Clears comment state when user signs out to prevent privacy issues.
···
final AuthProvider _authProvider;
late final CovesApiService _apiService;
final VoteProvider? _voteProvider;
+
final CommentService? _commentService;
// Track previous auth state to detect transitions
bool _wasAuthenticated = false;
···
String? _cursor;
bool _hasMore = true;
-
// Current post URI being viewed
+
// Current post being viewed
String? _postUri;
+
String? _postCid;
// Comment configuration
String _sort = 'hot';
···
}
/// Load comments for a specific post
+
///
+
/// Parameters:
+
/// - [postUri]: AT-URI of the post
+
/// - [postCid]: CID of the post (needed for creating comments)
+
/// - [refresh]: Whether to refresh from the beginning
Future<void> loadComments({
required String postUri,
+
required String postCid,
bool refresh = false,
}) async {
// If loading for a different post, reset state
if (postUri != _postUri) {
reset();
_postUri = postUri;
+
_postCid = postCid;
}
// If already loading, schedule a refresh to happen after current load
···
_pendingRefresh = false;
// Schedule refresh without awaiting to avoid blocking
// This is intentional - we want the refresh to happen asynchronously
-
unawaited(loadComments(postUri: _postUri!, refresh: true));
+
unawaited(
+
loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true),
+
);
}
}
}
···
///
/// Reloads comments from the beginning for the current post.
Future<void> refreshComments() async {
-
if (_postUri == null) {
+
if (_postUri == null || _postCid == null) {
if (kDebugMode) {
debugPrint('⚠️ Cannot refresh - no post loaded');
}
return;
}
-
await loadComments(postUri: _postUri!, refresh: true);
+
await loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true);
}
/// Load more comments (pagination)
Future<void> loadMoreComments() async {
-
if (!_hasMore || _isLoadingMore || _postUri == null) {
+
if (!_hasMore || _isLoadingMore || _postUri == null || _postCid == null) {
return;
}
-
await loadComments(postUri: _postUri!);
+
await loadComments(postUri: _postUri!, postCid: _postCid!);
}
/// Change sort order
···
notifyListeners();
// Reload comments with new sort
-
if (_postUri != null) {
+
if (_postUri != null && _postCid != null) {
try {
-
await loadComments(postUri: _postUri!, refresh: true);
+
await loadComments(
+
postUri: _postUri!,
+
postCid: _postCid!,
+
refresh: true,
+
);
return true;
} on Exception catch (e) {
// Revert to previous sort option on failure
···
}
}
+
/// Create a comment on the current post or as a reply to another comment
+
///
+
/// Parameters:
+
/// - [content]: The comment text content
+
/// - [parentComment]: Optional parent comment for nested replies.
+
/// If null, this is a top-level reply to the post.
+
///
+
/// The reply reference structure:
+
/// - Root: Always points to the original post (_postUri, _postCid)
+
/// - Parent: Points to the post (top-level) or the parent comment (nested)
+
///
+
/// After successful creation, refreshes the comments list.
+
///
+
/// Throws:
+
/// - ValidationException if content is empty or too long
+
/// - ApiException if CommentService is not available or no post is loaded
+
/// - ApiException for API errors
+
Future<void> createComment({
+
required String content,
+
ThreadViewComment? parentComment,
+
}) async {
+
// Validate content
+
final trimmedContent = content.trim();
+
if (trimmedContent.isEmpty) {
+
throw ValidationException('Comment cannot be empty');
+
}
+
+
// Use characters.length for proper Unicode/emoji counting
+
final charCount = trimmedContent.characters.length;
+
if (charCount > maxCommentLength) {
+
throw ValidationException(
+
'Comment too long ($charCount characters). '
+
'Maximum is $maxCommentLength characters.',
+
);
+
}
+
+
if (_commentService == null) {
+
throw ApiException('CommentService not available');
+
}
+
+
if (_postUri == null || _postCid == null) {
+
throw ApiException('No post loaded - cannot create comment');
+
}
+
+
// Root is always the original post
+
final rootUri = _postUri!;
+
final rootCid = _postCid!;
+
+
// Parent depends on whether this is a top-level or nested reply
+
final String parentUri;
+
final String parentCid;
+
+
if (parentComment != null) {
+
// Nested reply - parent is the comment being replied to
+
parentUri = parentComment.comment.uri;
+
parentCid = parentComment.comment.cid;
+
} else {
+
// Top-level reply - parent is the post
+
parentUri = rootUri;
+
parentCid = rootCid;
+
}
+
+
if (kDebugMode) {
+
debugPrint('💬 Creating comment');
+
debugPrint(' Root: $rootUri');
+
debugPrint(' Parent: $parentUri');
+
debugPrint(' Is nested: ${parentComment != null}');
+
}
+
+
try {
+
final response = await _commentService.createComment(
+
rootUri: rootUri,
+
rootCid: rootCid,
+
parentUri: parentUri,
+
parentCid: parentCid,
+
content: trimmedContent,
+
);
+
+
if (kDebugMode) {
+
debugPrint('✅ Comment created: ${response.uri}');
+
}
+
+
// Refresh comments to show the new comment
+
await refreshComments();
+
} on Exception catch (e) {
+
if (kDebugMode) {
+
debugPrint('❌ Failed to create comment: $e');
+
}
+
rethrow;
+
}
+
}
+
/// Initialize vote state for a comment and its replies recursively
///
/// Extracts viewer vote data from comment and initializes VoteProvider state.
···
/// Retry loading after error
Future<void> retry() async {
_error = null;
-
if (_postUri != null) {
-
await loadComments(postUri: _postUri!, refresh: true);
+
if (_postUri != null && _postCid != null) {
+
await loadComments(postUri: _postUri!, postCid: _postCid!, refresh: true);
}
}
···
_isLoading = false;
_isLoadingMore = false;
_postUri = null;
+
_postCid = null;
_pendingRefresh = false;
notifyListeners();
}
+93 -7
lib/screens/home/post_detail_screen.dart
···
void _loadComments() {
context.read<CommentsProvider>().loadComments(
postUri: widget.post.post.uri,
+
postCid: widget.post.post.cid,
refresh: true,
);
}
···
);
}
-
/// Handle comment submission
+
/// Handle comment submission (reply to post)
Future<void> _handleCommentSubmit(String content) async {
-
// TODO: Implement comment creation via atProto
-
ScaffoldMessenger.of(context).showSnackBar(
-
SnackBar(
-
content: Text('Comment submitted: $content'),
-
behavior: SnackBarBehavior.floating,
-
duration: const Duration(seconds: 2),
+
final commentsProvider = context.read<CommentsProvider>();
+
final messenger = ScaffoldMessenger.of(context);
+
+
try {
+
await commentsProvider.createComment(content: content);
+
+
if (mounted) {
+
messenger.showSnackBar(
+
const SnackBar(
+
content: Text('Comment posted'),
+
behavior: SnackBarBehavior.floating,
+
duration: Duration(seconds: 2),
+
),
+
);
+
}
+
} on Exception catch (e) {
+
if (mounted) {
+
messenger.showSnackBar(
+
SnackBar(
+
content: Text('Failed to post comment: $e'),
+
behavior: SnackBarBehavior.floating,
+
backgroundColor: AppColors.primary,
+
),
+
);
+
}
+
rethrow; // Let ReplyScreen know submission failed
+
}
+
}
+
+
/// Handle reply to a comment (nested reply)
+
Future<void> _handleCommentReply(
+
String content,
+
ThreadViewComment parentComment,
+
) async {
+
final commentsProvider = context.read<CommentsProvider>();
+
final messenger = ScaffoldMessenger.of(context);
+
+
try {
+
await commentsProvider.createComment(
+
content: content,
+
parentComment: parentComment,
+
);
+
+
if (mounted) {
+
messenger.showSnackBar(
+
const SnackBar(
+
content: Text('Reply posted'),
+
behavior: SnackBarBehavior.floating,
+
duration: Duration(seconds: 2),
+
),
+
);
+
}
+
} on Exception catch (e) {
+
if (mounted) {
+
messenger.showSnackBar(
+
SnackBar(
+
content: Text('Failed to post reply: $e'),
+
behavior: SnackBarBehavior.floating,
+
backgroundColor: AppColors.primary,
+
),
+
);
+
}
+
rethrow; // Let ReplyScreen know submission failed
+
}
+
}
+
+
/// Open reply screen for replying to a comment
+
void _openReplyToComment(ThreadViewComment comment) {
+
// Check authentication
+
final authProvider = context.read<AuthProvider>();
+
if (!authProvider.isAuthenticated) {
+
ScaffoldMessenger.of(context).showSnackBar(
+
const SnackBar(
+
content: Text('Sign in to reply'),
+
behavior: SnackBarBehavior.floating,
+
),
+
);
+
return;
+
}
+
+
// Navigate to reply screen with comment context
+
Navigator.of(context).push(
+
MaterialPageRoute<void>(
+
builder: (context) => ReplyScreen(
+
comment: comment,
+
onSubmit: (content) => _handleCommentReply(content, comment),
+
),
),
);
}
···
comment: comment,
currentTimeNotifier:
commentsProvider.currentTimeNotifier,
+
onCommentTap: _openReplyToComment,
);
},
childCount:
···
const _CommentItem({
required this.comment,
required this.currentTimeNotifier,
+
this.onCommentTap,
});
final ThreadViewComment comment;
final ValueNotifier<DateTime?> currentTimeNotifier;
+
final void Function(ThreadViewComment)? onCommentTap;
@override
Widget build(BuildContext context) {
···
thread: comment,
currentTime: currentTime,
maxDepth: 6,
+
onCommentTap: onCommentTap,
);
},
);
+6
lib/services/api_exceptions.dart
···
FederationException(super.message, {super.originalError})
: super(statusCode: null);
}
+
+
/// Validation error
+
/// Client-side validation failure (empty content, exceeds limits, etc.)
+
class ValidationException extends ApiException {
+
ValidationException(super.message) : super(statusCode: null);
+
}
+153
lib/services/auth_interceptor.dart
···
+
import 'package:dio/dio.dart';
+
import 'package:flutter/foundation.dart';
+
+
import '../models/coves_session.dart';
+
+
/// Creates a Dio interceptor that handles authentication and automatic
+
/// token refresh on 401 errors.
+
///
+
/// This shared utility eliminates duplication between VoteService and
+
/// CommentService by providing a single implementation of:
+
/// - Adding Authorization headers with fresh tokens on each request
+
/// - Automatic retry with token refresh on 401 responses
+
/// - Sign-out handling when refresh fails
+
///
+
/// Usage:
+
/// ```dart
+
/// _dio.interceptors.add(
+
/// createAuthInterceptor(
+
/// sessionGetter: () async => authProvider.session,
+
/// tokenRefresher: authProvider.refreshToken,
+
/// signOutHandler: authProvider.signOut,
+
/// serviceName: 'MyService',
+
/// ),
+
/// );
+
/// ```
+
InterceptorsWrapper createAuthInterceptor({
+
required Future<CovesSession?> Function()? sessionGetter,
+
required Future<bool> Function()? tokenRefresher,
+
required Future<void> Function()? signOutHandler,
+
required String serviceName,
+
required Dio dio,
+
}) {
+
return InterceptorsWrapper(
+
onRequest: (options, handler) async {
+
// Fetch fresh token before each request
+
final session = await sessionGetter?.call();
+
if (session != null) {
+
options.headers['Authorization'] = 'Bearer ${session.token}';
+
if (kDebugMode) {
+
debugPrint('🔐 $serviceName: Adding fresh Authorization header');
+
}
+
} else {
+
if (kDebugMode) {
+
debugPrint(
+
'⚠️ $serviceName: Session getter returned null - '
+
'making unauthenticated request',
+
);
+
}
+
}
+
return handler.next(options);
+
},
+
onError: (error, handler) async {
+
// Handle 401 errors with automatic token refresh
+
if (error.response?.statusCode == 401 && tokenRefresher != null) {
+
if (kDebugMode) {
+
debugPrint(
+
'🔄 $serviceName: 401 detected, attempting token refresh...',
+
);
+
}
+
+
// Check if we already retried this request (prevent infinite loop)
+
if (error.requestOptions.extra['retried'] == true) {
+
if (kDebugMode) {
+
debugPrint(
+
'⚠️ $serviceName: Request already retried after token refresh, '
+
'signing out user',
+
);
+
}
+
// Already retried once, don't retry again
+
if (signOutHandler != null) {
+
await signOutHandler();
+
}
+
return handler.next(error);
+
}
+
+
try {
+
// Attempt to refresh the token
+
final refreshSucceeded = await tokenRefresher();
+
+
if (refreshSucceeded) {
+
if (kDebugMode) {
+
debugPrint(
+
'✅ $serviceName: Token refresh successful, retrying request',
+
);
+
}
+
+
// Get the new session
+
final newSession = await sessionGetter?.call();
+
+
if (newSession != null) {
+
// Mark this request as retried to prevent infinite loops
+
error.requestOptions.extra['retried'] = true;
+
+
// Update the Authorization header with the new token
+
error.requestOptions.headers['Authorization'] =
+
'Bearer ${newSession.token}';
+
+
// Retry the original request with the new token
+
try {
+
final response = await dio.fetch(error.requestOptions);
+
return handler.resolve(response);
+
} on DioException catch (retryError) {
+
// If retry failed with 401 and already retried, we already
+
// signed out in the retry limit check above, so just pass
+
// the error through without signing out again
+
if (retryError.response?.statusCode == 401 &&
+
retryError.requestOptions.extra['retried'] == true) {
+
return handler.next(retryError);
+
}
+
// For other errors during retry, rethrow to outer catch
+
rethrow;
+
}
+
}
+
}
+
+
// Refresh failed, sign out the user
+
if (kDebugMode) {
+
debugPrint(
+
'❌ $serviceName: Token refresh failed, signing out user',
+
);
+
}
+
if (signOutHandler != null) {
+
await signOutHandler();
+
}
+
} on Exception catch (e) {
+
if (kDebugMode) {
+
debugPrint('❌ $serviceName: Error during token refresh: $e');
+
}
+
// Only sign out if we haven't already (avoid double sign-out)
+
// Check if this is a DioException from a retried request
+
final isRetriedRequest =
+
e is DioException &&
+
e.response?.statusCode == 401 &&
+
e.requestOptions.extra['retried'] == true;
+
+
if (!isRetriedRequest && signOutHandler != null) {
+
await signOutHandler();
+
}
+
}
+
}
+
+
// Log the error for debugging
+
if (kDebugMode) {
+
debugPrint('❌ $serviceName API Error: ${error.message}');
+
if (error.response != null) {
+
debugPrint(' Status: ${error.response?.statusCode}');
+
debugPrint(' Data: ${error.response?.data}');
+
}
+
}
+
return handler.next(error);
+
},
+
);
+
}
+170
lib/services/comment_service.dart
···
+
import 'package:dio/dio.dart';
+
import 'package:flutter/foundation.dart';
+
+
import '../config/environment_config.dart';
+
import '../models/coves_session.dart';
+
import 'api_exceptions.dart';
+
import 'auth_interceptor.dart';
+
+
/// Comment Service
+
///
+
/// Handles comment creation through the Coves backend.
+
///
+
/// **Architecture with Backend OAuth**:
+
/// With sealed tokens, the client cannot write directly to the user's PDS
+
/// (no DPoP keys available). Instead, comments go through the Coves backend:
+
///
+
/// Mobile Client → Coves Backend (sealed token) → User's PDS (DPoP)
+
///
+
/// The backend:
+
/// 1. Unseals the token to get the actual access/refresh tokens
+
/// 2. Uses stored DPoP keys to sign requests
+
/// 3. Writes to the user's PDS on their behalf
+
///
+
/// **Backend Endpoint**:
+
/// - POST /xrpc/social.coves.community.comment.create
+
class CommentService {
+
CommentService({
+
Future<CovesSession?> Function()? sessionGetter,
+
Future<bool> Function()? tokenRefresher,
+
Future<void> Function()? signOutHandler,
+
Dio? dio,
+
}) : _sessionGetter = sessionGetter {
+
_dio =
+
dio ??
+
Dio(
+
BaseOptions(
+
baseUrl: EnvironmentConfig.current.apiUrl,
+
connectTimeout: const Duration(seconds: 30),
+
receiveTimeout: const Duration(seconds: 30),
+
headers: {'Content-Type': 'application/json'},
+
),
+
);
+
+
// Add shared 401 retry interceptor
+
_dio.interceptors.add(
+
createAuthInterceptor(
+
sessionGetter: sessionGetter,
+
tokenRefresher: tokenRefresher,
+
signOutHandler: signOutHandler,
+
serviceName: 'CommentService',
+
dio: _dio,
+
),
+
);
+
}
+
+
final Future<CovesSession?> Function()? _sessionGetter;
+
late final Dio _dio;
+
+
/// Create a comment
+
///
+
/// Sends comment request to the Coves backend, which writes to the
+
/// user's PDS.
+
///
+
/// Parameters:
+
/// - [rootUri]: AT-URI of the root post (always the original post)
+
/// - [rootCid]: CID of the root post
+
/// - [parentUri]: AT-URI of the parent (post or comment)
+
/// - [parentCid]: CID of the parent
+
/// - [content]: Comment text content
+
///
+
/// Returns:
+
/// - CreateCommentResponse with uri and cid of the created comment
+
///
+
/// Throws:
+
/// - ApiException for API errors
+
/// - AuthenticationException for auth failures
+
Future<CreateCommentResponse> createComment({
+
required String rootUri,
+
required String rootCid,
+
required String parentUri,
+
required String parentCid,
+
required String content,
+
}) async {
+
try {
+
final session = await _sessionGetter?.call();
+
+
if (session == null) {
+
throw AuthenticationException(
+
'User not authenticated - no session available',
+
);
+
}
+
+
if (kDebugMode) {
+
debugPrint('💬 Creating comment via backend');
+
debugPrint(' Root: $rootUri');
+
debugPrint(' Parent: $parentUri');
+
debugPrint(' Content length: ${content.length}');
+
}
+
+
// Send comment request to backend
+
// Note: Authorization header is added by the interceptor
+
final response = await _dio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: {
+
'reply': {
+
'root': {'uri': rootUri, 'cid': rootCid},
+
'parent': {'uri': parentUri, 'cid': parentCid},
+
},
+
'content': content,
+
},
+
);
+
+
final data = response.data;
+
if (data == null) {
+
throw ApiException('Invalid response from server - no data');
+
}
+
+
final uri = data['uri'] as String?;
+
final cid = data['cid'] as String?;
+
+
if (uri == null || uri.isEmpty || cid == null || cid.isEmpty) {
+
throw ApiException(
+
'Invalid response from server - missing uri or cid',
+
);
+
}
+
+
if (kDebugMode) {
+
debugPrint('✅ Comment created: $uri');
+
}
+
+
return CreateCommentResponse(uri: uri, cid: cid);
+
} on DioException catch (e) {
+
if (kDebugMode) {
+
debugPrint('❌ Comment creation failed: ${e.message}');
+
debugPrint(' Status: ${e.response?.statusCode}');
+
debugPrint(' Data: ${e.response?.data}');
+
}
+
+
if (e.response?.statusCode == 401) {
+
throw AuthenticationException(
+
'Authentication failed. Please sign in again.',
+
originalError: e,
+
);
+
}
+
+
throw ApiException(
+
'Failed to create comment: ${e.message}',
+
statusCode: e.response?.statusCode,
+
originalError: e,
+
);
+
} on AuthenticationException {
+
rethrow;
+
} on ApiException {
+
rethrow;
+
} on Exception catch (e) {
+
throw ApiException('Failed to create comment: $e');
+
}
+
}
+
}
+
+
/// Response from comment creation
+
class CreateCommentResponse {
+
const CreateCommentResponse({required this.uri, required this.cid});
+
+
/// AT-URI of the created comment record
+
final String uri;
+
+
/// CID of the created comment record
+
final String cid;
+
}
+13 -126
lib/services/vote_service.dart
···
import '../models/coves_session.dart';
import '../providers/vote_provider.dart' show VoteState;
import 'api_exceptions.dart';
+
import 'auth_interceptor.dart';
/// Vote Service
///
···
/// 4. Handles toggle logic (creating, deleting, or switching vote direction)
///
/// **Backend Endpoints**:
-
/// - POST /xrpc/social.coves.feed.vote.create - Creates, toggles, or switches votes
+
/// - POST /xrpc/social.coves.feed.vote.create - Creates, toggles, or switches
+
/// votes
class VoteService {
VoteService({
Future<CovesSession?> Function()? sessionGetter,
···
Future<void> Function()? signOutHandler,
Dio? dio,
}) : _sessionGetter = sessionGetter,
-
_didGetter = didGetter,
-
_tokenRefresher = tokenRefresher,
-
_signOutHandler = signOutHandler {
+
_didGetter = didGetter {
_dio =
dio ??
Dio(
···
),
);
-
// Add 401 retry interceptor (same pattern as CovesApiService)
+
// Add shared 401 retry interceptor
_dio.interceptors.add(
-
InterceptorsWrapper(
-
onRequest: (options, handler) async {
-
// Fetch fresh token before each request
-
final session = await _sessionGetter?.call();
-
if (session != null) {
-
options.headers['Authorization'] = 'Bearer ${session.token}';
-
if (kDebugMode) {
-
debugPrint('🔐 VoteService: Adding fresh Authorization header');
-
}
-
} else {
-
if (kDebugMode) {
-
debugPrint(
-
'⚠️ VoteService: Session getter returned null - '
-
'making unauthenticated request',
-
);
-
}
-
}
-
return handler.next(options);
-
},
-
onError: (error, handler) async {
-
// Handle 401 errors with automatic token refresh
-
if (error.response?.statusCode == 401 && _tokenRefresher != null) {
-
if (kDebugMode) {
-
debugPrint(
-
'🔄 VoteService: 401 detected, attempting token refresh...',
-
);
-
}
-
-
// Check if we already retried this request (prevent infinite loop)
-
if (error.requestOptions.extra['retried'] == true) {
-
if (kDebugMode) {
-
debugPrint(
-
'⚠️ VoteService: Request already retried after token refresh, '
-
'signing out user',
-
);
-
}
-
// Already retried once, don't retry again
-
if (_signOutHandler != null) {
-
await _signOutHandler();
-
}
-
return handler.next(error);
-
}
-
-
try {
-
// Attempt to refresh the token
-
final refreshSucceeded = await _tokenRefresher();
-
-
if (refreshSucceeded) {
-
if (kDebugMode) {
-
debugPrint(
-
'✅ VoteService: Token refresh successful, retrying request',
-
);
-
}
-
-
// Get the new session
-
final newSession = await _sessionGetter?.call();
-
-
if (newSession != null) {
-
// Mark this request as retried to prevent infinite loops
-
error.requestOptions.extra['retried'] = true;
-
-
// Update the Authorization header with the new token
-
error.requestOptions.headers['Authorization'] =
-
'Bearer ${newSession.token}';
-
-
// Retry the original request with the new token
-
try {
-
final response = await _dio.fetch(error.requestOptions);
-
return handler.resolve(response);
-
} on DioException catch (retryError) {
-
// If retry failed with 401 and already retried, we already
-
// signed out in the retry limit check above, so just pass
-
// the error through without signing out again
-
if (retryError.response?.statusCode == 401 &&
-
retryError.requestOptions.extra['retried'] == true) {
-
return handler.next(retryError);
-
}
-
// For other errors during retry, rethrow to outer catch
-
rethrow;
-
}
-
}
-
}
-
-
// Refresh failed, sign out the user
-
if (kDebugMode) {
-
debugPrint(
-
'❌ VoteService: Token refresh failed, signing out user',
-
);
-
}
-
if (_signOutHandler != null) {
-
await _signOutHandler();
-
}
-
} catch (e) {
-
if (kDebugMode) {
-
debugPrint('❌ VoteService: Error during token refresh: $e');
-
}
-
// Only sign out if we haven't already (avoid double sign-out)
-
// Check if this is a DioException from a retried request
-
final isRetriedRequest =
-
e is DioException &&
-
e.response?.statusCode == 401 &&
-
e.requestOptions.extra['retried'] == true;
-
-
if (!isRetriedRequest && _signOutHandler != null) {
-
await _signOutHandler();
-
}
-
}
-
}
-
-
// Log the error for debugging
-
if (kDebugMode) {
-
debugPrint('❌ VoteService API Error: ${error.message}');
-
if (error.response != null) {
-
debugPrint(' Status: ${error.response?.statusCode}');
-
debugPrint(' Data: ${error.response?.data}');
-
}
-
}
-
return handler.next(error);
-
},
+
createAuthInterceptor(
+
sessionGetter: sessionGetter,
+
tokenRefresher: tokenRefresher,
+
signOutHandler: signOutHandler,
+
serviceName: 'VoteService',
+
dio: _dio,
),
);
}
final Future<CovesSession?> Function()? _sessionGetter;
final String? Function()? _didGetter;
-
final Future<bool> Function()? _tokenRefresher;
-
final Future<void> Function()? _signOutHandler;
late final Dio _dio;
/// Collection name for vote records
···
statusCode: e.response?.statusCode,
originalError: e,
);
+
} on ApiException {
+
rethrow;
} on Exception catch (e) {
throw ApiException('Failed to create vote: $e');
}
+73 -65
lib/widgets/comment_card.dart
···
/// - Comment content (supports facets for links/mentions)
/// - Heart vote button with optimistic updates via VoteProvider
/// - Visual threading indicator based on nesting depth
+
/// - Tap-to-reply functionality via [onTap] callback
///
/// The [currentTime] parameter allows passing the current time for
/// time-ago calculations, enabling periodic updates and testing.
···
required this.comment,
this.depth = 0,
this.currentTime,
+
this.onTap,
super.key,
});
final CommentView comment;
final int depth;
final DateTime? currentTime;
+
+
/// Callback when the comment is tapped (for reply functionality)
+
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
···
// the stroke width)
final borderLeftOffset = (threadingLineCount * 6.0) + 2.0;
-
return Container(
-
decoration: const BoxDecoration(color: AppColors.background),
-
child: Stack(
-
children: [
-
// Threading indicators - vertical lines showing nesting ancestry
-
Positioned.fill(
-
child: CustomPaint(
-
painter: _CommentDepthPainter(depth: threadingLineCount),
+
return InkWell(
+
onTap: onTap,
+
child: Container(
+
decoration: const BoxDecoration(color: AppColors.background),
+
child: Stack(
+
children: [
+
// Threading indicators - vertical lines showing nesting ancestry
+
Positioned.fill(
+
child: CustomPaint(
+
painter: _CommentDepthPainter(depth: threadingLineCount),
+
),
+
),
+
// Bottom border (starts after threading lines, not overlapping them)
+
Positioned(
+
left: borderLeftOffset,
+
right: 0,
+
bottom: 0,
+
child: Container(height: 1, color: AppColors.border),
),
-
),
-
// Bottom border (starts after threading lines, not overlapping them)
-
Positioned(
-
left: borderLeftOffset,
-
right: 0,
-
bottom: 0,
-
child: Container(height: 1, color: AppColors.border),
-
),
-
// Comment content with depth-based left padding
-
Padding(
-
padding: EdgeInsets.fromLTRB(leftPadding, 12, 16, 8),
-
child: Column(
-
crossAxisAlignment: CrossAxisAlignment.start,
-
children: [
-
// Author info row
-
Row(
-
children: [
-
// Author avatar
-
_buildAuthorAvatar(comment.author),
-
const SizedBox(width: 8),
-
Expanded(
-
child: Column(
-
crossAxisAlignment: CrossAxisAlignment.start,
-
children: [
-
// Author handle
-
Text(
-
'@${comment.author.handle}',
-
style: TextStyle(
-
color: AppColors.textPrimary.withValues(
-
alpha: 0.5,
+
// Comment content with depth-based left padding
+
Padding(
+
padding: EdgeInsets.fromLTRB(leftPadding, 12, 16, 8),
+
child: Column(
+
crossAxisAlignment: CrossAxisAlignment.start,
+
children: [
+
// Author info row
+
Row(
+
children: [
+
// Author avatar
+
_buildAuthorAvatar(comment.author),
+
const SizedBox(width: 8),
+
Expanded(
+
child: Column(
+
crossAxisAlignment: CrossAxisAlignment.start,
+
children: [
+
// Author handle
+
Text(
+
'@${comment.author.handle}',
+
style: TextStyle(
+
color: AppColors.textPrimary.withValues(
+
alpha: 0.5,
+
),
+
fontSize: 13,
+
fontWeight: FontWeight.w500,
),
-
fontSize: 13,
-
fontWeight: FontWeight.w500,
),
-
),
-
],
+
],
+
),
),
-
),
-
// Time ago
-
Text(
-
DateTimeUtils.formatTimeAgo(
-
comment.createdAt,
-
currentTime: currentTime,
+
// Time ago
+
Text(
+
DateTimeUtils.formatTimeAgo(
+
comment.createdAt,
+
currentTime: currentTime,
+
),
+
style: TextStyle(
+
color: AppColors.textPrimary.withValues(alpha: 0.5),
+
fontSize: 12,
+
),
),
-
style: TextStyle(
-
color: AppColors.textPrimary.withValues(alpha: 0.5),
-
fontSize: 12,
-
),
-
),
+
],
+
),
+
const SizedBox(height: 8),
+
+
// Comment content
+
if (comment.content.isNotEmpty) ...[
+
_buildCommentContent(comment),
+
const SizedBox(height: 8),
],
-
),
-
const SizedBox(height: 8),
-
// Comment content
-
if (comment.content.isNotEmpty) ...[
-
_buildCommentContent(comment),
-
const SizedBox(height: 8),
+
// Action buttons (just vote for now)
+
_buildActionButtons(context),
],
-
-
// Action buttons (just vote for now)
-
_buildActionButtons(context),
-
],
+
),
),
-
),
-
],
+
],
+
),
),
);
}
+8 -1
lib/widgets/comment_thread.dart
···
/// - Indents nested replies visually
/// - Limits nesting depth to prevent excessive indentation
/// - Shows "Load more replies" button when hasMore is true
+
/// - Supports tap-to-reply via [onCommentTap] callback
///
/// The [maxDepth] parameter controls how deeply nested comments can be
/// before they're rendered at the same level to prevent UI overflow.
···
this.maxDepth = 5,
this.currentTime,
this.onLoadMoreReplies,
+
this.onCommentTap,
super.key,
});
···
final int maxDepth;
final DateTime? currentTime;
final VoidCallback? onLoadMoreReplies;
+
+
/// Callback when a comment is tapped (for reply functionality)
+
final void Function(ThreadViewComment)? onCommentTap;
@override
Widget build(BuildContext context) {
···
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
-
// Render the comment
+
// Render the comment with tap handler
CommentCard(
comment: thread.comment,
depth: effectiveDepth,
currentTime: currentTime,
+
onTap: onCommentTap != null ? () => onCommentTap!(thread) : null,
),
// Render replies recursively
···
maxDepth: maxDepth,
currentTime: currentTime,
onLoadMoreReplies: onLoadMoreReplies,
+
onCommentTap: onCommentTap,
),
),
+1 -1
pubspec.lock
···
source: hosted
version: "1.3.1"
characters:
-
dependency: transitive
+
dependency: "direct main"
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
+1
pubspec.yaml
···
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
+
characters: ^1.4.0 # Unicode grapheme cluster support for emoji counting
flutter_secure_storage: ^9.2.2
shared_preferences: ^2.3.3
go_router: ^16.3.0
+447 -8
test/providers/comments_provider_test.dart
···
import 'package:coves_flutter/providers/auth_provider.dart';
import 'package:coves_flutter/providers/comments_provider.dart';
import 'package:coves_flutter/providers/vote_provider.dart';
+
import 'package:coves_flutter/services/api_exceptions.dart';
+
import 'package:coves_flutter/services/comment_service.dart';
import 'package:coves_flutter/services/coves_api_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
···
import 'comments_provider_test.mocks.dart';
// Generate mocks for dependencies
-
@GenerateMocks([AuthProvider, CovesApiService, VoteProvider])
+
@GenerateMocks([AuthProvider, CovesApiService, VoteProvider, CommentService])
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('CommentsProvider', () {
+
const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
+
const testPostCid = 'test-post-cid';
+
late CommentsProvider commentsProvider;
late MockAuthProvider mockAuthProvider;
late MockCovesApiService mockApiService;
···
});
group('loadComments', () {
-
const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
-
test('should load comments successfully', () async {
final mockComments = [
_createMockThreadComment('comment1'),
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
),
).thenAnswer((_) async => secondResponse);
-
await commentsProvider.loadComments(postUri: testPostUri);
+
await commentsProvider.loadComments(postUri: testPostUri, postCid: testPostCid);
expect(commentsProvider.comments.length, 2);
expect(commentsProvider.comments[0].comment.uri, 'comment1');
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
// Load different post
const differentPostUri =
'at://did:plc:test/social.coves.post.record/456';
+
const differentPostCid = 'different-post-cid';
final secondResponse = CommentsResponse(
post: {},
comments: [_createMockThreadComment('comment2')],
···
await commentsProvider.loadComments(
postUri: differentPostUri,
+
postCid: differentPostCid,
refresh: true,
);
···
// Start first load
final firstFuture = commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
// Try to load again while still loading - should schedule a refresh
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
expect(commentsProvider.currentTimeNotifier.value, null);
await commentsProvider.loadComments(
-
postUri: 'at://did:plc:test/social.coves.post.record/123',
+
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
).thenAnswer((_) async => response);
await commentsProvider.loadComments(
-
postUri: 'at://did:plc:test/social.coves.post.record/123',
+
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
).thenAnswer((_) async => response);
await commentsProvider.loadComments(
-
postUri: 'at://did:plc:test/social.coves.post.record/123',
+
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
});
final loadFuture = commentsProvider.loadComments(
-
postUri: 'at://did:plc:test/social.coves.post.record/123',
+
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
// Load first page (refresh)
await commentsProvider.loadComments(
postUri: testPostUri,
+
postCid: testPostCid,
refresh: true,
);
···
);
},
);
+
});
+
+
group('createComment', () {
+
late MockCommentService mockCommentService;
+
late CommentsProvider providerWithCommentService;
+
+
setUp(() {
+
mockCommentService = MockCommentService();
+
+
// Setup mock API service for loadComments
+
final mockResponse = CommentsResponse(
+
post: {},
+
comments: [_createMockThreadComment('comment1')],
+
);
+
when(
+
mockApiService.getComments(
+
postUri: anyNamed('postUri'),
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
depth: anyNamed('depth'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).thenAnswer((_) async => mockResponse);
+
+
providerWithCommentService = CommentsProvider(
+
mockAuthProvider,
+
apiService: mockApiService,
+
voteProvider: mockVoteProvider,
+
commentService: mockCommentService,
+
);
+
});
+
+
tearDown(() {
+
providerWithCommentService.dispose();
+
});
+
+
test('should throw ValidationException for empty content', () async {
+
// First load comments to set up post context
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
expect(
+
() => providerWithCommentService.createComment(content: ''),
+
throwsA(
+
isA<ValidationException>().having(
+
(e) => e.message,
+
'message',
+
contains('empty'),
+
),
+
),
+
);
+
});
+
+
test('should throw ValidationException for whitespace-only content', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
expect(
+
() => providerWithCommentService.createComment(content: ' \n\t '),
+
throwsA(isA<ValidationException>()),
+
);
+
});
+
+
test('should throw ValidationException for content exceeding limit', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
// Create a string longer than 10000 characters
+
final longContent = 'a' * 10001;
+
+
expect(
+
() => providerWithCommentService.createComment(content: longContent),
+
throwsA(
+
isA<ValidationException>().having(
+
(e) => e.message,
+
'message',
+
contains('too long'),
+
),
+
),
+
);
+
});
+
+
test('should count emoji correctly in character limit', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
// Each emoji should count as 1 character, not 2-4 bytes
+
// 9999 'a' chars + 1 emoji = 10000 chars (should pass)
+
final contentAtLimit = '${'a' * 9999}😀';
+
+
when(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: anyNamed('content'),
+
),
+
).thenAnswer(
+
(_) async => const CreateCommentResponse(
+
uri: 'at://did:plc:test/comment/abc',
+
cid: 'cid123',
+
),
+
);
+
+
// This should NOT throw
+
await providerWithCommentService.createComment(content: contentAtLimit);
+
+
verify(
+
mockCommentService.createComment(
+
rootUri: testPostUri,
+
rootCid: testPostCid,
+
parentUri: testPostUri,
+
parentCid: testPostCid,
+
content: contentAtLimit,
+
),
+
).called(1);
+
});
+
+
test('should throw ApiException when no post loaded', () async {
+
// Don't call loadComments first - no post context
+
+
expect(
+
() => providerWithCommentService.createComment(
+
content: 'Test comment',
+
),
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('No post loaded'),
+
),
+
),
+
);
+
});
+
+
test('should throw ApiException when no CommentService', () async {
+
// Create provider without CommentService
+
final providerWithoutService = CommentsProvider(
+
mockAuthProvider,
+
apiService: mockApiService,
+
voteProvider: mockVoteProvider,
+
);
+
+
await providerWithoutService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
expect(
+
() => providerWithoutService.createComment(content: 'Test comment'),
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('CommentService not available'),
+
),
+
),
+
);
+
+
providerWithoutService.dispose();
+
});
+
+
test('should create top-level comment (reply to post)', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
when(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: anyNamed('content'),
+
),
+
).thenAnswer(
+
(_) async => const CreateCommentResponse(
+
uri: 'at://did:plc:test/comment/abc',
+
cid: 'cid123',
+
),
+
);
+
+
await providerWithCommentService.createComment(
+
content: 'This is a test comment',
+
);
+
+
// Verify the comment service was called with correct parameters
+
// Root and parent should both be the post for top-level comments
+
verify(
+
mockCommentService.createComment(
+
rootUri: testPostUri,
+
rootCid: testPostCid,
+
parentUri: testPostUri,
+
parentCid: testPostCid,
+
content: 'This is a test comment',
+
),
+
).called(1);
+
});
+
+
test('should create nested comment (reply to comment)', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
when(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: anyNamed('content'),
+
),
+
).thenAnswer(
+
(_) async => const CreateCommentResponse(
+
uri: 'at://did:plc:test/comment/reply1',
+
cid: 'cidReply',
+
),
+
);
+
+
// Create a parent comment to reply to
+
final parentComment = _createMockThreadComment('parent-comment');
+
+
await providerWithCommentService.createComment(
+
content: 'This is a nested reply',
+
parentComment: parentComment,
+
);
+
+
// Root should still be the post, but parent should be the comment
+
verify(
+
mockCommentService.createComment(
+
rootUri: testPostUri,
+
rootCid: testPostCid,
+
parentUri: 'parent-comment',
+
parentCid: 'cid-parent-comment',
+
content: 'This is a nested reply',
+
),
+
).called(1);
+
});
+
+
test('should trim content before sending', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
when(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: anyNamed('content'),
+
),
+
).thenAnswer(
+
(_) async => const CreateCommentResponse(
+
uri: 'at://did:plc:test/comment/abc',
+
cid: 'cid123',
+
),
+
);
+
+
await providerWithCommentService.createComment(
+
content: ' Hello world! ',
+
);
+
+
// Verify trimmed content was sent
+
verify(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: 'Hello world!',
+
),
+
).called(1);
+
});
+
+
test('should refresh comments after successful creation', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
when(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: anyNamed('content'),
+
),
+
).thenAnswer(
+
(_) async => const CreateCommentResponse(
+
uri: 'at://did:plc:test/comment/abc',
+
cid: 'cid123',
+
),
+
);
+
+
await providerWithCommentService.createComment(
+
content: 'Test comment',
+
);
+
+
// Should have called getComments twice - once for initial load,
+
// once for refresh after comment creation
+
verify(
+
mockApiService.getComments(
+
postUri: anyNamed('postUri'),
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
depth: anyNamed('depth'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).called(2);
+
});
+
+
test('should rethrow exception from CommentService', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
when(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: anyNamed('content'),
+
),
+
).thenThrow(ApiException('Network error'));
+
+
expect(
+
() => providerWithCommentService.createComment(
+
content: 'Test comment',
+
),
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('Network error'),
+
),
+
),
+
);
+
});
+
+
test('should accept content at exactly max length', () async {
+
await providerWithCommentService.loadComments(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
refresh: true,
+
);
+
+
final contentAtLimit = 'a' * CommentsProvider.maxCommentLength;
+
+
when(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: anyNamed('content'),
+
),
+
).thenAnswer(
+
(_) async => const CreateCommentResponse(
+
uri: 'at://did:plc:test/comment/abc',
+
cid: 'cid123',
+
),
+
);
+
+
// Should not throw
+
await providerWithCommentService.createComment(content: contentAtLimit);
+
+
verify(
+
mockCommentService.createComment(
+
rootUri: anyNamed('rootUri'),
+
rootCid: anyNamed('rootCid'),
+
parentUri: anyNamed('parentUri'),
+
parentCid: anyNamed('parentCid'),
+
content: contentAtLimit,
+
),
+
).called(1);
+
});
});
});
+91 -44
test/providers/comments_provider_test.mocks.dart
···
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
-
import 'dart:async' as _i5;
-
import 'dart:ui' as _i6;
+
import 'dart:async' as _i6;
+
import 'dart:ui' as _i7;
import 'package:coves_flutter/models/comment.dart' as _i3;
import 'package:coves_flutter/models/post.dart' as _i2;
-
import 'package:coves_flutter/providers/auth_provider.dart' as _i4;
-
import 'package:coves_flutter/providers/vote_provider.dart' as _i8;
-
import 'package:coves_flutter/services/coves_api_service.dart' as _i7;
+
import 'package:coves_flutter/providers/auth_provider.dart' as _i5;
+
import 'package:coves_flutter/providers/vote_provider.dart' as _i9;
+
import 'package:coves_flutter/services/comment_service.dart' as _i4;
+
import 'package:coves_flutter/services/coves_api_service.dart' as _i8;
import 'package:mockito/mockito.dart' as _i1;
// ignore_for_file: type=lint
···
class _FakeCommentsResponse_1 extends _i1.SmartFake
implements _i3.CommentsResponse {
_FakeCommentsResponse_1(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeCreateCommentResponse_2 extends _i1.SmartFake
+
implements _i4.CreateCommentResponse {
+
_FakeCreateCommentResponse_2(Object parent, Invocation parentInvocation)
: super(parent, parentInvocation);
}
/// A class which mocks [AuthProvider].
///
/// See the documentation for Mockito's code generation for more information.
-
class MockAuthProvider extends _i1.Mock implements _i4.AuthProvider {
+
class MockAuthProvider extends _i1.Mock implements _i5.AuthProvider {
MockAuthProvider() {
_i1.throwOnMissingStub(this);
}
···
as bool);
@override
-
_i5.Future<String?> getAccessToken() =>
+
_i6.Future<String?> getAccessToken() =>
(super.noSuchMethod(
Invocation.method(#getAccessToken, []),
-
returnValue: _i5.Future<String?>.value(),
+
returnValue: _i6.Future<String?>.value(),
)
-
as _i5.Future<String?>);
+
as _i6.Future<String?>);
@override
-
_i5.Future<void> initialize() =>
+
_i6.Future<void> initialize() =>
(super.noSuchMethod(
Invocation.method(#initialize, []),
-
returnValue: _i5.Future<void>.value(),
-
returnValueForMissingStub: _i5.Future<void>.value(),
+
returnValue: _i6.Future<void>.value(),
+
returnValueForMissingStub: _i6.Future<void>.value(),
)
-
as _i5.Future<void>);
+
as _i6.Future<void>);
@override
-
_i5.Future<void> signIn(String? handle) =>
+
_i6.Future<void> signIn(String? handle) =>
(super.noSuchMethod(
Invocation.method(#signIn, [handle]),
-
returnValue: _i5.Future<void>.value(),
-
returnValueForMissingStub: _i5.Future<void>.value(),
+
returnValue: _i6.Future<void>.value(),
+
returnValueForMissingStub: _i6.Future<void>.value(),
)
-
as _i5.Future<void>);
+
as _i6.Future<void>);
@override
-
_i5.Future<void> signOut() =>
+
_i6.Future<void> signOut() =>
(super.noSuchMethod(
Invocation.method(#signOut, []),
-
returnValue: _i5.Future<void>.value(),
-
returnValueForMissingStub: _i5.Future<void>.value(),
+
returnValue: _i6.Future<void>.value(),
+
returnValueForMissingStub: _i6.Future<void>.value(),
)
-
as _i5.Future<void>);
+
as _i6.Future<void>);
@override
-
_i5.Future<bool> refreshToken() =>
+
_i6.Future<bool> refreshToken() =>
(super.noSuchMethod(
Invocation.method(#refreshToken, []),
-
returnValue: _i5.Future<bool>.value(false),
+
returnValue: _i6.Future<bool>.value(false),
)
-
as _i5.Future<bool>);
+
as _i6.Future<bool>);
@override
void clearError() => super.noSuchMethod(
···
);
@override
-
void addListener(_i6.VoidCallback? listener) => super.noSuchMethod(
+
void addListener(_i7.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#addListener, [listener]),
returnValueForMissingStub: null,
);
@override
-
void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod(
+
void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#removeListener, [listener]),
returnValueForMissingStub: null,
);
···
/// A class which mocks [CovesApiService].
///
/// See the documentation for Mockito's code generation for more information.
-
class MockCovesApiService extends _i1.Mock implements _i7.CovesApiService {
+
class MockCovesApiService extends _i1.Mock implements _i8.CovesApiService {
MockCovesApiService() {
_i1.throwOnMissingStub(this);
}
@override
-
_i5.Future<_i2.TimelineResponse> getTimeline({
+
_i6.Future<_i2.TimelineResponse> getTimeline({
String? sort = 'hot',
String? timeframe,
int? limit = 15,
···
#limit: limit,
#cursor: cursor,
}),
-
returnValue: _i5.Future<_i2.TimelineResponse>.value(
+
returnValue: _i6.Future<_i2.TimelineResponse>.value(
_FakeTimelineResponse_0(
this,
Invocation.method(#getTimeline, [], {
···
),
),
)
-
as _i5.Future<_i2.TimelineResponse>);
+
as _i6.Future<_i2.TimelineResponse>);
@override
-
_i5.Future<_i2.TimelineResponse> getDiscover({
+
_i6.Future<_i2.TimelineResponse> getDiscover({
String? sort = 'hot',
String? timeframe,
int? limit = 15,
···
#limit: limit,
#cursor: cursor,
}),
-
returnValue: _i5.Future<_i2.TimelineResponse>.value(
+
returnValue: _i6.Future<_i2.TimelineResponse>.value(
_FakeTimelineResponse_0(
this,
Invocation.method(#getDiscover, [], {
···
),
),
)
-
as _i5.Future<_i2.TimelineResponse>);
+
as _i6.Future<_i2.TimelineResponse>);
@override
-
_i5.Future<_i3.CommentsResponse> getComments({
+
_i6.Future<_i3.CommentsResponse> getComments({
required String? postUri,
String? sort = 'hot',
String? timeframe,
···
#limit: limit,
#cursor: cursor,
}),
-
returnValue: _i5.Future<_i3.CommentsResponse>.value(
+
returnValue: _i6.Future<_i3.CommentsResponse>.value(
_FakeCommentsResponse_1(
this,
Invocation.method(#getComments, [], {
···
),
),
)
-
as _i5.Future<_i3.CommentsResponse>);
+
as _i6.Future<_i3.CommentsResponse>);
@override
void dispose() => super.noSuchMethod(
···
/// A class which mocks [VoteProvider].
///
/// See the documentation for Mockito's code generation for more information.
-
class MockVoteProvider extends _i1.Mock implements _i8.VoteProvider {
+
class MockVoteProvider extends _i1.Mock implements _i9.VoteProvider {
MockVoteProvider() {
_i1.throwOnMissingStub(this);
}
···
);
@override
-
_i8.VoteState? getVoteState(String? postUri) =>
+
_i9.VoteState? getVoteState(String? postUri) =>
(super.noSuchMethod(Invocation.method(#getVoteState, [postUri]))
-
as _i8.VoteState?);
+
as _i9.VoteState?);
@override
bool isLiked(String? postUri) =>
···
as int);
@override
-
_i5.Future<bool> toggleVote({
+
_i6.Future<bool> toggleVote({
required String? postUri,
required String? postCid,
String? direction = 'up',
···
#postCid: postCid,
#direction: direction,
}),
-
returnValue: _i5.Future<bool>.value(false),
+
returnValue: _i6.Future<bool>.value(false),
)
-
as _i5.Future<bool>);
+
as _i6.Future<bool>);
@override
void setInitialVoteState({
···
);
@override
-
void addListener(_i6.VoidCallback? listener) => super.noSuchMethod(
+
void addListener(_i7.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#addListener, [listener]),
returnValueForMissingStub: null,
);
@override
-
void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod(
+
void removeListener(_i7.VoidCallback? listener) => super.noSuchMethod(
Invocation.method(#removeListener, [listener]),
returnValueForMissingStub: null,
);
···
returnValueForMissingStub: null,
);
}
+
+
/// A class which mocks [CommentService].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockCommentService extends _i1.Mock implements _i4.CommentService {
+
MockCommentService() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i6.Future<_i4.CreateCommentResponse> createComment({
+
required String? rootUri,
+
required String? rootCid,
+
required String? parentUri,
+
required String? parentCid,
+
required String? content,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#createComment, [], {
+
#rootUri: rootUri,
+
#rootCid: rootCid,
+
#parentUri: parentUri,
+
#parentCid: parentCid,
+
#content: content,
+
}),
+
returnValue: _i6.Future<_i4.CreateCommentResponse>.value(
+
_FakeCreateCommentResponse_2(
+
this,
+
Invocation.method(#createComment, [], {
+
#rootUri: rootUri,
+
#rootCid: rootCid,
+
#parentUri: parentUri,
+
#parentCid: parentCid,
+
#content: content,
+
}),
+
),
+
),
+
)
+
as _i6.Future<_i4.CreateCommentResponse>);
+
}
+357
test/services/comment_service_test.dart
···
+
import 'package:coves_flutter/models/coves_session.dart';
+
import 'package:coves_flutter/services/api_exceptions.dart';
+
import 'package:coves_flutter/services/comment_service.dart';
+
import 'package:dio/dio.dart';
+
import 'package:flutter_test/flutter_test.dart';
+
import 'package:mockito/annotations.dart';
+
import 'package:mockito/mockito.dart';
+
+
import 'comment_service_test.mocks.dart';
+
+
@GenerateMocks([Dio])
+
void main() {
+
group('CommentService', () {
+
group('CreateCommentResponse', () {
+
test('should create response with uri and cid', () {
+
const response = CreateCommentResponse(
+
uri: 'at://did:plc:test/social.coves.community.comment/123',
+
cid: 'bafy123',
+
);
+
+
expect(
+
response.uri,
+
'at://did:plc:test/social.coves.community.comment/123',
+
);
+
expect(response.cid, 'bafy123');
+
});
+
});
+
+
group('createComment', () {
+
late MockDio mockDio;
+
late CommentService commentService;
+
late CovesSession testSession;
+
+
setUp(() {
+
mockDio = MockDio();
+
testSession = CovesSession(
+
token: 'test-token',
+
did: 'did:plc:test',
+
sessionId: 'test-session-id',
+
handle: 'test.user',
+
);
+
+
// Setup default interceptors behavior
+
when(mockDio.interceptors).thenReturn(Interceptors());
+
+
commentService = CommentService(
+
sessionGetter: () async => testSession,
+
tokenRefresher: () async => true,
+
signOutHandler: () async {},
+
dio: mockDio,
+
);
+
});
+
+
test('should create comment successfully', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 200,
+
data: {
+
'uri': 'at://did:plc:test/social.coves.community.comment/abc123',
+
'cid': 'bafy123',
+
},
+
),
+
);
+
+
final response = await commentService.createComment(
+
rootUri: 'at://did:plc:author/social.coves.post.record/post123',
+
rootCid: 'rootCid123',
+
parentUri: 'at://did:plc:author/social.coves.post.record/post123',
+
parentCid: 'parentCid123',
+
content: 'This is a test comment',
+
);
+
+
expect(
+
response.uri,
+
'at://did:plc:test/social.coves.community.comment/abc123',
+
);
+
expect(response.cid, 'bafy123');
+
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: {
+
'reply': {
+
'root': {
+
'uri': 'at://did:plc:author/social.coves.post.record/post123',
+
'cid': 'rootCid123',
+
},
+
'parent': {
+
'uri': 'at://did:plc:author/social.coves.post.record/post123',
+
'cid': 'parentCid123',
+
},
+
},
+
'content': 'This is a test comment',
+
},
+
),
+
).called(1);
+
});
+
+
test('should throw AuthenticationException when no session', () async {
+
final serviceWithoutSession = CommentService(
+
sessionGetter: () async => null,
+
tokenRefresher: () async => true,
+
signOutHandler: () async {},
+
dio: mockDio,
+
);
+
+
expect(
+
() => serviceWithoutSession.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(isA<AuthenticationException>()),
+
);
+
});
+
+
test('should throw ApiException on network error', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
+
DioException(
+
requestOptions: RequestOptions(path: ''),
+
type: DioExceptionType.connectionError,
+
message: 'Connection failed',
+
),
+
);
+
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(isA<ApiException>()),
+
);
+
});
+
+
test('should throw AuthenticationException on 401 response', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
+
DioException(
+
requestOptions: RequestOptions(path: ''),
+
type: DioExceptionType.badResponse,
+
response: Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 401,
+
data: {'error': 'Unauthorized'},
+
),
+
),
+
);
+
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(isA<AuthenticationException>()),
+
);
+
});
+
+
test('should throw ApiException on invalid response (null data)', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 200,
+
data: null,
+
),
+
);
+
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('no data'),
+
),
+
),
+
);
+
});
+
+
test('should throw ApiException on invalid response (missing uri)', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 200,
+
data: {'cid': 'bafy123'},
+
),
+
);
+
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('missing uri'),
+
),
+
),
+
);
+
});
+
+
test('should throw ApiException on invalid response (empty uri)', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 200,
+
data: {'uri': '', 'cid': 'bafy123'},
+
),
+
);
+
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(
+
isA<ApiException>().having(
+
(e) => e.message,
+
'message',
+
contains('missing uri'),
+
),
+
),
+
);
+
});
+
+
test('should throw ApiException on server error', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
+
DioException(
+
requestOptions: RequestOptions(path: ''),
+
type: DioExceptionType.badResponse,
+
response: Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 500,
+
data: {'error': 'Internal server error'},
+
),
+
message: 'Internal server error',
+
),
+
);
+
+
expect(
+
() => commentService.createComment(
+
rootUri: 'at://did:plc:author/post/123',
+
rootCid: 'rootCid',
+
parentUri: 'at://did:plc:author/post/123',
+
parentCid: 'parentCid',
+
content: 'Test comment',
+
),
+
throwsA(isA<ApiException>()),
+
);
+
});
+
+
test('should send correct parent for nested reply', () async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: ''),
+
statusCode: 200,
+
data: {
+
'uri': 'at://did:plc:test/social.coves.community.comment/reply1',
+
'cid': 'bafyReply',
+
},
+
),
+
);
+
+
await commentService.createComment(
+
rootUri: 'at://did:plc:author/social.coves.post.record/post123',
+
rootCid: 'postCid',
+
parentUri:
+
'at://did:plc:commenter/social.coves.community.comment/comment1',
+
parentCid: 'commentCid',
+
content: 'This is a nested reply',
+
);
+
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: {
+
'reply': {
+
'root': {
+
'uri': 'at://did:plc:author/social.coves.post.record/post123',
+
'cid': 'postCid',
+
},
+
'parent': {
+
'uri':
+
'at://did:plc:commenter/social.coves.community.comment/'
+
'comment1',
+
'cid': 'commentCid',
+
},
+
},
+
'content': 'This is a nested reply',
+
},
+
),
+
).called(1);
+
});
+
});
+
});
+
}
+806
test/services/comment_service_test.mocks.dart
···
+
// Mocks generated by Mockito 5.4.6 from annotations
+
// in coves_flutter/test/services/comment_service_test.dart.
+
// Do not manually edit this file.
+
+
// ignore_for_file: no_leading_underscores_for_library_prefixes
+
import 'dart:async' as _i8;
+
+
import 'package:dio/src/adapter.dart' as _i4;
+
import 'package:dio/src/cancel_token.dart' as _i9;
+
import 'package:dio/src/dio.dart' as _i7;
+
import 'package:dio/src/dio_mixin.dart' as _i3;
+
import 'package:dio/src/options.dart' as _i2;
+
import 'package:dio/src/response.dart' as _i6;
+
import 'package:dio/src/transformer.dart' as _i5;
+
import 'package:mockito/mockito.dart' as _i1;
+
+
// ignore_for_file: type=lint
+
// ignore_for_file: avoid_redundant_argument_values
+
// ignore_for_file: avoid_setters_without_getters
+
// ignore_for_file: comment_references
+
// ignore_for_file: deprecated_member_use
+
// ignore_for_file: deprecated_member_use_from_same_package
+
// ignore_for_file: implementation_imports
+
// ignore_for_file: invalid_use_of_visible_for_testing_member
+
// ignore_for_file: must_be_immutable
+
// ignore_for_file: prefer_const_constructors
+
// ignore_for_file: unnecessary_parenthesis
+
// ignore_for_file: camel_case_types
+
// ignore_for_file: subtype_of_sealed_class
+
// ignore_for_file: invalid_use_of_internal_member
+
+
class _FakeBaseOptions_0 extends _i1.SmartFake implements _i2.BaseOptions {
+
_FakeBaseOptions_0(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeInterceptors_1 extends _i1.SmartFake implements _i3.Interceptors {
+
_FakeInterceptors_1(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeHttpClientAdapter_2 extends _i1.SmartFake
+
implements _i4.HttpClientAdapter {
+
_FakeHttpClientAdapter_2(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeTransformer_3 extends _i1.SmartFake implements _i5.Transformer {
+
_FakeTransformer_3(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeResponse_4<T1> extends _i1.SmartFake implements _i6.Response<T1> {
+
_FakeResponse_4(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeDio_5 extends _i1.SmartFake implements _i7.Dio {
+
_FakeDio_5(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
/// A class which mocks [Dio].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockDio extends _i1.Mock implements _i7.Dio {
+
MockDio() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i2.BaseOptions get options =>
+
(super.noSuchMethod(
+
Invocation.getter(#options),
+
returnValue: _FakeBaseOptions_0(this, Invocation.getter(#options)),
+
)
+
as _i2.BaseOptions);
+
+
@override
+
_i3.Interceptors get interceptors =>
+
(super.noSuchMethod(
+
Invocation.getter(#interceptors),
+
returnValue: _FakeInterceptors_1(
+
this,
+
Invocation.getter(#interceptors),
+
),
+
)
+
as _i3.Interceptors);
+
+
@override
+
_i4.HttpClientAdapter get httpClientAdapter =>
+
(super.noSuchMethod(
+
Invocation.getter(#httpClientAdapter),
+
returnValue: _FakeHttpClientAdapter_2(
+
this,
+
Invocation.getter(#httpClientAdapter),
+
),
+
)
+
as _i4.HttpClientAdapter);
+
+
@override
+
_i5.Transformer get transformer =>
+
(super.noSuchMethod(
+
Invocation.getter(#transformer),
+
returnValue: _FakeTransformer_3(
+
this,
+
Invocation.getter(#transformer),
+
),
+
)
+
as _i5.Transformer);
+
+
@override
+
set options(_i2.BaseOptions? value) => super.noSuchMethod(
+
Invocation.setter(#options, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
set httpClientAdapter(_i4.HttpClientAdapter? value) => super.noSuchMethod(
+
Invocation.setter(#httpClientAdapter, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
set transformer(_i5.Transformer? value) => super.noSuchMethod(
+
Invocation.setter(#transformer, value),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void close({bool? force = false}) => super.noSuchMethod(
+
Invocation.method(#close, [], {#force: force}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i8.Future<_i6.Response<T>> head<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> headUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> get<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> getUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> post<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#post,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#post,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> postUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> put<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#put,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#put,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> putUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> patch<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#patch,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patch,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> patchUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> delete<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> deleteUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i9.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<dynamic>> download(
+
String? urlPath,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
Map<String, dynamic>? queryParameters,
+
_i9.CancelToken? cancelToken,
+
bool? deleteOnError = true,
+
_i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write,
+
String? lengthHeader = 'content-length',
+
Object? data,
+
_i2.Options? options,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#download,
+
[urlPath, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<dynamic>>.value(
+
_FakeResponse_4<dynamic>(
+
this,
+
Invocation.method(
+
#download,
+
[urlPath, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i8.Future<_i6.Response<dynamic>> downloadUri(
+
Uri? uri,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
_i9.CancelToken? cancelToken,
+
bool? deleteOnError = true,
+
_i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write,
+
String? lengthHeader = 'content-length',
+
Object? data,
+
_i2.Options? options,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#downloadUri,
+
[uri, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<dynamic>>.value(
+
_FakeResponse_4<dynamic>(
+
this,
+
Invocation.method(
+
#downloadUri,
+
[uri, savePath],
+
{
+
#onReceiveProgress: onReceiveProgress,
+
#cancelToken: cancelToken,
+
#deleteOnError: deleteOnError,
+
#fileAccessMode: fileAccessMode,
+
#lengthHeader: lengthHeader,
+
#data: data,
+
#options: options,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> request<T>(
+
String? url, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i9.CancelToken? cancelToken,
+
_i2.Options? options,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#request,
+
[url],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#request,
+
[url],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> requestUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i9.CancelToken? cancelToken,
+
_i2.Options? options,
+
_i2.ProgressCallback? onSendProgress,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i8.Future<_i6.Response<T>> fetch<T>(_i2.RequestOptions? requestOptions) =>
+
(super.noSuchMethod(
+
Invocation.method(#fetch, [requestOptions]),
+
returnValue: _i8.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(#fetch, [requestOptions]),
+
),
+
),
+
)
+
as _i8.Future<_i6.Response<T>>);
+
+
@override
+
_i7.Dio clone({
+
_i2.BaseOptions? options,
+
_i3.Interceptors? interceptors,
+
_i4.HttpClientAdapter? httpClientAdapter,
+
_i5.Transformer? transformer,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#clone, [], {
+
#options: options,
+
#interceptors: interceptors,
+
#httpClientAdapter: httpClientAdapter,
+
#transformer: transformer,
+
}),
+
returnValue: _FakeDio_5(
+
this,
+
Invocation.method(#clone, [], {
+
#options: options,
+
#interceptors: interceptors,
+
#httpClientAdapter: httpClientAdapter,
+
#transformer: transformer,
+
}),
+
),
+
)
+
as _i7.Dio);
+
}