Compare changes

Choose any two refs to compare.

+10
ios/Runner/Runner.entitlements
···
+
<?xml version="1.0" encoding="UTF-8"?>
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+
<plist version="1.0">
+
<dict>
+
<key>com.apple.developer.associated-domains</key>
+
<array>
+
<string>applinks:coves.social</string>
+
</array>
+
</dict>
+
</plist>
+11 -4
test/widgets/feed_screen_test.dart
···
voteService: VoteService(
sessionGetter: () async => null,
didGetter: () => null,
-
pdsUrlGetter: () => null,
),
authProvider: FakeAuthProvider(),
);
···
community: CommunityRef(
did: 'did:plc:community',
name: 'test-community',
+
handle: 'test-community.community.coves.social',
),
createdAt: DateTime.now(),
indexedAt: DateTime.now(),
···
await tester.pumpWidget(createTestWidget());
-
expect(find.text('c/test-community'), findsOneWidget);
+
// Check for community handle parts (displayed as !test-community@coves.social)
+
expect(find.textContaining('!test-community'), findsOneWidget);
expect(find.text('@test.user'), findsOneWidget);
});
···
// Verify post card exists (which contains Semantics wrapper)
expect(find.text('Accessible Post'), findsOneWidget);
-
expect(find.text('c/test-community'), findsOneWidget);
+
// Check for community handle parts (displayed as !test-community@coves.social)
+
expect(find.textContaining('!test-community'), findsOneWidget);
+
expect(find.textContaining('@coves.social'), findsOneWidget);
});
testWidgets('should properly dispose scroll controller', (tester) async {
···
handle: 'test.user',
displayName: 'Test User',
),
-
community: CommunityRef(did: 'did:plc:community', name: 'test-community'),
+
community: CommunityRef(
+
did: 'did:plc:community',
+
name: 'test-community',
+
handle: 'test-community.community.coves.social',
+
),
createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
text: 'Test body',
+9 -10
lib/services/coves_api_service.dart
···
Future<bool> Function()? tokenRefresher,
Future<void> Function()? signOutHandler,
Dio? dio,
-
}) : _tokenGetter = tokenGetter,
-
_tokenRefresher = tokenRefresher,
-
_signOutHandler = signOutHandler {
+
}) : _tokenGetter = tokenGetter,
+
_tokenRefresher = tokenRefresher,
+
_signOutHandler = signOutHandler {
_dio =
dio ??
Dio(
···
}
// Don't retry the refresh endpoint itself (avoid infinite loop)
-
final isRefreshEndpoint =
-
error.requestOptions.path.contains('/oauth/refresh');
+
final isRefreshEndpoint = error.requestOptions.path.contains(
+
'/oauth/refresh',
+
);
if (isRefreshEndpoint) {
if (kDebugMode) {
debugPrint(
···
}
// 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 &&
+
final isRetriedRequest =
+
e is DioException &&
e.response?.statusCode == 401 &&
e.requestOptions.extra['retried'] == true;
···
if (kDebugMode) {
debugPrint('โŒ Error parsing comments response: $e');
}
-
throw ApiException(
-
'Failed to parse server response',
-
originalError: e,
-
);
+
throw ApiException('Failed to parse server response', originalError: e);
}
}
+12 -21
lib/services/coves_auth_service.dart
···
/// 4. Call /oauth/refresh when needed
/// 5. Call /oauth/logout to sign out
class CovesAuthService {
-
factory CovesAuthService({
-
Dio? dio,
-
FlutterSecureStorage? storage,
-
}) {
+
factory CovesAuthService({Dio? dio, FlutterSecureStorage? storage}) {
_instance ??= CovesAuthService._internal(dio: dio, storage: storage);
return _instance!;
}
-
CovesAuthService._internal({
-
Dio? dio,
-
FlutterSecureStorage? storage,
-
}) : _storage = storage ??
-
const FlutterSecureStorage(
-
aOptions: AndroidOptions(encryptedSharedPreferences: true),
-
iOptions: IOSOptions(
-
accessibility: KeychainAccessibility.first_unlock,
-
),
-
) {
+
CovesAuthService._internal({Dio? dio, FlutterSecureStorage? storage})
+
: _storage =
+
storage ??
+
const FlutterSecureStorage(
+
aOptions: AndroidOptions(encryptedSharedPreferences: true),
+
iOptions: IOSOptions(
+
accessibility: KeychainAccessibility.first_unlock,
+
),
+
) {
// Initialize Dio if provided, otherwise it will be initialized in initialize()
if (dio != null) {
_dio = dio;
···
await _dio.post<void>(
'/oauth/logout',
options: Options(
-
headers: {
-
'Authorization': 'Bearer ${_session!.token}',
-
},
+
headers: {'Authorization': 'Bearer ${_session!.token}'},
),
);
···
/// Save session to secure storage
Future<void> _saveSession(CovesSession session) async {
-
await _storage.write(
-
key: _storageKey,
-
value: session.toJsonString(),
-
);
+
await _storage.write(key: _storageKey, value: session.toJsonString());
}
/// Clear session from secure storage
-1
lib/widgets/icons/bluesky_icons.dart
···
/// Bluesky-style navigation icons using SVG assets
/// These icons match the design from Bluesky's social-app
class BlueSkyIcon extends StatelessWidget {
-
const BlueSkyIcon({
required this.iconName,
this.size = 28,
+1 -3
lib/widgets/post_action_bar.dart
···
return Container(
decoration: const BoxDecoration(
color: AppColors.background,
-
border: Border(
-
top: BorderSide(color: AppColors.backgroundSecondary),
-
),
+
border: Border(top: BorderSide(color: AppColors.backgroundSecondary)),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SafeArea(
+15 -18
lib/widgets/post_card.dart
···
),
)
else
-
// Title when navigation is disabled
-
if (post.post.title != null) ...[
-
Text(
-
post.post.title!,
-
style: TextStyle(
-
color: AppColors.textPrimary,
-
fontSize: titleFontSize,
-
fontWeight: titleFontWeight,
-
height: 1.3,
-
),
+
// Title when navigation is disabled
+
if (post.post.title != null) ...[
+
Text(
+
post.post.title!,
+
style: TextStyle(
+
color: AppColors.textPrimary,
+
fontSize: titleFontSize,
+
fontWeight: titleFontWeight,
+
height: 1.3,
),
-
if (post.post.embed?.external != null ||
-
post.post.text.isNotEmpty)
-
const SizedBox(height: 8),
-
],
+
),
+
if (post.post.embed?.external != null ||
+
post.post.text.isNotEmpty)
+
const SizedBox(height: 8),
+
],
// Embed (handles its own taps - not wrapped in InkWell)
if (post.post.embed?.external != null) ...[
···
// For non-video embeds (images, link previews), make them tappable
// to navigate to post detail
if (widget.onImageTap != null) {
-
return GestureDetector(
-
onTap: widget.onImageTap,
-
child: thumbnailWidget,
-
);
+
return GestureDetector(onTap: widget.onImageTap, child: thumbnailWidget);
}
// No tap handler provided, just return the thumbnail
+6 -8
lib/widgets/post_card_actions.dart
···
Icon(
Icons.chat_bubble_outline,
size: 20,
-
color:
-
AppColors.textPrimary.withValues(
-
alpha: 0.6,
-
),
+
color: AppColors.textPrimary.withValues(
+
alpha: 0.6,
+
),
),
const SizedBox(width: 5),
Text(
DateTimeUtils.formatCount(count),
style: TextStyle(
-
color:
-
AppColors.textPrimary.withValues(
-
alpha: 0.6,
-
),
+
color: AppColors.textPrimary.withValues(
+
alpha: 0.6,
+
),
fontSize: 13,
),
),
+13 -37
test/models/coves_session_test.dart
···
'session_id': 'sess456',
};
-
expect(
-
() => CovesSession.fromJson(json),
-
throwsA(isA<TypeError>()),
-
);
+
expect(() => CovesSession.fromJson(json), throwsA(isA<TypeError>()));
});
test('should throw when did has wrong type', () {
···
'session_id': 'sess456',
};
-
expect(
-
() => CovesSession.fromJson(json),
-
throwsA(isA<TypeError>()),
-
);
+
expect(() => CovesSession.fromJson(json), throwsA(isA<TypeError>()));
});
test('should throw when session_id has wrong type', () {
···
'session_id': 123, // Should be String
};
-
expect(
-
() => CovesSession.fromJson(json),
-
throwsA(isA<TypeError>()),
-
);
+
expect(() => CovesSession.fromJson(json), throwsA(isA<TypeError>()));
});
test('should throw when token field is missing', () {
-
final json = {
-
'did': 'did:plc:test123',
-
'session_id': 'sess456',
-
};
+
final json = {'did': 'did:plc:test123', 'session_id': 'sess456'};
-
expect(
-
() => CovesSession.fromJson(json),
-
throwsA(isA<TypeError>()),
-
);
+
expect(() => CovesSession.fromJson(json), throwsA(isA<TypeError>()));
});
test('should throw when did field is missing', () {
-
final json = {
-
'token': 'abc123',
-
'session_id': 'sess456',
-
};
+
final json = {'token': 'abc123', 'session_id': 'sess456'};
-
expect(
-
() => CovesSession.fromJson(json),
-
throwsA(isA<TypeError>()),
-
);
+
expect(() => CovesSession.fromJson(json), throwsA(isA<TypeError>()));
});
test('should throw when session_id field is missing', () {
-
final json = {
-
'token': 'abc123',
-
'did': 'did:plc:test123',
-
};
+
final json = {'token': 'abc123', 'did': 'did:plc:test123'};
-
expect(
-
() => CovesSession.fromJson(json),
-
throwsA(isA<TypeError>()),
-
);
+
expect(() => CovesSession.fromJson(json), throwsA(isA<TypeError>()));
});
test('should handle extra fields in JSON', () {
···
final stringRep = session.toString();
expect(stringRep, isNot(contains('Bearer')));
-
expect(stringRep, isNot(contains('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')));
+
expect(
+
stringRep,
+
isNot(contains('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')),
+
);
});
});
}
+3 -2
test/providers/auth_provider_test.dart
···
await authProvider.signIn('alice.bsky.social');
// Sign out with error
-
when(mockAuthService.signOut())
-
.thenThrow(Exception('Revocation failed'));
+
when(
+
mockAuthService.signOut(),
+
).thenThrow(Exception('Revocation failed'));
await authProvider.signOut();
+95 -88
test/services/coves_api_service_token_refresh_test.dart
···
apiService.dispose();
});
-
test('should call token refresher on 401 response but only retry once',
-
() async {
+
test('should call token refresher on 401 response but only retry once', () async {
// This test verifies the interceptor detects 401, calls the refresher,
// and only retries ONCE to prevent infinite loops (even if retry returns 401).
···
expect(signOutCallCount, 1);
});
-
test('should NOT retry refresh endpoint on 401 (avoid infinite loop)',
-
() async {
-
// This test verifies that the interceptor checks for /oauth/refresh
-
// in the path to avoid infinite loops. Due to limitations with mocking
-
// complex request/response cycles, we test this by verifying the
-
// signOutHandler gets called when refresh fails.
-
-
// Set refresh to fail (simulates refresh endpoint returning 401)
-
shouldRefreshSucceed = false;
-
-
const postUri = 'at://did:plc:test/social.coves.post.record/123';
-
-
dioAdapter.onGet(
-
'/xrpc/social.coves.community.comment.getComments',
-
(server) => server.reply(401, {
-
'error': 'Unauthorized',
-
'message': 'Token expired',
-
}),
-
queryParameters: {
-
'post': postUri,
-
'sort': 'hot',
-
'depth': 10,
-
'limit': 50,
-
},
-
);
-
-
// Make the request and expect it to fail
-
expect(
-
() => apiService.getComments(postUri: postUri),
-
throwsA(isA<Exception>()),
-
);
-
-
// Wait for async operations to complete
-
await Future.delayed(const Duration(milliseconds: 100));
-
-
// Verify user was signed out (no infinite loop)
-
expect(signOutCallCount, 1);
-
});
-
-
test('should sign out user if token refresh throws exception', () async {
-
// Skipped: causes retry loops with http_mock_adapter after disposal
-
// The core functionality is tested by the "should sign out user if token
-
// refresh fails" test above.
-
}, skip: 'Causes retry issues with http_mock_adapter');
-
-
test('should handle 401 gracefully when no refresher is provided',
-
() async {
-
// Create API service without refresh capability
-
final apiServiceNoRefresh = CovesApiService(
-
dio: dio,
-
tokenGetter: mockTokenGetter,
-
// No tokenRefresher provided
-
// No signOutHandler provided
-
);
-
-
const postUri = 'at://did:plc:test/social.coves.post.record/123';
-
-
// Request returns 401
-
dioAdapter.onGet(
-
'/xrpc/social.coves.community.comment.getComments',
-
(server) => server.reply(401, {
-
'error': 'Unauthorized',
-
'message': 'Token expired',
-
}),
-
queryParameters: {
-
'post': postUri,
-
'sort': 'hot',
-
'depth': 10,
-
'limit': 50,
-
},
-
);
-
-
// Make the request and expect it to fail with AuthenticationException
-
expect(
-
() => apiServiceNoRefresh.getComments(postUri: postUri),
-
throwsA(isA<Exception>()),
-
);
-
-
// Verify refresh was NOT called (no refresher provided)
-
expect(tokenRefreshCallCount, 0);
-
-
// Verify sign-out was NOT called (no handler provided)
-
expect(signOutCallCount, 0);
-
-
apiServiceNoRefresh.dispose();
-
});
+
test(
+
'should NOT retry refresh endpoint on 401 (avoid infinite loop)',
+
() async {
+
// This test verifies that the interceptor checks for /oauth/refresh
+
// in the path to avoid infinite loops. Due to limitations with mocking
+
// complex request/response cycles, we test this by verifying the
+
// signOutHandler gets called when refresh fails.
+
+
// Set refresh to fail (simulates refresh endpoint returning 401)
+
shouldRefreshSucceed = false;
+
+
const postUri = 'at://did:plc:test/social.coves.post.record/123';
+
+
dioAdapter.onGet(
+
'/xrpc/social.coves.community.comment.getComments',
+
(server) => server.reply(401, {
+
'error': 'Unauthorized',
+
'message': 'Token expired',
+
}),
+
queryParameters: {
+
'post': postUri,
+
'sort': 'hot',
+
'depth': 10,
+
'limit': 50,
+
},
+
);
+
+
// Make the request and expect it to fail
+
expect(
+
() => apiService.getComments(postUri: postUri),
+
throwsA(isA<Exception>()),
+
);
+
+
// Wait for async operations to complete
+
await Future.delayed(const Duration(milliseconds: 100));
+
+
// Verify user was signed out (no infinite loop)
+
expect(signOutCallCount, 1);
+
},
+
);
+
+
test(
+
'should sign out user if token refresh throws exception',
+
() async {
+
// Skipped: causes retry loops with http_mock_adapter after disposal
+
// The core functionality is tested by the "should sign out user if token
+
// refresh fails" test above.
+
},
+
skip: 'Causes retry issues with http_mock_adapter',
+
);
+
+
test(
+
'should handle 401 gracefully when no refresher is provided',
+
() async {
+
// Create API service without refresh capability
+
final apiServiceNoRefresh = CovesApiService(
+
dio: dio,
+
tokenGetter: mockTokenGetter,
+
// No tokenRefresher provided
+
// No signOutHandler provided
+
);
+
+
const postUri = 'at://did:plc:test/social.coves.post.record/123';
+
+
// Request returns 401
+
dioAdapter.onGet(
+
'/xrpc/social.coves.community.comment.getComments',
+
(server) => server.reply(401, {
+
'error': 'Unauthorized',
+
'message': 'Token expired',
+
}),
+
queryParameters: {
+
'post': postUri,
+
'sort': 'hot',
+
'depth': 10,
+
'limit': 50,
+
},
+
);
+
+
// Make the request and expect it to fail with AuthenticationException
+
expect(
+
() => apiServiceNoRefresh.getComments(postUri: postUri),
+
throwsA(isA<Exception>()),
+
);
+
+
// Verify refresh was NOT called (no refresher provided)
+
expect(tokenRefreshCallCount, 0);
+
+
// Verify sign-out was NOT called (no handler provided)
+
expect(signOutCallCount, 0);
+
+
apiServiceNoRefresh.dispose();
+
},
+
);
// Skipped: http_mock_adapter cannot handle stateful request/response cycles
+39 -30
test/services/coves_auth_service_environment_test.dart
···
);
// Mock storage read for the environment-specific key
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
// Act - Restore session
final result = await authService.restoreSession();
···
handle: 'alice.bsky.social',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Mock successful refresh
const newToken = 'new-refreshed-token';
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
statusCode: 200,
-
data: {
-
'sealed_token': newToken,
-
'access_token': 'some-access-token'
-
},
-
));
-
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
statusCode: 200,
+
data: {'sealed_token': newToken, 'access_token': 'some-access-token'},
+
),
+
);
+
+
when(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
// Act - Refresh token (which saves the updated session)
await authService.refreshToken();
// Assert - Verify environment-specific key was used for saving
-
verify(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.called(1);
+
verify(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).called(1);
// Verify the generic key was never used
-
verifyNever(mockStorage.write(key: 'coves_session', value: anyNamed('value')));
+
verifyNever(
+
mockStorage.write(key: 'coves_session', value: anyNamed('value')),
+
);
});
test('should delete sessions using environment-specific keys', () async {
···
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Mock logout
-
when(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/logout'),
-
statusCode: 200,
-
));
+
when(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/logout'),
+
statusCode: 200,
+
),
+
);
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
+561 -443
test/services/coves_auth_service_test.dart
···
group('CovesAuthService', () {
group('signIn()', () {
test('should throw ArgumentError when handle is empty', () async {
-
expect(
-
() => authService.signIn(''),
-
throwsA(isA<ArgumentError>()),
-
);
+
expect(() => authService.signIn(''), throwsA(isA<ArgumentError>()));
});
-
test('should throw ArgumentError when handle is whitespace-only',
-
() async {
-
expect(
-
() => authService.signIn(' '),
-
throwsA(isA<ArgumentError>()),
-
);
-
});
+
test(
+
'should throw ArgumentError when handle is whitespace-only',
+
() async {
+
expect(
+
() => authService.signIn(' '),
+
throwsA(isA<ArgumentError>()),
+
);
+
},
+
);
-
test('should throw appropriate error when user cancels sign-in',
-
() async {
+
test('should throw appropriate error when user cancels sign-in', () async {
// Note: FlutterWebAuth2.authenticate is not easily mockable as it's a static method
// This test documents expected behavior when authentication is cancelled
// In practice, this would throw with CANCELED/cancelled in the message
···
// Skipping for now as it requires more complex mocking infrastructure
});
-
test('should throw Exception when network error occurs during OAuth',
-
() async {
-
// Note: Similar to above, FlutterWebAuth2 static methods are difficult to mock
-
// This test documents expected behavior
-
// The actual implementation catches exceptions and rethrows with context
-
});
+
test(
+
'should throw Exception when network error occurs during OAuth',
+
() async {
+
// Note: Similar to above, FlutterWebAuth2 static methods are difficult to mock
+
// This test documents expected behavior
+
// The actual implementation catches exceptions and rethrows with context
+
},
+
);
test('should trim handle before processing', () async {
// This test verifies the handle trimming logic
···
);
final jsonString = session.toJsonString();
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => jsonString);
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => jsonString);
// Act
final result = await authService.restoreSession();
···
test('should return null when no stored session exists', () async {
// Arrange
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => null);
+
when(mockStorage.read(key: storageKey)).thenAnswer((_) async => null);
// Act
final result = await authService.restoreSession();
···
test('should handle corrupted storage data gracefully', () async {
// Arrange
const corruptedJson = 'not-valid-json{]';
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => corruptedJson);
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => corruptedJson);
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
// Act
final result = await authService.restoreSession();
···
verify(mockStorage.delete(key: storageKey)).called(1);
});
-
test('should handle session JSON with missing required fields gracefully',
-
() async {
-
// Arrange
-
const invalidJson = '{"token": "test"}'; // Missing required fields (did, session_id)
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => invalidJson);
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
-
-
// Act
-
final result = await authService.restoreSession();
-
-
// Assert
-
// Should return null and clear corrupted storage
-
expect(result, isNull);
-
verify(mockStorage.read(key: storageKey)).called(1);
-
verify(mockStorage.delete(key: storageKey)).called(1);
-
});
+
test(
+
'should handle session JSON with missing required fields gracefully',
+
() async {
+
// Arrange
+
const invalidJson =
+
'{"token": "test"}'; // Missing required fields (did, session_id)
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => invalidJson);
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
+
+
// Act
+
final result = await authService.restoreSession();
+
+
// Assert
+
// Should return null and clear corrupted storage
+
expect(result, isNull);
+
verify(mockStorage.read(key: storageKey)).called(1);
+
verify(mockStorage.delete(key: storageKey)).called(1);
+
},
+
);
test('should handle storage read errors gracefully', () async {
// Arrange
-
when(mockStorage.read(key: storageKey))
-
.thenThrow(Exception('Storage error'));
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
+
when(
+
mockStorage.read(key: storageKey),
+
).thenThrow(Exception('Storage error'));
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
// Act
final result = await authService.restoreSession();
···
group('refreshToken()', () {
test('should throw StateError when no session exists', () async {
// Act & Assert
-
expect(
-
() => authService.refreshToken(),
-
throwsA(isA<StateError>()),
-
);
+
expect(() => authService.refreshToken(), throwsA(isA<StateError>()));
});
-
test('should successfully refresh token and return updated session',
-
() async {
-
// Arrange - First restore a session
-
const initialSession = CovesSession(
-
token: 'old-token',
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
handle: 'alice.bsky.social',
-
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
-
await authService.restoreSession();
-
-
// Mock successful refresh response
-
const newToken = 'new-refreshed-token';
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
+
test(
+
'should successfully refresh token and return updated session',
+
() async {
+
// Arrange - First restore a session
+
const initialSession = CovesSession(
+
token: 'old-token',
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
+
handle: 'alice.bsky.social',
+
);
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
+
await authService.restoreSession();
+
+
// Mock successful refresh response
+
const newToken = 'new-refreshed-token';
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
requestOptions: RequestOptions(path: '/oauth/refresh'),
statusCode: 200,
-
data: {'sealed_token': newToken, 'access_token': 'some-access-token'},
-
));
-
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
-
-
// Act
-
final result = await authService.refreshToken();
+
data: {
+
'sealed_token': newToken,
+
'access_token': 'some-access-token',
+
},
+
),
+
);
-
// Assert
-
expect(result.token, newToken);
-
expect(result.did, 'did:plc:test123');
-
expect(result.sessionId, 'session-123');
-
expect(result.handle, 'alice.bsky.social');
-
verify(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).called(1);
-
verify(mockStorage.write(
-
key: storageKey,
-
value: anyNamed('value'),
-
)).called(1);
-
});
+
when(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
+
+
// Act
+
final result = await authService.refreshToken();
+
+
// Assert
+
expect(result.token, newToken);
+
expect(result.did, 'did:plc:test123');
+
expect(result.sessionId, 'session-123');
+
expect(result.handle, 'alice.bsky.social');
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).called(1);
+
verify(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).called(1);
+
},
+
);
test('should throw "Session expired" on 401 response', () async {
// Arrange - First restore a session
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Mock 401 response
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenThrow(
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
DioException(
requestOptions: RequestOptions(path: '/oauth/refresh'),
type: DioExceptionType.badResponse,
···
expect(
() => authService.refreshToken(),
throwsA(
-
predicate((e) =>
-
e is Exception && e.toString().contains('Session expired')),
+
predicate(
+
(e) => e is Exception && e.toString().contains('Session expired'),
+
),
),
);
});
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Mock network error
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenThrow(
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
DioException(
requestOptions: RequestOptions(path: '/oauth/refresh'),
type: DioExceptionType.connectionError,
···
expect(
() => authService.refreshToken(),
throwsA(
-
predicate((e) =>
-
e is Exception &&
-
e.toString().contains('Token refresh failed')),
+
predicate(
+
(e) =>
+
e is Exception &&
+
e.toString().contains('Token refresh failed'),
+
),
),
);
});
-
test('should throw Exception when response is missing sealed_token', () async {
-
// Arrange - First restore a session
-
const session = CovesSession(
-
token: 'old-token',
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
-
await authService.restoreSession();
-
-
// Mock response without sealed_token
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
+
test(
+
'should throw Exception when response is missing sealed_token',
+
() async {
+
// Arrange - First restore a session
+
const session = CovesSession(
+
token: 'old-token',
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
+
);
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
+
await authService.restoreSession();
+
+
// Mock response without sealed_token
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
requestOptions: RequestOptions(path: '/oauth/refresh'),
statusCode: 200,
data: {'access_token': 'some-token'}, // No sealed_token field
-
));
-
-
// Act & Assert
-
expect(
-
() => authService.refreshToken(),
-
throwsA(
-
predicate((e) =>
-
e is Exception &&
-
e.toString().contains('Invalid refresh response')),
-
),
-
);
-
});
+
),
+
);
-
test('should throw Exception when response sealed_token is empty', () async {
-
// Arrange - First restore a session
-
const session = CovesSession(
-
token: 'old-token',
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
-
await authService.restoreSession();
+
// Act & Assert
+
expect(
+
() => authService.refreshToken(),
+
throwsA(
+
predicate(
+
(e) =>
+
e is Exception &&
+
e.toString().contains('Invalid refresh response'),
+
),
+
),
+
);
+
},
+
);
-
// Mock response with empty sealed_token
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
+
test(
+
'should throw Exception when response sealed_token is empty',
+
() async {
+
// Arrange - First restore a session
+
const session = CovesSession(
+
token: 'old-token',
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
+
);
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
+
await authService.restoreSession();
+
+
// Mock response with empty sealed_token
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
requestOptions: RequestOptions(path: '/oauth/refresh'),
statusCode: 200,
-
data: {'sealed_token': '', 'access_token': 'some-token'}, // Empty sealed_token
-
));
+
data: {
+
'sealed_token': '',
+
'access_token': 'some-token',
+
}, // Empty sealed_token
+
),
+
);
-
// Act & Assert
-
expect(
-
() => authService.refreshToken(),
-
throwsA(
-
predicate((e) =>
-
e is Exception &&
-
e.toString().contains('Invalid refresh response')),
-
),
-
);
-
});
+
// Act & Assert
+
expect(
+
() => authService.refreshToken(),
+
throwsA(
+
predicate(
+
(e) =>
+
e is Exception &&
+
e.toString().contains('Invalid refresh response'),
+
),
+
),
+
);
+
},
+
);
});
group('signOut()', () {
-
test('should clear session and storage on successful server-side logout',
-
() async {
-
// Arrange - First restore a session
-
const session = CovesSession(
-
token: 'test-token',
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
-
await authService.restoreSession();
-
-
// Mock successful logout
-
when(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
)).thenAnswer((_) async => Response(
+
test(
+
'should clear session and storage on successful server-side logout',
+
() async {
+
// Arrange - First restore a session
+
const session = CovesSession(
+
token: 'test-token',
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
+
);
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
+
await authService.restoreSession();
+
+
// Mock successful logout
+
when(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
).thenAnswer(
+
(_) async => Response(
requestOptions: RequestOptions(path: '/oauth/logout'),
statusCode: 200,
-
));
+
),
+
);
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
-
// Act
-
await authService.signOut();
+
// Act
+
await authService.signOut();
-
// Assert
-
expect(authService.session, isNull);
-
expect(authService.isAuthenticated, isFalse);
-
verify(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
)).called(1);
-
verify(mockStorage.delete(key: storageKey)).called(1);
-
});
+
// Assert
+
expect(authService.session, isNull);
+
expect(authService.isAuthenticated, isFalse);
+
verify(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
).called(1);
+
verify(mockStorage.delete(key: storageKey)).called(1);
+
},
+
);
-
test('should clear local state even when server revocation fails',
-
() async {
-
// Arrange - First restore a session
-
const session = CovesSession(
-
token: 'test-token',
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
-
await authService.restoreSession();
-
-
// Mock server error
-
when(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
)).thenThrow(
-
DioException(
-
requestOptions: RequestOptions(path: '/oauth/logout'),
-
type: DioExceptionType.connectionError,
-
message: 'Connection failed',
-
),
-
);
+
test(
+
'should clear local state even when server revocation fails',
+
() async {
+
// Arrange - First restore a session
+
const session = CovesSession(
+
token: 'test-token',
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
+
);
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
+
await authService.restoreSession();
+
+
// Mock server error
+
when(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
).thenThrow(
+
DioException(
+
requestOptions: RequestOptions(path: '/oauth/logout'),
+
type: DioExceptionType.connectionError,
+
message: 'Connection failed',
+
),
+
);
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
-
// Act
-
await authService.signOut();
+
// Act
+
await authService.signOut();
-
// Assert
-
expect(authService.session, isNull);
-
expect(authService.isAuthenticated, isFalse);
-
verify(mockStorage.delete(key: storageKey)).called(1);
-
});
+
// Assert
+
expect(authService.session, isNull);
+
expect(authService.isAuthenticated, isFalse);
+
verify(mockStorage.delete(key: storageKey)).called(1);
+
},
+
);
test('should work even when no session exists', () async {
// Arrange
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
// Act
await authService.signOut();
···
expect(authService.session, isNull);
expect(authService.isAuthenticated, isFalse);
verify(mockStorage.delete(key: storageKey)).called(1);
-
verifyNever(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
));
+
verifyNever(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
);
});
test('should clear local state even when storage delete fails', () async {
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
-
when(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/logout'),
-
statusCode: 200,
-
));
+
when(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/logout'),
+
statusCode: 200,
+
),
+
);
-
when(mockStorage.delete(key: storageKey))
-
.thenThrow(Exception('Storage error'));
+
when(
+
mockStorage.delete(key: storageKey),
+
).thenThrow(Exception('Storage error'));
// Act & Assert - Should not throw
expect(() => authService.signOut(), throwsA(isA<Exception>()));
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Act
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
-
when(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/logout'),
-
statusCode: 200,
-
));
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
+
when(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/logout'),
+
statusCode: 200,
+
),
+
);
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
// Act
await authService.signOut();
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Assert
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
-
when(mockDio.post<void>(
-
'/oauth/logout',
-
options: anyNamed('options'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/logout'),
-
statusCode: 200,
-
));
-
when(mockStorage.delete(key: storageKey))
-
.thenAnswer((_) async => {});
+
when(
+
mockDio.post<void>('/oauth/logout', options: anyNamed('options')),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/logout'),
+
statusCode: 200,
+
),
+
);
+
when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {});
// Act
await authService.signOut();
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
// Act
await authService.restoreSession();
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
await authService.restoreSession();
const newToken = 'new-token';
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
statusCode: 200,
-
data: {'sealed_token': newToken, 'access_token': 'some-access-token'},
-
));
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
statusCode: 200,
+
data: {
+
'sealed_token': newToken,
+
'access_token': 'some-access-token',
+
},
+
),
+
);
+
when(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
// Act
await authService.refreshToken();
···
});
group('refreshToken() - Concurrency Protection', () {
-
test('should only make one API request for concurrent refresh calls',
-
() async {
-
// Arrange - First restore a session
-
const initialSession = CovesSession(
-
token: 'old-token',
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
handle: 'alice.bsky.social',
-
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
-
await authService.restoreSession();
-
-
const newToken = 'new-refreshed-token';
-
-
// Mock refresh response with a delay to simulate network latency
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async {
-
await Future.delayed(const Duration(milliseconds: 100));
-
return Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
statusCode: 200,
-
data: {'sealed_token': newToken, 'access_token': 'some-access-token'},
+
test(
+
'should only make one API request for concurrent refresh calls',
+
() async {
+
// Arrange - First restore a session
+
const initialSession = CovesSession(
+
token: 'old-token',
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
+
handle: 'alice.bsky.social',
);
-
});
-
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
-
-
// Act - Launch 3 concurrent refresh calls
-
final results = await Future.wait([
-
authService.refreshToken(),
-
authService.refreshToken(),
-
authService.refreshToken(),
-
]);
-
-
// Assert - All calls should return the same refreshed session
-
expect(results.length, 3);
-
expect(results[0].token, newToken);
-
expect(results[1].token, newToken);
-
expect(results[2].token, newToken);
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
+
await authService.restoreSession();
+
+
const newToken = 'new-refreshed-token';
+
+
// Mock refresh response with a delay to simulate network latency
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer((_) async {
+
await Future.delayed(const Duration(milliseconds: 100));
+
return Response(
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
statusCode: 200,
+
data: {
+
'sealed_token': newToken,
+
'access_token': 'some-access-token',
+
},
+
);
+
});
+
+
when(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
+
+
// Act - Launch 3 concurrent refresh calls
+
final results = await Future.wait([
+
authService.refreshToken(),
+
authService.refreshToken(),
+
authService.refreshToken(),
+
]);
+
+
// Assert - All calls should return the same refreshed session
+
expect(results.length, 3);
+
expect(results[0].token, newToken);
+
expect(results[1].token, newToken);
+
expect(results[2].token, newToken);
+
+
// Verify only one API call was made
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).called(1);
-
// Verify only one API call was made
-
verify(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).called(1);
-
-
// Verify only one storage write was made
-
verify(mockStorage.write(
-
key: storageKey,
-
value: anyNamed('value'),
-
)).called(1);
-
});
+
// Verify only one storage write was made
+
verify(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).called(1);
+
},
+
);
test('should propagate errors to all concurrent waiters', () async {
// Arrange - First restore a session
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => session.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => session.toJsonString());
await authService.restoreSession();
// Mock 401 response with delay
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async {
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer((_) async {
await Future.delayed(const Duration(milliseconds: 100));
throw DioException(
requestOptions: RequestOptions(path: '/oauth/refresh'),
···
expect(errorCount, 3);
// Verify only one API call was made
-
verify(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).called(1);
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).called(1);
});
test('should allow new refresh after previous one completes', () async {
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
await authService.restoreSession();
const newToken1 = 'new-token-1';
const newToken2 = 'new-token-2';
// Mock first refresh
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
statusCode: 200,
-
data: {'sealed_token': newToken1, 'access_token': 'some-access-token'},
-
));
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
statusCode: 200,
+
data: {
+
'sealed_token': newToken1,
+
'access_token': 'some-access-token',
+
},
+
),
+
);
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
+
when(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
// Act - First refresh
final result1 = await authService.refreshToken();
···
expect(result1.token, newToken1);
// Now update the mock for the second refresh
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
statusCode: 200,
-
data: {'sealed_token': newToken2, 'access_token': 'some-access-token'},
-
));
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
statusCode: 200,
+
data: {
+
'sealed_token': newToken2,
+
'access_token': 'some-access-token',
+
},
+
),
+
);
// Act - Second refresh (should be allowed since first completed)
final result2 = await authService.refreshToken();
···
expect(result2.token, newToken2);
// Verify two separate API calls were made
-
verify(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).called(2);
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).called(2);
});
test('should allow new refresh after previous one fails', () async {
···
did: 'did:plc:test123',
sessionId: 'session-123',
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
await authService.restoreSession();
// Mock first refresh to fail
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenThrow(
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenThrow(
DioException(
requestOptions: RequestOptions(path: '/oauth/refresh'),
type: DioExceptionType.connectionError,
···
// Now mock a successful second refresh
const newToken = 'new-token-after-retry';
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async => Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
statusCode: 200,
-
data: {'sealed_token': newToken, 'access_token': 'some-access-token'},
-
));
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer(
+
(_) async => Response(
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
statusCode: 200,
+
data: {
+
'sealed_token': newToken,
+
'access_token': 'some-access-token',
+
},
+
),
+
);
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
+
when(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
// Act - Second refresh (should be allowed and succeed)
final result = await authService.refreshToken();
···
expect(result.token, newToken);
// Verify two separate API calls were made
-
verify(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).called(2);
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).called(2);
});
test(
-
'should handle concurrent calls where one arrives after refresh completes',
-
() async {
-
// Arrange - First restore a session
-
const initialSession = CovesSession(
-
token: 'old-token',
-
did: 'did:plc:test123',
-
sessionId: 'session-123',
-
);
-
when(mockStorage.read(key: storageKey))
-
.thenAnswer((_) async => initialSession.toJsonString());
-
await authService.restoreSession();
-
-
const newToken1 = 'new-token-1';
-
const newToken2 = 'new-token-2';
-
-
var callCount = 0;
-
-
// Mock refresh with different responses
-
when(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).thenAnswer((_) async {
-
callCount++;
-
await Future.delayed(const Duration(milliseconds: 50));
-
return Response(
-
requestOptions: RequestOptions(path: '/oauth/refresh'),
-
statusCode: 200,
-
data: {'sealed_token': callCount == 1 ? newToken1 : newToken2, 'access_token': 'some-access-token'},
+
'should handle concurrent calls where one arrives after refresh completes',
+
() async {
+
// Arrange - First restore a session
+
const initialSession = CovesSession(
+
token: 'old-token',
+
did: 'did:plc:test123',
+
sessionId: 'session-123',
);
-
});
-
-
when(mockStorage.write(key: storageKey, value: anyNamed('value')))
-
.thenAnswer((_) async => {});
-
-
// Act - Start first refresh
-
final future1 = authService.refreshToken();
+
when(
+
mockStorage.read(key: storageKey),
+
).thenAnswer((_) async => initialSession.toJsonString());
+
await authService.restoreSession();
-
// Wait for it to complete
-
final result1 = await future1;
+
const newToken1 = 'new-token-1';
+
const newToken2 = 'new-token-2';
-
// Start second refresh after first completes
-
final result2 = await authService.refreshToken();
+
var callCount = 0;
-
// Assert
-
expect(result1.token, newToken1);
-
expect(result2.token, newToken2);
-
-
// Verify two separate API calls were made
-
verify(mockDio.post<Map<String, dynamic>>(
-
'/oauth/refresh',
-
data: anyNamed('data'),
-
)).called(2);
-
});
+
// Mock refresh with different responses
+
when(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).thenAnswer((_) async {
+
callCount++;
+
await Future.delayed(const Duration(milliseconds: 50));
+
return Response(
+
requestOptions: RequestOptions(path: '/oauth/refresh'),
+
statusCode: 200,
+
data: {
+
'sealed_token': callCount == 1 ? newToken1 : newToken2,
+
'access_token': 'some-access-token',
+
},
+
);
+
});
+
+
when(
+
mockStorage.write(key: storageKey, value: anyNamed('value')),
+
).thenAnswer((_) async => {});
+
+
// Act - Start first refresh
+
final future1 = authService.refreshToken();
+
+
// Wait for it to complete
+
final result1 = await future1;
+
+
// Start second refresh after first completes
+
final result2 = await authService.refreshToken();
+
+
// Assert
+
expect(result1.token, newToken1);
+
expect(result2.token, newToken2);
+
+
// Verify two separate API calls were made
+
verify(
+
mockDio.post<Map<String, dynamic>>(
+
'/oauth/refresh',
+
data: anyNamed('data'),
+
),
+
).called(2);
+
},
+
);
});
});
}
+9 -2
lib/models/comment.dart
···
}
class CommentViewerState {
-
CommentViewerState({this.vote});
+
CommentViewerState({this.vote, this.voteUri});
factory CommentViewerState.fromJson(Map<String, dynamic> json) {
-
return CommentViewerState(vote: json['vote'] as String?);
+
return CommentViewerState(
+
vote: json['vote'] as String?,
+
voteUri: json['voteUri'] as String?,
+
);
}
+
/// Vote direction: "up", "down", or null if not voted
final String? vote;
+
+
/// AT-URI of the vote record (if backend provides it)
+
final String? voteUri;
}
+41
lib/models/post.dart
···
final FeedReason? reason;
}
+
class ViewerState {
+
ViewerState({
+
this.vote,
+
this.voteUri,
+
this.saved = false,
+
this.savedUri,
+
this.tags,
+
});
+
+
factory ViewerState.fromJson(Map<String, dynamic> json) {
+
return ViewerState(
+
vote: json['vote'] as String?,
+
voteUri: json['voteUri'] as String?,
+
saved: json['saved'] as bool? ?? false,
+
savedUri: json['savedUri'] as String?,
+
tags: (json['tags'] as List<dynamic>?)?.cast<String>(),
+
);
+
}
+
+
/// Vote direction: "up", "down", or null if not voted
+
final String? vote;
+
+
/// AT-URI of the vote record
+
final String? voteUri;
+
+
/// Whether the post is saved/bookmarked
+
final bool saved;
+
+
/// AT-URI of the saved record
+
final String? savedUri;
+
+
/// User-applied tags
+
final List<String>? tags;
+
}
+
class PostView {
PostView({
required this.uri,
···
required this.stats,
this.embed,
this.facets,
+
this.viewer,
});
factory PostView.fromJson(Map<String, dynamic> json) {
···
.map((f) => PostFacet.fromJson(f as Map<String, dynamic>))
.toList()
: null,
+
viewer:
+
json['viewer'] != null
+
? ViewerState.fromJson(json['viewer'] as Map<String, dynamic>)
+
: null,
);
}
final String uri;
···
final PostStats stats;
final PostEmbed? embed;
final List<PostFacet>? facets;
+
final ViewerState? viewer;
}
class AuthorView {
+20 -51
lib/providers/vote_provider.dart
···
import 'package:flutter/foundation.dart';
import '../services/api_exceptions.dart';
-
import '../services/vote_service.dart' show VoteService, VoteInfo;
+
import '../services/vote_service.dart' show VoteService;
import 'auth_provider.dart';
/// Vote Provider
···
_pendingRequests[postUri] = true;
try {
-
// Make API call - pass existing vote info to avoid O(n) PDS lookup
+
// Make API call
final response = await _voteService.createVote(
postUri: postUri,
postCid: postCid,
direction: direction,
-
existingVoteRkey: currentState?.rkey,
-
existingVoteDirection: currentState?.direction,
);
// Update with server response
···
String? voteUri,
}) {
if (voteDirection != null) {
-
// Extract rkey from vote URI if available
-
// URI format: at://did:plc:xyz/social.coves.feed.vote/3kby...
-
String? rkey;
-
if (voteUri != null) {
-
final parts = voteUri.split('/');
-
if (parts.isNotEmpty) {
-
rkey = parts.last;
-
}
-
}
-
_votes[postUri] = VoteState(
direction: voteDirection,
uri: voteUri,
-
rkey: rkey,
+
rkey: VoteState.extractRkeyFromUri(voteUri),
deleted: false,
);
} else {
_votes.remove(postUri);
}
-
// Don't notify listeners - this is just initial state
-
}
-
-
/// Load initial vote states from a map of votes
-
///
-
/// This is used to bulk-load vote state after querying the user's PDS.
-
/// Typically called after loading feed posts to fill in which posts
-
/// the user has voted on.
-
///
-
/// IMPORTANT: This clears score adjustments since the server score
-
/// already reflects the loaded votes. If we kept stale adjustments,
-
/// we'd double-count votes (server score + our adjustment).
-
///
-
/// Parameters:
-
/// - [votes]: Map of post URI -> vote info from VoteService.getUserVotes()
-
void loadInitialVotes(Map<String, VoteInfo> votes) {
-
for (final entry in votes.entries) {
-
final postUri = entry.key;
-
final voteInfo = entry.value;
-
-
_votes[postUri] = VoteState(
-
direction: voteInfo.direction,
-
uri: voteInfo.voteUri,
-
rkey: voteInfo.rkey,
-
deleted: false,
-
);
-
-
// Clear any stale score adjustments for this post
-
// The server score already includes this vote
-
_scoreAdjustments.remove(postUri);
-
}
-
if (kDebugMode) {
-
debugPrint('๐Ÿ“Š Initialized ${votes.length} vote states');
-
}
+
// IMPORTANT: Clear any stale score adjustment for this post.
+
// When we receive fresh data from the server (via feed/comments refresh),
+
// the server's score already reflects the actual vote state. Any local
+
// delta from a previous optimistic update is now stale and would cause
+
// double-counting (e.g., server score already includes +1, plus our +1).
+
_scoreAdjustments.remove(postUri);
-
// Notify once after loading all votes
-
notifyListeners();
+
// Don't notify listeners - this is just initial state
}
/// Clear all vote state (e.g., on sign out)
···
/// Whether the vote has been deleted
final bool deleted;
+
+
/// Extract rkey (record key) from an AT-URI
+
///
+
/// AT-URI format: at://did:plc:xyz/social.coves.feed.vote/3kby...
+
/// Returns the last segment (rkey) or null if URI is null/invalid.
+
static String? extractRkeyFromUri(String? uri) {
+
if (uri == null) return null;
+
final parts = uri.split('/');
+
return parts.isNotEmpty ? parts.last : null;
+
}
}
+12
test/providers/auth_provider_test.mocks.dart
···
import 'package:coves_flutter/models/coves_session.dart' as _i2;
import 'package:coves_flutter/services/coves_auth_service.dart' as _i3;
import 'package:mockito/mockito.dart' as _i1;
+
import 'package:mockito/src/dummies.dart' as _i5;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
···
returnValueForMissingStub: _i4.Future<void>.value(),
)
as _i4.Future<void>);
+
+
@override
+
String validateAndNormalizeHandle(String? handle) =>
+
(super.noSuchMethod(
+
Invocation.method(#validateAndNormalizeHandle, [handle]),
+
returnValue: _i5.dummyValue<String>(
+
this,
+
Invocation.method(#validateAndNormalizeHandle, [handle]),
+
),
+
)
+
as String);
}
+229 -1
test/providers/feed_provider_test.dart
···
import 'package:coves_flutter/models/post.dart';
import 'package:coves_flutter/providers/auth_provider.dart';
import 'package:coves_flutter/providers/feed_provider.dart';
+
import 'package:coves_flutter/providers/vote_provider.dart';
import 'package:coves_flutter/services/coves_api_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
···
import 'feed_provider_test.mocks.dart';
// Generate mocks
-
@GenerateMocks([AuthProvider, CovesApiService])
+
@GenerateMocks([AuthProvider, CovesApiService, VoteProvider])
void main() {
group('FeedProvider', () {
late FeedProvider feedProvider;
···
expect(feedProvider.isLoading, false);
});
});
+
+
group('Vote state initialization from viewer data', () {
+
late MockVoteProvider mockVoteProvider;
+
late FeedProvider feedProviderWithVotes;
+
+
setUp(() {
+
mockVoteProvider = MockVoteProvider();
+
feedProviderWithVotes = FeedProvider(
+
mockAuthProvider,
+
apiService: mockApiService,
+
voteProvider: mockVoteProvider,
+
);
+
});
+
+
tearDown(() {
+
feedProviderWithVotes.dispose();
+
});
+
+
test('should initialize vote state when viewer.vote is "up"', () async {
+
when(mockAuthProvider.isAuthenticated).thenReturn(true);
+
+
final mockResponse = TimelineResponse(
+
feed: [
+
_createMockPostWithViewer(
+
uri: 'at://did:plc:test/social.coves.post.record/1',
+
vote: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
+
),
+
],
+
cursor: 'cursor',
+
);
+
+
when(
+
mockApiService.getTimeline(
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).thenAnswer((_) async => mockResponse);
+
+
await feedProviderWithVotes.fetchTimeline(refresh: true);
+
+
verify(
+
mockVoteProvider.setInitialVoteState(
+
postUri: 'at://did:plc:test/social.coves.post.record/1',
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
+
),
+
).called(1);
+
});
+
+
test('should initialize vote state when viewer.vote is "down"', () async {
+
when(mockAuthProvider.isAuthenticated).thenReturn(true);
+
+
final mockResponse = TimelineResponse(
+
feed: [
+
_createMockPostWithViewer(
+
uri: 'at://did:plc:test/social.coves.post.record/1',
+
vote: 'down',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
+
),
+
],
+
cursor: 'cursor',
+
);
+
+
when(
+
mockApiService.getTimeline(
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).thenAnswer((_) async => mockResponse);
+
+
await feedProviderWithVotes.fetchTimeline(refresh: true);
+
+
verify(
+
mockVoteProvider.setInitialVoteState(
+
postUri: 'at://did:plc:test/social.coves.post.record/1',
+
voteDirection: 'down',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
+
),
+
).called(1);
+
});
+
+
test(
+
'should clear stale vote state when viewer.vote is null on refresh',
+
() async {
+
when(mockAuthProvider.isAuthenticated).thenReturn(true);
+
+
// Feed item with null vote (user removed vote on another device)
+
final mockResponse = TimelineResponse(
+
feed: [
+
_createMockPostWithViewer(
+
uri: 'at://did:plc:test/social.coves.post.record/1',
+
vote: null,
+
voteUri: null,
+
),
+
],
+
cursor: 'cursor',
+
);
+
+
when(
+
mockApiService.getTimeline(
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).thenAnswer((_) async => mockResponse);
+
+
await feedProviderWithVotes.fetchTimeline(refresh: true);
+
+
// Should call setInitialVoteState with null to clear stale state
+
verify(
+
mockVoteProvider.setInitialVoteState(
+
postUri: 'at://did:plc:test/social.coves.post.record/1',
+
voteDirection: null,
+
voteUri: null,
+
),
+
).called(1);
+
},
+
);
+
+
test(
+
'should initialize vote state for all feed items including no viewer',
+
() async {
+
when(mockAuthProvider.isAuthenticated).thenReturn(true);
+
+
final mockResponse = TimelineResponse(
+
feed: [
+
_createMockPostWithViewer(
+
uri: 'at://did:plc:test/social.coves.post.record/1',
+
vote: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
+
),
+
_createMockPost(), // No viewer state
+
],
+
cursor: 'cursor',
+
);
+
+
when(
+
mockApiService.getTimeline(
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).thenAnswer((_) async => mockResponse);
+
+
await feedProviderWithVotes.fetchTimeline(refresh: true);
+
+
// Should be called for both posts
+
verify(
+
mockVoteProvider.setInitialVoteState(
+
postUri: anyNamed('postUri'),
+
voteDirection: anyNamed('voteDirection'),
+
voteUri: anyNamed('voteUri'),
+
),
+
).called(2);
+
},
+
);
+
+
test('should not initialize vote state when not authenticated', () async {
+
when(mockAuthProvider.isAuthenticated).thenReturn(false);
+
+
final mockResponse = TimelineResponse(
+
feed: [
+
_createMockPostWithViewer(
+
uri: 'at://did:plc:test/social.coves.post.record/1',
+
vote: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/vote1',
+
),
+
],
+
cursor: 'cursor',
+
);
+
+
when(
+
mockApiService.getDiscover(
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).thenAnswer((_) async => mockResponse);
+
+
await feedProviderWithVotes.fetchDiscover(refresh: true);
+
+
// Should NOT call setInitialVoteState when not authenticated
+
verifyNever(
+
mockVoteProvider.setInitialVoteState(
+
postUri: anyNamed('postUri'),
+
voteDirection: anyNamed('voteDirection'),
+
voteUri: anyNamed('voteUri'),
+
),
+
);
+
});
+
});
});
}
···
),
);
}
+
+
// Helper function to create mock posts with viewer state
+
FeedViewPost _createMockPostWithViewer({
+
required String uri,
+
String? vote,
+
String? voteUri,
+
}) {
+
return FeedViewPost(
+
post: PostView(
+
uri: uri,
+
cid: 'test-cid',
+
rkey: 'test-rkey',
+
author: AuthorView(
+
did: 'did:plc:author',
+
handle: 'test.user',
+
displayName: 'Test User',
+
),
+
community: CommunityRef(did: 'did:plc:community', name: 'test-community'),
+
createdAt: DateTime.parse('2025-01-01T12:00:00Z'),
+
indexedAt: DateTime.parse('2025-01-01T12:00:00Z'),
+
text: 'Test body',
+
title: 'Test Post',
+
stats: PostStats(score: 42, upvotes: 50, downvotes: 8, commentCount: 5),
+
facets: [],
+
viewer: ViewerState(vote: vote, voteUri: voteUri),
+
),
+
);
+
}
+104
test/providers/feed_provider_test.mocks.dart
···
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:mockito/mockito.dart' as _i1;
···
returnValueForMissingStub: null,
);
}
+
+
/// A class which mocks [VoteProvider].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockVoteProvider extends _i1.Mock implements _i8.VoteProvider {
+
MockVoteProvider() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
bool get hasListeners =>
+
(super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false)
+
as bool);
+
+
@override
+
void dispose() => super.noSuchMethod(
+
Invocation.method(#dispose, []),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i8.VoteState? getVoteState(String? postUri) =>
+
(super.noSuchMethod(Invocation.method(#getVoteState, [postUri]))
+
as _i8.VoteState?);
+
+
@override
+
bool isLiked(String? postUri) =>
+
(super.noSuchMethod(
+
Invocation.method(#isLiked, [postUri]),
+
returnValue: false,
+
)
+
as bool);
+
+
@override
+
bool isPending(String? postUri) =>
+
(super.noSuchMethod(
+
Invocation.method(#isPending, [postUri]),
+
returnValue: false,
+
)
+
as bool);
+
+
@override
+
int getAdjustedScore(String? postUri, int? serverScore) =>
+
(super.noSuchMethod(
+
Invocation.method(#getAdjustedScore, [postUri, serverScore]),
+
returnValue: 0,
+
)
+
as int);
+
+
@override
+
_i5.Future<bool> toggleVote({
+
required String? postUri,
+
required String? postCid,
+
String? direction = 'up',
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#toggleVote, [], {
+
#postUri: postUri,
+
#postCid: postCid,
+
#direction: direction,
+
}),
+
returnValue: _i5.Future<bool>.value(false),
+
)
+
as _i5.Future<bool>);
+
+
@override
+
void setInitialVoteState({
+
required String? postUri,
+
String? voteDirection,
+
String? voteUri,
+
}) => super.noSuchMethod(
+
Invocation.method(#setInitialVoteState, [], {
+
#postUri: postUri,
+
#voteDirection: voteDirection,
+
#voteUri: voteUri,
+
}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void clear() => super.noSuchMethod(
+
Invocation.method(#clear, []),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void addListener(_i6.VoidCallback? listener) => super.noSuchMethod(
+
Invocation.method(#addListener, [listener]),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void removeListener(_i6.VoidCallback? listener) => super.noSuchMethod(
+
Invocation.method(#removeListener, [listener]),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void notifyListeners() => super.noSuchMethod(
+
Invocation.method(#notifyListeners, []),
+
returnValueForMissingStub: null,
+
);
+
}
+134 -28
test/providers/vote_provider_test.dart
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer(
(_) async => const VoteResponse(
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer((_) async => const VoteResponse(deleted: true));
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenThrow(ApiException('Network error', statusCode: 500));
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenThrow(NetworkException('Connection failed'));
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer((_) async {
await Future.delayed(const Duration(milliseconds: 100));
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).called(1);
});
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer(
(_) async => const VoteResponse(
···
expect(voteState?.deleted, false);
});
+
test('should set initial vote state with "down" direction', () {
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'down',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
+
);
+
+
// Should not be "liked" (isLiked checks for 'up' direction)
+
expect(voteProvider.isLiked(testPostUri), false);
+
+
final voteState = voteProvider.getVoteState(testPostUri);
+
expect(voteState?.direction, 'down');
+
expect(voteState?.uri, 'at://did:plc:test/social.coves.feed.vote/456');
+
expect(voteState?.deleted, false);
+
});
+
+
test('should extract rkey from voteUri', () {
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/3kbyxyz123',
+
);
+
+
final voteState = voteProvider.getVoteState(testPostUri);
+
expect(voteState?.rkey, '3kbyxyz123');
+
});
+
+
test('should handle voteUri being null', () {
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
);
+
+
final voteState = voteProvider.getVoteState(testPostUri);
+
expect(voteState?.direction, 'up');
+
expect(voteState?.uri, null);
+
expect(voteState?.rkey, null);
+
expect(voteState?.deleted, false);
+
});
+
test('should remove vote state when voteDirection is null', () {
// First set a vote
voteProvider.setInitialVoteState(
···
expect(voteProvider.getVoteState(testPostUri), null);
});
+
test('should clear stale vote state when refreshing with null vote', () {
+
// Simulate initial state from previous session
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
+
);
+
+
expect(voteProvider.isLiked(testPostUri), true);
+
+
// Simulate refresh where server returns viewer.vote = null
+
// (user removed vote on another device)
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: null,
+
);
+
+
// Vote should be cleared
+
expect(voteProvider.isLiked(testPostUri), false);
+
expect(voteProvider.getVoteState(testPostUri), null);
+
});
+
+
test('should clear stale score adjustment on refresh', () async {
+
// Simulate optimistic upvote that created a +1 adjustment
+
when(
+
mockVoteService.createVote(
+
postUri: anyNamed('postUri'),
+
postCid: anyNamed('postCid'),
+
direction: anyNamed('direction'),
+
),
+
).thenAnswer(
+
(_) async => const VoteResponse(
+
uri: 'at://did:plc:test/social.coves.feed.vote/456',
+
cid: 'bafy123',
+
rkey: '456',
+
deleted: false,
+
),
+
);
+
+
// Create vote - this sets _scoreAdjustments[testPostUri] = +1
+
await voteProvider.toggleVote(
+
postUri: testPostUri,
+
postCid: 'bafy2bzacepostcid123',
+
);
+
+
// Verify adjustment exists
+
const serverScore = 10;
+
expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 11);
+
+
// Now simulate a feed refresh - server returns fresh score (11)
+
// which already includes the vote. The adjustment should be cleared.
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
+
);
+
+
// After refresh, adjustment should be cleared (server score is truth)
+
// If we pass the NEW server score (11), we should get 11, not 12
+
const freshServerScore = 11;
+
expect(
+
voteProvider.getAdjustedScore(testPostUri, freshServerScore),
+
11,
+
);
+
});
+
test('should not notify listeners when setting initial state', () {
var notificationCount = 0;
voteProvider
···
});
});
+
group('VoteState.extractRkeyFromUri', () {
+
test('should extract rkey from valid AT-URI', () {
+
expect(
+
VoteState.extractRkeyFromUri(
+
'at://did:plc:test/social.coves.feed.vote/3kbyxyz123',
+
),
+
'3kbyxyz123',
+
);
+
});
+
+
test('should return null for null uri', () {
+
expect(VoteState.extractRkeyFromUri(null), null);
+
});
+
+
test('should handle URI with no path segments', () {
+
expect(VoteState.extractRkeyFromUri(''), '');
+
});
+
+
test('should handle complex rkey values', () {
+
expect(
+
VoteState.extractRkeyFromUri(
+
'at://did:plc:abc123xyz/social.coves.feed.vote/3lbp7kw2abc',
+
),
+
'3lbp7kw2abc',
+
);
+
});
+
});
+
group('clear', () {
test('should clear all vote state', () {
const post1 = 'at://did:plc:test/social.coves.post.record/1';
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer((_) async {
await Future.delayed(const Duration(milliseconds: 50));
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer(
(_) async => const VoteResponse(
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer((_) async => const VoteResponse(deleted: true));
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer(
(_) async => const VoteResponse(
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer(
(_) async => const VoteResponse(
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenAnswer(
(_) async => const VoteResponse(
···
postUri: anyNamed('postUri'),
postCid: anyNamed('postCid'),
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
),
).thenThrow(ApiException('Network error', statusCode: 500));
-16
test/providers/vote_provider_test.mocks.dart
···
_i1.throwOnMissingStub(this);
}
-
@override
-
_i3.Future<Map<String, _i2.VoteInfo>> getUserVotes() =>
-
(super.noSuchMethod(
-
Invocation.method(#getUserVotes, []),
-
returnValue: _i3.Future<Map<String, _i2.VoteInfo>>.value(
-
<String, _i2.VoteInfo>{},
-
),
-
)
-
as _i3.Future<Map<String, _i2.VoteInfo>>);
-
@override
_i3.Future<_i2.VoteResponse> createVote({
required String? postUri,
required String? postCid,
String? direction = 'up',
-
String? existingVoteRkey,
-
String? existingVoteDirection,
}) =>
(super.noSuchMethod(
Invocation.method(#createVote, [], {
#postUri: postUri,
#postCid: postCid,
#direction: direction,
-
#existingVoteRkey: existingVoteRkey,
-
#existingVoteDirection: existingVoteDirection,
}),
returnValue: _i3.Future<_i2.VoteResponse>.value(
_FakeVoteResponse_0(
···
#postUri: postUri,
#postCid: postCid,
#direction: direction,
-
#existingVoteRkey: existingVoteRkey,
-
#existingVoteDirection: existingVoteDirection,
}),
),
),
+1102
test/services/coves_auth_service_environment_test.mocks.dart
···
+
// Mocks generated by Mockito 5.4.6 from annotations
+
// in coves_flutter/test/services/coves_auth_service_environment_test.dart.
+
// Do not manually edit this file.
+
+
// ignore_for_file: no_leading_underscores_for_library_prefixes
+
import 'dart:async' as _i9;
+
+
import 'package:dio/src/adapter.dart' as _i4;
+
import 'package:dio/src/cancel_token.dart' as _i10;
+
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:flutter/foundation.dart' as _i11;
+
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i8;
+
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);
+
}
+
+
class _FakeIOSOptions_6 extends _i1.SmartFake implements _i8.IOSOptions {
+
_FakeIOSOptions_6(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeAndroidOptions_7 extends _i1.SmartFake
+
implements _i8.AndroidOptions {
+
_FakeAndroidOptions_7(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeLinuxOptions_8 extends _i1.SmartFake implements _i8.LinuxOptions {
+
_FakeLinuxOptions_8(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeWindowsOptions_9 extends _i1.SmartFake
+
implements _i8.WindowsOptions {
+
_FakeWindowsOptions_9(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeWebOptions_10 extends _i1.SmartFake implements _i8.WebOptions {
+
_FakeWebOptions_10(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeMacOsOptions_11 extends _i1.SmartFake implements _i8.MacOsOptions {
+
_FakeMacOsOptions_11(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
+
_i9.Future<_i6.Response<T>> head<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> headUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> get<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> getUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> post<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> postUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.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: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> put<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> putUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.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: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> patch<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> patchUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.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: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> delete<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> deleteUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<dynamic>> download(
+
String? urlPath,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
Map<String, dynamic>? queryParameters,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i9.Future<_i6.Response<dynamic>> downloadUri(
+
Uri? uri,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> request<T>(
+
String? url, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> requestUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i10.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: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> fetch<T>(_i2.RequestOptions? requestOptions) =>
+
(super.noSuchMethod(
+
Invocation.method(#fetch, [requestOptions]),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(#fetch, [requestOptions]),
+
),
+
),
+
)
+
as _i9.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);
+
}
+
+
/// A class which mocks [FlutterSecureStorage].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockFlutterSecureStorage extends _i1.Mock
+
implements _i8.FlutterSecureStorage {
+
MockFlutterSecureStorage() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i8.IOSOptions get iOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#iOptions),
+
returnValue: _FakeIOSOptions_6(this, Invocation.getter(#iOptions)),
+
)
+
as _i8.IOSOptions);
+
+
@override
+
_i8.AndroidOptions get aOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#aOptions),
+
returnValue: _FakeAndroidOptions_7(
+
this,
+
Invocation.getter(#aOptions),
+
),
+
)
+
as _i8.AndroidOptions);
+
+
@override
+
_i8.LinuxOptions get lOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#lOptions),
+
returnValue: _FakeLinuxOptions_8(
+
this,
+
Invocation.getter(#lOptions),
+
),
+
)
+
as _i8.LinuxOptions);
+
+
@override
+
_i8.WindowsOptions get wOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#wOptions),
+
returnValue: _FakeWindowsOptions_9(
+
this,
+
Invocation.getter(#wOptions),
+
),
+
)
+
as _i8.WindowsOptions);
+
+
@override
+
_i8.WebOptions get webOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#webOptions),
+
returnValue: _FakeWebOptions_10(
+
this,
+
Invocation.getter(#webOptions),
+
),
+
)
+
as _i8.WebOptions);
+
+
@override
+
_i8.MacOsOptions get mOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#mOptions),
+
returnValue: _FakeMacOsOptions_11(
+
this,
+
Invocation.getter(#mOptions),
+
),
+
)
+
as _i8.MacOsOptions);
+
+
@override
+
void registerListener({
+
required String? key,
+
required _i11.ValueChanged<String?>? listener,
+
}) => super.noSuchMethod(
+
Invocation.method(#registerListener, [], {#key: key, #listener: listener}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterListener({
+
required String? key,
+
required _i11.ValueChanged<String?>? listener,
+
}) => super.noSuchMethod(
+
Invocation.method(#unregisterListener, [], {
+
#key: key,
+
#listener: listener,
+
}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterAllListenersForKey({required String? key}) =>
+
super.noSuchMethod(
+
Invocation.method(#unregisterAllListenersForKey, [], {#key: key}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterAllListeners() => super.noSuchMethod(
+
Invocation.method(#unregisterAllListeners, []),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i9.Future<void> write({
+
required String? key,
+
required String? value,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#write, [], {
+
#key: key,
+
#value: value,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<String?> read({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#read, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<String?>.value(),
+
)
+
as _i9.Future<String?>);
+
+
@override
+
_i9.Future<bool> containsKey({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#containsKey, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<bool>.value(false),
+
)
+
as _i9.Future<bool>);
+
+
@override
+
_i9.Future<void> delete({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#delete, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<Map<String, String>> readAll({
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#readAll, [], {
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<Map<String, String>>.value(
+
<String, String>{},
+
),
+
)
+
as _i9.Future<Map<String, String>>);
+
+
@override
+
_i9.Future<void> deleteAll({
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#deleteAll, [], {
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<bool?> isCupertinoProtectedDataAvailable() =>
+
(super.noSuchMethod(
+
Invocation.method(#isCupertinoProtectedDataAvailable, []),
+
returnValue: _i9.Future<bool?>.value(),
+
)
+
as _i9.Future<bool?>);
+
}
+1102
test/services/coves_auth_service_singleton_test.mocks.dart
···
+
// Mocks generated by Mockito 5.4.6 from annotations
+
// in coves_flutter/test/services/coves_auth_service_singleton_test.dart.
+
// Do not manually edit this file.
+
+
// ignore_for_file: no_leading_underscores_for_library_prefixes
+
import 'dart:async' as _i9;
+
+
import 'package:dio/src/adapter.dart' as _i4;
+
import 'package:dio/src/cancel_token.dart' as _i10;
+
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:flutter/foundation.dart' as _i11;
+
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i8;
+
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);
+
}
+
+
class _FakeIOSOptions_6 extends _i1.SmartFake implements _i8.IOSOptions {
+
_FakeIOSOptions_6(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeAndroidOptions_7 extends _i1.SmartFake
+
implements _i8.AndroidOptions {
+
_FakeAndroidOptions_7(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeLinuxOptions_8 extends _i1.SmartFake implements _i8.LinuxOptions {
+
_FakeLinuxOptions_8(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeWindowsOptions_9 extends _i1.SmartFake
+
implements _i8.WindowsOptions {
+
_FakeWindowsOptions_9(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeWebOptions_10 extends _i1.SmartFake implements _i8.WebOptions {
+
_FakeWebOptions_10(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeMacOsOptions_11 extends _i1.SmartFake implements _i8.MacOsOptions {
+
_FakeMacOsOptions_11(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
+
_i9.Future<_i6.Response<T>> head<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> headUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> get<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> getUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> post<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> postUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.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: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> put<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> putUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.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: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> patch<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> patchUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.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: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> delete<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> deleteUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<dynamic>> download(
+
String? urlPath,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
Map<String, dynamic>? queryParameters,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i9.Future<_i6.Response<dynamic>> downloadUri(
+
Uri? uri,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> request<T>(
+
String? url, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> requestUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i10.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: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> fetch<T>(_i2.RequestOptions? requestOptions) =>
+
(super.noSuchMethod(
+
Invocation.method(#fetch, [requestOptions]),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(#fetch, [requestOptions]),
+
),
+
),
+
)
+
as _i9.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);
+
}
+
+
/// A class which mocks [FlutterSecureStorage].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockFlutterSecureStorage extends _i1.Mock
+
implements _i8.FlutterSecureStorage {
+
MockFlutterSecureStorage() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i8.IOSOptions get iOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#iOptions),
+
returnValue: _FakeIOSOptions_6(this, Invocation.getter(#iOptions)),
+
)
+
as _i8.IOSOptions);
+
+
@override
+
_i8.AndroidOptions get aOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#aOptions),
+
returnValue: _FakeAndroidOptions_7(
+
this,
+
Invocation.getter(#aOptions),
+
),
+
)
+
as _i8.AndroidOptions);
+
+
@override
+
_i8.LinuxOptions get lOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#lOptions),
+
returnValue: _FakeLinuxOptions_8(
+
this,
+
Invocation.getter(#lOptions),
+
),
+
)
+
as _i8.LinuxOptions);
+
+
@override
+
_i8.WindowsOptions get wOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#wOptions),
+
returnValue: _FakeWindowsOptions_9(
+
this,
+
Invocation.getter(#wOptions),
+
),
+
)
+
as _i8.WindowsOptions);
+
+
@override
+
_i8.WebOptions get webOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#webOptions),
+
returnValue: _FakeWebOptions_10(
+
this,
+
Invocation.getter(#webOptions),
+
),
+
)
+
as _i8.WebOptions);
+
+
@override
+
_i8.MacOsOptions get mOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#mOptions),
+
returnValue: _FakeMacOsOptions_11(
+
this,
+
Invocation.getter(#mOptions),
+
),
+
)
+
as _i8.MacOsOptions);
+
+
@override
+
void registerListener({
+
required String? key,
+
required _i11.ValueChanged<String?>? listener,
+
}) => super.noSuchMethod(
+
Invocation.method(#registerListener, [], {#key: key, #listener: listener}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterListener({
+
required String? key,
+
required _i11.ValueChanged<String?>? listener,
+
}) => super.noSuchMethod(
+
Invocation.method(#unregisterListener, [], {
+
#key: key,
+
#listener: listener,
+
}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterAllListenersForKey({required String? key}) =>
+
super.noSuchMethod(
+
Invocation.method(#unregisterAllListenersForKey, [], {#key: key}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterAllListeners() => super.noSuchMethod(
+
Invocation.method(#unregisterAllListeners, []),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i9.Future<void> write({
+
required String? key,
+
required String? value,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#write, [], {
+
#key: key,
+
#value: value,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<String?> read({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#read, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<String?>.value(),
+
)
+
as _i9.Future<String?>);
+
+
@override
+
_i9.Future<bool> containsKey({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#containsKey, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<bool>.value(false),
+
)
+
as _i9.Future<bool>);
+
+
@override
+
_i9.Future<void> delete({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#delete, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<Map<String, String>> readAll({
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#readAll, [], {
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<Map<String, String>>.value(
+
<String, String>{},
+
),
+
)
+
as _i9.Future<Map<String, String>>);
+
+
@override
+
_i9.Future<void> deleteAll({
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#deleteAll, [], {
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<bool?> isCupertinoProtectedDataAvailable() =>
+
(super.noSuchMethod(
+
Invocation.method(#isCupertinoProtectedDataAvailable, []),
+
returnValue: _i9.Future<bool?>.value(),
+
)
+
as _i9.Future<bool?>);
+
}
+1102
test/services/coves_auth_service_validation_test.mocks.dart
···
+
// Mocks generated by Mockito 5.4.6 from annotations
+
// in coves_flutter/test/services/coves_auth_service_validation_test.dart.
+
// Do not manually edit this file.
+
+
// ignore_for_file: no_leading_underscores_for_library_prefixes
+
import 'dart:async' as _i9;
+
+
import 'package:dio/src/adapter.dart' as _i4;
+
import 'package:dio/src/cancel_token.dart' as _i10;
+
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:flutter/foundation.dart' as _i11;
+
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i8;
+
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);
+
}
+
+
class _FakeIOSOptions_6 extends _i1.SmartFake implements _i8.IOSOptions {
+
_FakeIOSOptions_6(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeAndroidOptions_7 extends _i1.SmartFake
+
implements _i8.AndroidOptions {
+
_FakeAndroidOptions_7(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeLinuxOptions_8 extends _i1.SmartFake implements _i8.LinuxOptions {
+
_FakeLinuxOptions_8(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeWindowsOptions_9 extends _i1.SmartFake
+
implements _i8.WindowsOptions {
+
_FakeWindowsOptions_9(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeWebOptions_10 extends _i1.SmartFake implements _i8.WebOptions {
+
_FakeWebOptions_10(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeMacOsOptions_11 extends _i1.SmartFake implements _i8.MacOsOptions {
+
_FakeMacOsOptions_11(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
+
_i9.Future<_i6.Response<T>> head<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#head,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> headUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#headUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> get<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#get,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> getUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
_i2.ProgressCallback? onReceiveProgress,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#getUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> post<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> postUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.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: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#postUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> put<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> putUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.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: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#putUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> patch<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> patchUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.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: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#patchUri,
+
[uri],
+
{
+
#data: data,
+
#options: options,
+
#cancelToken: cancelToken,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> delete<T>(
+
String? path, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#delete,
+
[path],
+
{
+
#data: data,
+
#queryParameters: queryParameters,
+
#options: options,
+
#cancelToken: cancelToken,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> deleteUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i2.Options? options,
+
_i10.CancelToken? cancelToken,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#deleteUri,
+
[uri],
+
{#data: data, #options: options, #cancelToken: cancelToken},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<dynamic>> download(
+
String? urlPath,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
Map<String, dynamic>? queryParameters,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i9.Future<_i6.Response<dynamic>> downloadUri(
+
Uri? uri,
+
dynamic savePath, {
+
_i2.ProgressCallback? onReceiveProgress,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<dynamic>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> request<T>(
+
String? url, {
+
Object? data,
+
Map<String, dynamic>? queryParameters,
+
_i10.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: _i9.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 _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> requestUri<T>(
+
Uri? uri, {
+
Object? data,
+
_i10.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: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(
+
#requestUri,
+
[uri],
+
{
+
#data: data,
+
#cancelToken: cancelToken,
+
#options: options,
+
#onSendProgress: onSendProgress,
+
#onReceiveProgress: onReceiveProgress,
+
},
+
),
+
),
+
),
+
)
+
as _i9.Future<_i6.Response<T>>);
+
+
@override
+
_i9.Future<_i6.Response<T>> fetch<T>(_i2.RequestOptions? requestOptions) =>
+
(super.noSuchMethod(
+
Invocation.method(#fetch, [requestOptions]),
+
returnValue: _i9.Future<_i6.Response<T>>.value(
+
_FakeResponse_4<T>(
+
this,
+
Invocation.method(#fetch, [requestOptions]),
+
),
+
),
+
)
+
as _i9.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);
+
}
+
+
/// A class which mocks [FlutterSecureStorage].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockFlutterSecureStorage extends _i1.Mock
+
implements _i8.FlutterSecureStorage {
+
MockFlutterSecureStorage() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i8.IOSOptions get iOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#iOptions),
+
returnValue: _FakeIOSOptions_6(this, Invocation.getter(#iOptions)),
+
)
+
as _i8.IOSOptions);
+
+
@override
+
_i8.AndroidOptions get aOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#aOptions),
+
returnValue: _FakeAndroidOptions_7(
+
this,
+
Invocation.getter(#aOptions),
+
),
+
)
+
as _i8.AndroidOptions);
+
+
@override
+
_i8.LinuxOptions get lOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#lOptions),
+
returnValue: _FakeLinuxOptions_8(
+
this,
+
Invocation.getter(#lOptions),
+
),
+
)
+
as _i8.LinuxOptions);
+
+
@override
+
_i8.WindowsOptions get wOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#wOptions),
+
returnValue: _FakeWindowsOptions_9(
+
this,
+
Invocation.getter(#wOptions),
+
),
+
)
+
as _i8.WindowsOptions);
+
+
@override
+
_i8.WebOptions get webOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#webOptions),
+
returnValue: _FakeWebOptions_10(
+
this,
+
Invocation.getter(#webOptions),
+
),
+
)
+
as _i8.WebOptions);
+
+
@override
+
_i8.MacOsOptions get mOptions =>
+
(super.noSuchMethod(
+
Invocation.getter(#mOptions),
+
returnValue: _FakeMacOsOptions_11(
+
this,
+
Invocation.getter(#mOptions),
+
),
+
)
+
as _i8.MacOsOptions);
+
+
@override
+
void registerListener({
+
required String? key,
+
required _i11.ValueChanged<String?>? listener,
+
}) => super.noSuchMethod(
+
Invocation.method(#registerListener, [], {#key: key, #listener: listener}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterListener({
+
required String? key,
+
required _i11.ValueChanged<String?>? listener,
+
}) => super.noSuchMethod(
+
Invocation.method(#unregisterListener, [], {
+
#key: key,
+
#listener: listener,
+
}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterAllListenersForKey({required String? key}) =>
+
super.noSuchMethod(
+
Invocation.method(#unregisterAllListenersForKey, [], {#key: key}),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void unregisterAllListeners() => super.noSuchMethod(
+
Invocation.method(#unregisterAllListeners, []),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
_i9.Future<void> write({
+
required String? key,
+
required String? value,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#write, [], {
+
#key: key,
+
#value: value,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<String?> read({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#read, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<String?>.value(),
+
)
+
as _i9.Future<String?>);
+
+
@override
+
_i9.Future<bool> containsKey({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#containsKey, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<bool>.value(false),
+
)
+
as _i9.Future<bool>);
+
+
@override
+
_i9.Future<void> delete({
+
required String? key,
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#delete, [], {
+
#key: key,
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<Map<String, String>> readAll({
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#readAll, [], {
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<Map<String, String>>.value(
+
<String, String>{},
+
),
+
)
+
as _i9.Future<Map<String, String>>);
+
+
@override
+
_i9.Future<void> deleteAll({
+
_i8.IOSOptions? iOptions,
+
_i8.AndroidOptions? aOptions,
+
_i8.LinuxOptions? lOptions,
+
_i8.WebOptions? webOptions,
+
_i8.MacOsOptions? mOptions,
+
_i8.WindowsOptions? wOptions,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#deleteAll, [], {
+
#iOptions: iOptions,
+
#aOptions: aOptions,
+
#lOptions: lOptions,
+
#webOptions: webOptions,
+
#mOptions: mOptions,
+
#wOptions: wOptions,
+
}),
+
returnValue: _i9.Future<void>.value(),
+
returnValueForMissingStub: _i9.Future<void>.value(),
+
)
+
as _i9.Future<void>);
+
+
@override
+
_i9.Future<bool?> isCupertinoProtectedDataAvailable() =>
+
(super.noSuchMethod(
+
Invocation.method(#isCupertinoProtectedDataAvailable, []),
+
returnValue: _i9.Future<bool?>.value(),
+
)
+
as _i9.Future<bool?>);
+
}
-23
test/services/vote_service_test.dart
···
});
});
-
group('ExistingVote', () {
-
test('should store direction and rkey', () {
-
const vote = ExistingVote(direction: 'up', rkey: 'abc123');
-
-
expect(vote.direction, 'up');
-
expect(vote.rkey, 'abc123');
-
});
-
});
-
-
group('VoteInfo', () {
-
test('should store vote info', () {
-
const info = VoteInfo(
-
direction: 'up',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/123',
-
rkey: '123',
-
);
-
-
expect(info.direction, 'up');
-
expect(info.voteUri, 'at://did:plc:test/social.coves.feed.vote/123');
-
expect(info.rkey, '123');
-
});
-
});
-
group('API Exception handling', () {
test('should throw ApiException on Dio network error', () {
final dioError = DioException(
+18 -9
test/test_helpers/mock_providers.dart
···
import 'package:coves_flutter/models/coves_session.dart';
import 'package:coves_flutter/providers/vote_provider.dart';
-
import 'package:coves_flutter/services/vote_service.dart';
import 'package:flutter/foundation.dart';
/// Mock AuthProvider for testing
···
notifyListeners();
}
-
void loadInitialVotes(Map<String, VoteInfo> votes) {
-
for (final entry in votes.entries) {
-
final postUri = entry.key;
-
final voteInfo = entry.value;
+
void setInitialVoteState({
+
required String postUri,
+
String? voteDirection,
+
String? voteUri,
+
}) {
+
if (voteDirection != null) {
+
String? rkey;
+
if (voteUri != null) {
+
final parts = voteUri.split('/');
+
if (parts.isNotEmpty) {
+
rkey = parts.last;
+
}
+
}
_votes[postUri] = VoteState(
-
direction: voteInfo.direction,
-
uri: voteInfo.voteUri,
-
rkey: voteInfo.rkey,
+
direction: voteDirection,
+
uri: voteUri,
+
rkey: rkey,
deleted: false,
);
_scoreAdjustments.remove(postUri);
+
} else {
+
_votes.remove(postUri);
}
-
notifyListeners();
}
void clear() {
+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);
+
}
+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;
+
}
+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
+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);
+
},
+
);
+
}
+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');
}
+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);
+
});
+
});
});
+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);
+
}
+12 -8
lib/screens/home/search_screen.dart lib/screens/home/communities_screen.dart
···
import '../../constants/app_colors.dart';
-
class SearchScreen extends StatelessWidget {
-
const SearchScreen({super.key});
+
class CommunitiesScreen extends StatelessWidget {
+
const CommunitiesScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
-
backgroundColor: const Color(0xFF0B0F14),
+
backgroundColor: AppColors.background,
appBar: AppBar(
-
backgroundColor: const Color(0xFF0B0F14),
+
backgroundColor: AppColors.background,
foregroundColor: Colors.white,
-
title: const Text('Search'),
+
title: const Text('Communities'),
automaticallyImplyLeading: false,
),
body: const Center(
···
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
-
Icon(Icons.search, size: 64, color: AppColors.primary),
+
Icon(
+
Icons.workspaces_outlined,
+
size: 64,
+
color: AppColors.primary,
+
),
SizedBox(height: 24),
Text(
-
'Search',
+
'Communities',
style: TextStyle(
fontSize: 28,
color: Colors.white,
···
),
SizedBox(height: 16),
Text(
-
'Search communities and conversations',
+
'Discover and join communities',
style: TextStyle(fontSize: 16, color: Color(0xFFB6C2D2)),
textAlign: TextAlign.center,
),
+162 -14
lib/screens/home/feed_screen.dart
···
import '../../models/post.dart';
import '../../providers/auth_provider.dart';
import '../../providers/feed_provider.dart';
+
import '../../widgets/icons/bluesky_icons.dart';
import '../../widgets/post_card.dart';
+
/// Header layout constants
+
const double _kHeaderHeight = 44;
+
const double _kTabUnderlineWidth = 28;
+
const double _kTabUnderlineHeight = 3;
+
const double _kHeaderContentPadding = _kHeaderHeight;
+
class FeedScreen extends StatefulWidget {
-
const FeedScreen({super.key});
+
const FeedScreen({super.key, this.onSearchTap});
+
+
/// Callback when search icon is tapped (to switch to communities tab)
+
final VoidCallback? onSearchTap;
@override
State<FeedScreen> createState() => _FeedScreenState();
···
);
final isLoading = context.select<FeedProvider, bool>((p) => p.isLoading);
final error = context.select<FeedProvider, String?>((p) => p.error);
+
final feedType = context.select<FeedProvider, FeedType>(
+
(p) => p.feedType,
+
);
// IMPORTANT: This relies on FeedProvider creating new list instances
// (_posts = [..._posts, ...response.feed]) rather than mutating in-place.
···
return Scaffold(
backgroundColor: AppColors.background,
-
appBar: AppBar(
-
backgroundColor: AppColors.background,
-
foregroundColor: AppColors.textPrimary,
-
title: Text(isAuthenticated ? 'Feed' : 'Explore'),
-
automaticallyImplyLeading: false,
-
),
body: SafeArea(
-
child: _buildBody(
-
isLoading: isLoading,
-
error: error,
-
posts: posts,
-
isLoadingMore: isLoadingMore,
-
isAuthenticated: isAuthenticated,
-
currentTime: currentTime,
+
child: Stack(
+
children: [
+
// Feed content (behind header)
+
_buildBody(
+
isLoading: isLoading,
+
error: error,
+
posts: posts,
+
isLoadingMore: isLoadingMore,
+
isAuthenticated: isAuthenticated,
+
currentTime: currentTime,
+
),
+
// Transparent header overlay
+
_buildHeader(
+
feedType: feedType,
+
isAuthenticated: isAuthenticated,
+
),
+
],
+
),
+
),
+
);
+
}
+
+
Widget _buildHeader({
+
required FeedType feedType,
+
required bool isAuthenticated,
+
}) {
+
return Container(
+
height: _kHeaderHeight,
+
decoration: BoxDecoration(
+
// Gradient fade from solid to transparent
+
gradient: LinearGradient(
+
begin: Alignment.topCenter,
+
end: Alignment.bottomCenter,
+
colors: [
+
AppColors.background,
+
AppColors.background.withValues(alpha: 0.8),
+
AppColors.background.withValues(alpha: 0),
+
],
+
stops: const [0.0, 0.6, 1.0],
+
),
+
),
+
padding: const EdgeInsets.symmetric(horizontal: 16),
+
child: Row(
+
children: [
+
// Feed type tabs in the center
+
Expanded(
+
child: _buildFeedTypeTabs(
+
feedType: feedType,
+
isAuthenticated: isAuthenticated,
+
),
+
),
+
// Search/Communities icon on the right
+
if (widget.onSearchTap != null)
+
Semantics(
+
label: 'Navigate to Communities',
+
button: true,
+
child: InkWell(
+
onTap: widget.onSearchTap,
+
borderRadius: BorderRadius.circular(20),
+
splashColor: AppColors.primary.withValues(alpha: 0.2),
+
child: Padding(
+
padding: const EdgeInsets.all(8),
+
child: BlueSkyIcon.search(color: AppColors.textPrimary),
+
),
+
),
+
),
+
],
+
),
+
);
+
}
+
+
Widget _buildFeedTypeTabs({
+
required FeedType feedType,
+
required bool isAuthenticated,
+
}) {
+
// If not authenticated, only show Discover
+
if (!isAuthenticated) {
+
return Center(
+
child: _buildFeedTypeTab(
+
label: 'Discover',
+
isActive: true,
+
onTap: null,
+
),
+
);
+
}
+
+
// Authenticated: show both tabs side by side (TikTok style)
+
return Row(
+
mainAxisAlignment: MainAxisAlignment.center,
+
children: [
+
_buildFeedTypeTab(
+
label: 'Discover',
+
isActive: feedType == FeedType.discover,
+
onTap: () => _switchToFeedType(FeedType.discover),
+
),
+
const SizedBox(width: 24),
+
_buildFeedTypeTab(
+
label: 'For You',
+
isActive: feedType == FeedType.forYou,
+
onTap: () => _switchToFeedType(FeedType.forYou),
+
),
+
],
+
);
+
}
+
+
Widget _buildFeedTypeTab({
+
required String label,
+
required bool isActive,
+
required VoidCallback? onTap,
+
}) {
+
return Semantics(
+
label: '$label feed${isActive ? ', selected' : ''}',
+
button: true,
+
selected: isActive,
+
child: GestureDetector(
+
onTap: onTap,
+
behavior: HitTestBehavior.opaque,
+
child: Column(
+
mainAxisSize: MainAxisSize.min,
+
mainAxisAlignment: MainAxisAlignment.center,
+
children: [
+
Text(
+
label,
+
style: TextStyle(
+
color: isActive
+
? AppColors.textPrimary
+
: AppColors.textSecondary.withValues(alpha: 0.6),
+
fontSize: 16,
+
fontWeight: isActive ? FontWeight.w700 : FontWeight.w400,
+
),
+
),
+
const SizedBox(height: 2),
+
// Underline indicator (TikTok style)
+
Container(
+
width: _kTabUnderlineWidth,
+
height: _kTabUnderlineHeight,
+
decoration: BoxDecoration(
+
color: isActive ? AppColors.textPrimary : Colors.transparent,
+
borderRadius: BorderRadius.circular(2),
+
),
+
),
+
],
),
),
);
}
+
void _switchToFeedType(FeedType type) {
+
Provider.of<FeedProvider>(context, listen: false).setFeedType(type);
+
}
+
Widget _buildBody({
required bool isLoading,
required String? error,
···
color: AppColors.primary,
child: ListView.builder(
controller: _scrollController,
+
// Add top padding so content isn't hidden behind transparent header
+
padding: const EdgeInsets.only(top: _kHeaderContentPadding),
// Add extra item for loading indicator or pagination error
itemCount: posts.length + (isLoadingMore || error != null ? 1 : 0),
itemBuilder: (context, index) {
+24 -13
lib/screens/home/main_shell_screen.dart
···
import '../../constants/app_colors.dart';
import '../../widgets/icons/bluesky_icons.dart';
+
import 'communities_screen.dart';
import 'create_post_screen.dart';
import 'feed_screen.dart';
import 'notifications_screen.dart';
import 'profile_screen.dart';
-
import 'search_screen.dart';
class MainShellScreen extends StatefulWidget {
const MainShellScreen({super.key});
···
class _MainShellScreenState extends State<MainShellScreen> {
int _selectedIndex = 0;
-
static const List<Widget> _screens = [
-
FeedScreen(),
-
SearchScreen(),
-
CreatePostScreen(),
-
NotificationsScreen(),
-
ProfileScreen(),
-
];
-
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
+
void _onCommunitiesTap() {
+
setState(() {
+
_selectedIndex = 1; // Switch to communities tab
+
});
+
}
+
@override
Widget build(BuildContext context) {
return Scaffold(
-
body: _screens[_selectedIndex],
+
body: IndexedStack(
+
index: _selectedIndex,
+
children: [
+
FeedScreen(onSearchTap: _onCommunitiesTap),
+
const CommunitiesScreen(),
+
const CreatePostScreen(),
+
const NotificationsScreen(),
+
const ProfileScreen(),
+
],
+
),
bottomNavigationBar: Container(
decoration: const BoxDecoration(
color: Color(0xFF0B0F14),
···
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildNavItem(0, 'home', 'Home'),
-
_buildNavItem(1, 'search', 'Search'),
+
_buildNavItem(1, 'communities', 'Communities'),
_buildNavItem(2, 'plus', 'Create'),
_buildNavItem(3, 'bell', 'Notifications'),
_buildNavItem(4, 'person', 'Me'),
···
case 'home':
icon = BlueSkyIcon.homeSimple(color: color);
break;
-
case 'search':
-
icon = BlueSkyIcon.search(color: color);
+
case 'communities':
+
icon = Icon(
+
isSelected ? Icons.workspaces : Icons.workspaces_outlined,
+
color: color,
+
size: 24,
+
);
break;
case 'plus':
icon = BlueSkyIcon.plus(color: color);
+21
lib/providers/comments_provider.dart
···
String? _cursor;
bool _hasMore = true;
+
// Collapsed thread state - stores URIs of collapsed comments
+
final Set<String> _collapsedComments = {};
+
// Current post being viewed
String? _postUri;
String? _postCid;
···
String get sort => _sort;
String? get timeframe => _timeframe;
ValueNotifier<DateTime?> get currentTimeNotifier => _currentTimeNotifier;
+
Set<String> get collapsedComments => _collapsedComments;
+
+
/// Toggle collapsed state for a comment thread
+
///
+
/// When collapsed, the comment's replies are hidden from view.
+
/// Long-pressing the same comment again will expand the thread.
+
void toggleCollapsed(String uri) {
+
if (_collapsedComments.contains(uri)) {
+
_collapsedComments.remove(uri);
+
} else {
+
_collapsedComments.add(uri);
+
}
+
notifyListeners();
+
}
+
+
/// Check if a specific comment is collapsed
+
bool isCollapsed(String uri) => _collapsedComments.contains(uri);
/// Start periodic time updates for "time ago" strings
///
···
_postUri = null;
_postCid = null;
_pendingRefresh = false;
+
_collapsedComments.clear();
notifyListeners();
}
+117 -67
lib/widgets/comment_card.dart
···
/// - Heart vote button with optimistic updates via VoteProvider
/// - Visual threading indicator based on nesting depth
/// - Tap-to-reply functionality via [onTap] callback
+
/// - Long-press to collapse thread via [onLongPress] callback
///
/// The [currentTime] parameter allows passing the current time for
/// time-ago calculations, enabling periodic updates and testing.
+
///
+
/// When [isCollapsed] is true, displays a badge showing [collapsedCount]
+
/// hidden replies on the threading indicator bar.
class CommentCard extends StatelessWidget {
const CommentCard({
required this.comment,
this.depth = 0,
this.currentTime,
this.onTap,
+
this.onLongPress,
+
this.isCollapsed = false,
+
this.collapsedCount = 0,
super.key,
});
···
/// Callback when the comment is tapped (for reply functionality)
final VoidCallback? onTap;
+
/// Callback when the comment is long-pressed (for collapse functionality)
+
final VoidCallback? onLongPress;
+
+
/// Whether this comment's thread is currently collapsed
+
final bool isCollapsed;
+
+
/// Number of replies hidden when collapsed
+
final int collapsedCount;
+
@override
Widget build(BuildContext context) {
// All comments get at least 1 threading line (depth + 1)
···
// the stroke width)
final borderLeftOffset = (threadingLineCount * 6.0) + 2.0;
-
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),
+
return GestureDetector(
+
onLongPress: onLongPress != null
+
? () {
+
HapticFeedback.mediumImpact();
+
onLongPress!();
+
}
+
: null,
+
child: 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),
-
),
-
// 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,
+
// Collapsed count badge - positioned after threading lines
+
// to avoid overlap at any depth level
+
if (isCollapsed && collapsedCount > 0)
+
Positioned(
+
left: borderLeftOffset + 4,
+
bottom: 8,
+
child: Container(
+
padding: const EdgeInsets.symmetric(
+
horizontal: 6,
+
vertical: 2,
+
),
+
decoration: BoxDecoration(
+
color: AppColors.primary,
+
borderRadius: BorderRadius.circular(8),
+
),
+
child: Text(
+
'+$collapsedCount hidden',
+
style: const TextStyle(
+
color: AppColors.textPrimary,
+
fontSize: 10,
+
fontWeight: FontWeight.w500,
+
),
+
),
+
),
+
),
+
// 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,
+
),
+
fontSize: 13,
+
fontWeight: FontWeight.w500,
),
-
fontSize: 13,
-
fontWeight: FontWeight.w500,
),
-
),
-
],
-
),
-
),
-
// Time ago
-
Text(
-
DateTimeUtils.formatTimeAgo(
-
comment.createdAt,
-
currentTime: currentTime,
+
],
+
),
),
-
style: TextStyle(
-
color: AppColors.textPrimary.withValues(alpha: 0.5),
-
fontSize: 12,
+
// Time ago
+
Text(
+
DateTimeUtils.formatTimeAgo(
+
comment.createdAt,
+
currentTime: currentTime,
+
),
+
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),
-
],
+
),
),
-
),
-
],
+
],
+
),
),
),
);
+3
.gitignore
···
/android/app/debug
/android/app/profile
/android/app/release
+
+
# macOS (not targeting this platform)
+
macos/
+1
ios/Flutter/Debug.xcconfig
···
+
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+1
ios/Flutter/Release.xcconfig
···
+
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+43
ios/Podfile
···
+
# Uncomment this line to define a global platform for your project
+
# platform :ios, '13.0'
+
+
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+
project 'Runner', {
+
'Debug' => :debug,
+
'Profile' => :release,
+
'Release' => :release,
+
}
+
+
def flutter_root
+
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
+
unless File.exist?(generated_xcode_build_settings_path)
+
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
+
end
+
+
File.foreach(generated_xcode_build_settings_path) do |line|
+
matches = line.match(/FLUTTER_ROOT\=(.*)/)
+
return matches[1].strip if matches
+
end
+
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
+
end
+
+
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+
flutter_ios_podfile_setup
+
+
target 'Runner' do
+
use_frameworks!
+
+
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+
target 'RunnerTests' do
+
inherit! :search_paths
+
end
+
end
+
+
post_install do |installer|
+
installer.pods_project.targets.each do |target|
+
flutter_additional_ios_build_settings(target)
+
end
+
end
+1 -1
ios/Flutter/AppFrameworkInfo.plist
···
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
-
<string>12.0</string>
+
<string>13.0</string>
</dict>
</plist>
+68
ios/Podfile.lock
···
+
PODS:
+
- Flutter (1.0.0)
+
- flutter_secure_storage (6.0.0):
+
- Flutter
+
- flutter_web_auth_2 (3.0.0):
+
- Flutter
+
- path_provider_foundation (0.0.1):
+
- Flutter
+
- FlutterMacOS
+
- share_plus (0.0.1):
+
- Flutter
+
- shared_preferences_foundation (0.0.1):
+
- Flutter
+
- FlutterMacOS
+
- sqflite_darwin (0.0.4):
+
- Flutter
+
- FlutterMacOS
+
- url_launcher_ios (0.0.1):
+
- Flutter
+
- video_player_avfoundation (0.0.1):
+
- Flutter
+
- FlutterMacOS
+
+
DEPENDENCIES:
+
- Flutter (from `Flutter`)
+
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
+
- flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`)
+
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
+
- share_plus (from `.symlinks/plugins/share_plus/ios`)
+
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
+
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
+
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
+
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
+
+
EXTERNAL SOURCES:
+
Flutter:
+
:path: Flutter
+
flutter_secure_storage:
+
:path: ".symlinks/plugins/flutter_secure_storage/ios"
+
flutter_web_auth_2:
+
:path: ".symlinks/plugins/flutter_web_auth_2/ios"
+
path_provider_foundation:
+
:path: ".symlinks/plugins/path_provider_foundation/darwin"
+
share_plus:
+
:path: ".symlinks/plugins/share_plus/ios"
+
shared_preferences_foundation:
+
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
+
sqflite_darwin:
+
:path: ".symlinks/plugins/sqflite_darwin/darwin"
+
url_launcher_ios:
+
:path: ".symlinks/plugins/url_launcher_ios/ios"
+
video_player_avfoundation:
+
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
+
+
SPEC CHECKSUMS:
+
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
+
flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80
+
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
+
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
+
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
+
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
+
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
+
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
+
+
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
+
+
COCOAPODS: 1.16.2
+2
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
···
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
···
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
+3
ios/Runner.xcworkspace/contents.xcworkspacedata
···
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
+
<FileRef
+
location = "group:Pods/Pods.xcodeproj">
+
</FileRef>
</Workspace>