chore: update dependencies and apply code formatting

Update dependencies to support URL launching and apply Dart
formatting to all modified files.

Dependencies:
- Add url_launcher: ^6.3.1 (for external browser launching)
- Add url_launcher_platform_interface: ^2.3.2 (dev, for testing)

Code Formatting:
- Apply dart format to all 95 files in codebase
- Ensures consistent style following Dart guidelines
- All files pass flutter analyze with 0 errors, 0 warnings

Quality Checks:
✅ dart format . (95 files formatted)
✅ flutter analyze (0 errors, 0 warnings, 43 info)
✅ flutter test (143 passing, 9 skipped, 0 failing)

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

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

+9 -8
lib/config/environment_config.dart
···
/// - Local: Local PDS + PLC for development/testing
///
/// Set via ENVIRONMENT environment variable or flutter run --dart-define
-
enum Environment {
-
production,
-
local,
-
}
class EnvironmentConfig {
-
const EnvironmentConfig({
required this.environment,
required this.apiUrl,
···
static const production = EnvironmentConfig(
environment: Environment.production,
apiUrl: 'https://coves.social', // TODO: Update when production is live
-
handleResolverUrl: 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle',
plcDirectoryUrl: 'https://plc.directory',
);
···
static const local = EnvironmentConfig(
environment: Environment.local,
apiUrl: 'http://localhost:8081',
-
handleResolverUrl: 'http://localhost:3001/xrpc/com.atproto.identity.resolveHandle',
plcDirectoryUrl: 'http://localhost:3002',
);
/// Get current environment based on build configuration
static EnvironmentConfig get current {
// Read from --dart-define=ENVIRONMENT=local
-
const envString = String.fromEnvironment('ENVIRONMENT', defaultValue: 'production');
switch (envString) {
case 'local':
···
/// - Local: Local PDS + PLC for development/testing
///
/// Set via ENVIRONMENT environment variable or flutter run --dart-define
+
enum Environment { production, local }
class EnvironmentConfig {
const EnvironmentConfig({
required this.environment,
required this.apiUrl,
···
static const production = EnvironmentConfig(
environment: Environment.production,
apiUrl: 'https://coves.social', // TODO: Update when production is live
+
handleResolverUrl:
+
'https://bsky.social/xrpc/com.atproto.identity.resolveHandle',
plcDirectoryUrl: 'https://plc.directory',
);
···
static const local = EnvironmentConfig(
environment: Environment.local,
apiUrl: 'http://localhost:8081',
+
handleResolverUrl:
+
'http://localhost:3001/xrpc/com.atproto.identity.resolveHandle',
plcDirectoryUrl: 'http://localhost:3002',
);
/// Get current environment based on build configuration
static EnvironmentConfig get current {
// Read from --dart-define=ENVIRONMENT=local
+
const envString = String.fromEnvironment(
+
'ENVIRONMENT',
+
defaultValue: 'production',
+
);
switch (envString) {
case 'local':
+11 -9
lib/main.dart
···
providers: [
ChangeNotifierProvider.value(value: authProvider),
ChangeNotifierProvider(
-
create: (_) => VoteProvider(
-
voteService: voteService,
-
authProvider: authProvider,
-
),
),
ChangeNotifierProxyProvider2<AuthProvider, VoteProvider, FeedProvider>(
-
create: (context) => FeedProvider(
-
authProvider,
-
voteProvider: context.read<VoteProvider>(),
-
voteService: voteService,
-
),
update: (context, auth, vote, previous) {
// Reuse existing provider to maintain state across rebuilds
return previous ??
···
providers: [
ChangeNotifierProvider.value(value: authProvider),
ChangeNotifierProvider(
+
create:
+
(_) => VoteProvider(
+
voteService: voteService,
+
authProvider: authProvider,
+
),
),
ChangeNotifierProxyProvider2<AuthProvider, VoteProvider, FeedProvider>(
+
create:
+
(context) => FeedProvider(
+
authProvider,
+
voteProvider: context.read<VoteProvider>(),
+
voteService: voteService,
+
),
update: (context, auth, vote, previous) {
// Reuse existing provider to maintain state across rebuilds
return previous ??
+2 -2
lib/providers/feed_provider.dart
···
CovesApiService? apiService,
VoteProvider? voteProvider,
VoteService? voteService,
-
}) : _voteProvider = voteProvider,
-
_voteService = voteService {
// Use injected service (for testing) or create new one (for production)
// Pass token getter to API service for automatic fresh token retrieval
_apiService =
···
CovesApiService? apiService,
VoteProvider? voteProvider,
VoteService? voteService,
+
}) : _voteProvider = voteProvider,
+
_voteService = voteService {
// Use injected service (for testing) or create new one (for production)
// Pass token getter to API service for automatic fresh token retrieval
_apiService =
+6 -12
lib/providers/vote_provider.dart
···
VoteProvider({
required VoteService voteService,
required AuthProvider authProvider,
-
}) : _voteService = voteService,
-
_authProvider = authProvider {
// Listen to auth state changes and clear votes on sign-out
_authProvider.addListener(_onAuthChanged);
}
···
newAdjustment += 1; // Remove downvote
}
} else if (currentState?.direction != null &&
-
currentState?.direction != direction &&
-
!(currentState?.deleted ?? false)) {
// Switching vote direction
if (direction == 'up') {
newAdjustment += 2; // Remove downvote (-1) and add upvote (+1)
···
);
} else {
// Create or switch direction
-
_votes[postUri] = VoteState(
-
direction: direction,
-
deleted: false,
-
);
}
// Apply score adjustment
···
// Update with server response
if (response.deleted) {
// Vote was removed
-
_votes[postUri] = VoteState(
-
direction: direction,
-
deleted: true,
-
);
} else {
// Vote was created or updated
_votes[postUri] = VoteState(
···
VoteProvider({
required VoteService voteService,
required AuthProvider authProvider,
+
}) : _voteService = voteService,
+
_authProvider = authProvider {
// Listen to auth state changes and clear votes on sign-out
_authProvider.addListener(_onAuthChanged);
}
···
newAdjustment += 1; // Remove downvote
}
} else if (currentState?.direction != null &&
+
currentState?.direction != direction &&
+
!(currentState?.deleted ?? false)) {
// Switching vote direction
if (direction == 'up') {
newAdjustment += 2; // Remove downvote (-1) and add upvote (+1)
···
);
} else {
// Create or switch direction
+
_votes[postUri] = VoteState(direction: direction, deleted: false);
}
// Apply score adjustment
···
// Update with server response
if (response.deleted) {
// Vote was removed
+
_votes[postUri] = VoteState(direction: direction, deleted: true);
} else {
// Vote was created or updated
_votes[postUri] = VoteState(
+1 -1
lib/services/pds_discovery_service.dart
···
/// 4. Return the PDS URL for OAuth discovery
class PDSDiscoveryService {
PDSDiscoveryService({EnvironmentConfig? config})
-
: _config = config ?? EnvironmentConfig.current;
final Dio _dio = Dio();
final EnvironmentConfig _config;
···
/// 4. Return the PDS URL for OAuth discovery
class PDSDiscoveryService {
PDSDiscoveryService({EnvironmentConfig? config})
+
: _config = config ?? EnvironmentConfig.current;
final Dio _dio = Dio();
final EnvironmentConfig _config;
+16 -33
lib/services/vote_service.dart
···
Future<OAuthSession?> Function()? sessionGetter,
String? Function()? didGetter,
String? Function()? pdsUrlGetter,
-
}) : _sessionGetter = sessionGetter,
-
_didGetter = didGetter,
-
_pdsUrlGetter = pdsUrlGetter;
final Future<OAuthSession?> Function()? _sessionGetter;
final String? Function()? _didGetter;
···
// Paginate through all vote records
do {
-
final url = cursor == null
-
? '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=100'
-
: '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=100&cursor=$cursor';
final response = await session.fetchHandler(url, method: 'GET');
···
if (kDebugMode) {
debugPrint(' Same direction - deleting vote');
}
-
await _deleteVote(
-
userDid: userDid,
-
rkey: existingVote.rkey,
-
);
return const VoteResponse(deleted: true);
}
···
if (kDebugMode) {
debugPrint(' Different direction - switching vote');
}
-
await _deleteVote(
-
userDid: userDid,
-
rkey: existingVote.rkey,
-
);
}
// Step 2: Create new vote
···
do {
// Build URL with cursor if available
-
final url = cursor == null
-
? '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=$pageSize&reverse=true'
-
: '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=$pageSize&reverse=true&cursor=$cursor';
final response = await session.fetchHandler(url, method: 'GET');
···
// Build the vote record according to the lexicon
final record = {
r'$type': voteCollection,
-
'subject': {
-
'uri': postUri,
-
'cid': postCid,
-
},
'direction': direction,
'createdAt': DateTime.now().toUtc().toIso8601String(),
};
···
// Extract rkey from URI
final rkey = uri.split('/').last;
-
return VoteResponse(
-
uri: uri,
-
cid: cid,
-
rkey: rkey,
-
deleted: false,
-
);
}
/// Delete vote record from PDS
···
///
/// Response from createVote operation.
class VoteResponse {
-
const VoteResponse({
-
this.uri,
-
this.cid,
-
this.rkey,
-
required this.deleted,
-
});
/// AT-URI of the created vote record
final String? uri;
···
Future<OAuthSession?> Function()? sessionGetter,
String? Function()? didGetter,
String? Function()? pdsUrlGetter,
+
}) : _sessionGetter = sessionGetter,
+
_didGetter = didGetter,
+
_pdsUrlGetter = pdsUrlGetter;
final Future<OAuthSession?> Function()? _sessionGetter;
final String? Function()? _didGetter;
···
// Paginate through all vote records
do {
+
final url =
+
cursor == null
+
? '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=100'
+
: '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=100&cursor=$cursor';
final response = await session.fetchHandler(url, method: 'GET');
···
if (kDebugMode) {
debugPrint(' Same direction - deleting vote');
}
+
await _deleteVote(userDid: userDid, rkey: existingVote.rkey);
return const VoteResponse(deleted: true);
}
···
if (kDebugMode) {
debugPrint(' Different direction - switching vote');
}
+
await _deleteVote(userDid: userDid, rkey: existingVote.rkey);
}
// Step 2: Create new vote
···
do {
// Build URL with cursor if available
+
final url =
+
cursor == null
+
? '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=$pageSize&reverse=true'
+
: '/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=$pageSize&reverse=true&cursor=$cursor';
final response = await session.fetchHandler(url, method: 'GET');
···
// Build the vote record according to the lexicon
final record = {
r'$type': voteCollection,
+
'subject': {'uri': postUri, 'cid': postCid},
'direction': direction,
'createdAt': DateTime.now().toUtc().toIso8601String(),
};
···
// Extract rkey from URI
final rkey = uri.split('/').last;
+
return VoteResponse(uri: uri, cid: cid, rkey: rkey, deleted: false);
}
/// Delete vote record from PDS
···
///
/// Response from createVote operation.
class VoteResponse {
+
const VoteResponse({this.uri, this.cid, this.rkey, required this.deleted});
/// AT-URI of the created vote record
final String? uri;
+4 -3
lib/widgets/icons/reply_icon.dart
···
@override
void paint(Canvas canvas, Size size) {
-
final paint = Paint()
-
..color = color
-
..style = PaintingStyle.fill; // Always fill - paths are pre-stroked
// Scale factor to fit 24x24 viewBox into widget size
final scale = size.width / 24.0;
···
@override
void paint(Canvas canvas, Size size) {
+
final paint =
+
Paint()
+
..color = color
+
..style = PaintingStyle.fill; // Always fill - paths are pre-stroked
// Scale factor to fit 24x24 viewBox into widget size
final scale = size.width / 24.0;
+39 -37
lib/widgets/icons/share_icon.dart
···
@override
void paint(Canvas canvas, Size size) {
-
final paint = Paint()
-
..color = color
-
..style = PaintingStyle.fill; // Always fill - paths are pre-stroked
// Scale factor to fit 24x24 viewBox into widget size
final scale = size.width / 24.0;
···
// L8.207 9.207a1 1 0 1 1-1.414-1.414l4.5-4.5A1 1 0 0 1 12 3Z
// Box bottom part
-
final path = Path()
-
..moveTo(20, 13.75)
-
..cubicTo(20.552, 13.75, 21, 14.198, 21, 14.75)
-
..lineTo(21, 18)
-
..cubicTo(21, 19.657, 19.657, 21, 18, 21)
-
..lineTo(6, 21)
-
..cubicTo(4.343, 21, 3, 19.657, 3, 18)
-
..lineTo(3, 14.75)
-
..cubicTo(3, 14.198, 3.448, 13.75, 4, 13.75)
-
..cubicTo(4.552, 13.75, 5, 14.198, 5, 14.75)
-
..lineTo(5, 18)
-
..cubicTo(5, 18.552, 5.448, 19, 6, 19)
-
..lineTo(18, 19)
-
..cubicTo(18.552, 19, 19, 18.552, 19, 18)
-
..lineTo(19, 14.75)
-
..cubicTo(19, 14.198, 19.448, 13.75, 20, 13.75)
-
..close()
-
// Arrow
-
..moveTo(12, 3)
-
..cubicTo(12.265, 3, 12.52, 3.105, 12.707, 3.293)
-
..lineTo(17.207, 7.793)
-
..cubicTo(17.598, 8.184, 17.598, 8.817, 17.207, 9.207)
-
..cubicTo(16.816, 9.598, 16.183, 9.598, 15.793, 9.207)
-
..lineTo(13, 6.414)
-
..lineTo(13, 15.25)
-
..cubicTo(13, 15.802, 12.552, 16.25, 12, 16.25)
-
..cubicTo(11.448, 16.25, 11, 15.802, 11, 15.25)
-
..lineTo(11, 6.414)
-
..lineTo(8.207, 9.207)
-
..cubicTo(7.816, 9.598, 7.183, 9.598, 6.793, 9.207)
-
..cubicTo(6.402, 8.816, 6.402, 8.183, 6.793, 7.793)
-
..lineTo(11.293, 3.293)
-
..cubicTo(11.48, 3.105, 11.735, 3, 12, 3)
-
..close();
canvas.drawPath(path, paint);
}
···
@override
void paint(Canvas canvas, Size size) {
+
final paint =
+
Paint()
+
..color = color
+
..style = PaintingStyle.fill; // Always fill - paths are pre-stroked
// Scale factor to fit 24x24 viewBox into widget size
final scale = size.width / 24.0;
···
// L8.207 9.207a1 1 0 1 1-1.414-1.414l4.5-4.5A1 1 0 0 1 12 3Z
// Box bottom part
+
final path =
+
Path()
+
..moveTo(20, 13.75)
+
..cubicTo(20.552, 13.75, 21, 14.198, 21, 14.75)
+
..lineTo(21, 18)
+
..cubicTo(21, 19.657, 19.657, 21, 18, 21)
+
..lineTo(6, 21)
+
..cubicTo(4.343, 21, 3, 19.657, 3, 18)
+
..lineTo(3, 14.75)
+
..cubicTo(3, 14.198, 3.448, 13.75, 4, 13.75)
+
..cubicTo(4.552, 13.75, 5, 14.198, 5, 14.75)
+
..lineTo(5, 18)
+
..cubicTo(5, 18.552, 5.448, 19, 6, 19)
+
..lineTo(18, 19)
+
..cubicTo(18.552, 19, 19, 18.552, 19, 18)
+
..lineTo(19, 14.75)
+
..cubicTo(19, 14.198, 19.448, 13.75, 20, 13.75)
+
..close()
+
// Arrow
+
..moveTo(12, 3)
+
..cubicTo(12.265, 3, 12.52, 3.105, 12.707, 3.293)
+
..lineTo(17.207, 7.793)
+
..cubicTo(17.598, 8.184, 17.598, 8.817, 17.207, 9.207)
+
..cubicTo(16.816, 9.598, 16.183, 9.598, 15.793, 9.207)
+
..lineTo(13, 6.414)
+
..lineTo(13, 15.25)
+
..cubicTo(13, 15.802, 12.552, 16.25, 12, 16.25)
+
..cubicTo(11.448, 16.25, 11, 15.802, 11, 15.25)
+
..lineTo(11, 6.414)
+
..lineTo(8.207, 9.207)
+
..cubicTo(7.816, 9.598, 7.183, 9.598, 6.793, 9.207)
+
..cubicTo(6.402, 8.816, 6.402, 8.183, 6.793, 7.793)
+
..lineTo(11.293, 3.293)
+
..cubicTo(11.48, 3.105, 11.735, 3, 12, 3)
+
..close();
canvas.drawPath(path, paint);
}
+3 -9
packages/atproto_oauth_flutter/lib/src/client/oauth_client.dart
···
// Restore DPoP key with error handling for corrupted JWK data
final FlutterKey dpopKey;
try {
-
dpopKey = FlutterKey.fromJwk(
-
stateData.dpopKey as Map<String, dynamic>,
-
);
if (kDebugMode) {
print('🔓 DPoP key restored successfully for token exchange');
}
···
// This ensures DPoP proofs match the token binding
final FlutterKey dpopKey;
try {
-
dpopKey = FlutterKey.fromJwk(
-
session.dpopKey as Map<String, dynamic>,
-
);
} catch (e) {
// If key is corrupted, delete the session and force re-authentication
await _sessionGetter.delStored(
···
// This ensures DPoP proofs match the token binding
final FlutterKey dpopKey;
try {
-
dpopKey = FlutterKey.fromJwk(
-
session.dpopKey as Map<String, dynamic>,
-
);
} catch (e) {
// If key is corrupted, skip server-side revocation
// The finally block will still delete the local session
···
// Restore DPoP key with error handling for corrupted JWK data
final FlutterKey dpopKey;
try {
+
dpopKey = FlutterKey.fromJwk(stateData.dpopKey as Map<String, dynamic>);
if (kDebugMode) {
print('🔓 DPoP key restored successfully for token exchange');
}
···
// This ensures DPoP proofs match the token binding
final FlutterKey dpopKey;
try {
+
dpopKey = FlutterKey.fromJwk(session.dpopKey as Map<String, dynamic>);
} catch (e) {
// If key is corrupted, delete the session and force re-authentication
await _sessionGetter.delStored(
···
// This ensures DPoP proofs match the token binding
final FlutterKey dpopKey;
try {
+
dpopKey = FlutterKey.fromJwk(session.dpopKey as Map<String, dynamic>);
} catch (e) {
// If key is corrupted, skip server-side revocation
// The finally block will still delete the local session
+8 -3
packages/atproto_oauth_flutter/lib/src/dpop/fetch_dpop.dart
···
// Check for nonce errors in successful responses (when validateStatus: true)
// This handles the case where Dio returns 401 as a successful response
-
if (nextNonce != null && await _isUseDpopNonceError(response, options.isAuthServer)) {
final isTokenEndpoint =
uri.path.contains('/token') || uri.path.endsWith('/token');
if (kDebugMode) {
-
print('⚠️ DPoP nonce error in response (status ${response.statusCode})');
print(' Is token endpoint: $isTokenEndpoint');
}
if (isTokenEndpoint) {
// Don't retry token endpoint - just pass through with nonce cached
if (kDebugMode) {
-
print(' NOT retrying token endpoint (nonce cached for next attempt)');
}
handler.next(response);
return;
···
// Check for nonce errors in successful responses (when validateStatus: true)
// This handles the case where Dio returns 401 as a successful response
+
if (nextNonce != null &&
+
await _isUseDpopNonceError(response, options.isAuthServer)) {
final isTokenEndpoint =
uri.path.contains('/token') || uri.path.endsWith('/token');
if (kDebugMode) {
+
print(
+
'⚠️ DPoP nonce error in response (status ${response.statusCode})',
+
);
print(' Is token endpoint: $isTokenEndpoint');
}
if (isTokenEndpoint) {
// Don't retry token endpoint - just pass through with nonce cached
if (kDebugMode) {
+
print(
+
' NOT retrying token endpoint (nonce cached for next attempt)',
+
);
}
handler.next(response);
return;
+1 -2
packages/atproto_oauth_flutter/lib/src/session/oauth_session.dart
···
method: method,
headers: headers,
responseType: ResponseType.bytes, // Get raw bytes for compatibility
-
validateStatus: (status) =>
-
true, // Don't throw on any status code
),
data: body,
);
···
method: method,
headers: headers,
responseType: ResponseType.bytes, // Get raw bytes for compatibility
+
validateStatus: (status) => true, // Don't throw on any status code
),
data: body,
);
+2 -2
pubspec.lock
···
source: hosted
version: "1.4.0"
url_launcher:
-
dependency: transitive
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
···
source: hosted
version: "3.2.4"
url_launcher_platform_interface:
-
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
···
source: hosted
version: "1.4.0"
url_launcher:
+
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
···
source: hosted
version: "3.2.4"
url_launcher_platform_interface:
+
dependency: "direct dev"
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
+2
pubspec.yaml
···
flutter_svg: ^2.2.1
dio: ^5.9.0
cached_network_image: ^3.4.1
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
···
flutter_svg: ^2.2.1
dio: ^5.9.0
cached_network_image: ^3.4.1
+
url_launcher: ^6.3.1
dev_dependencies:
flutter_test:
sdk: flutter
+
url_launcher_platform_interface: ^2.3.2
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
+96 -108
test/providers/vote_provider_test.dart
···
existingVoteRkey: anyNamed('existingVoteRkey'),
existingVoteDirection: anyNamed('existingVoteDirection'),
),
-
).thenAnswer(
-
(_) async => const VoteResponse(deleted: true),
-
);
// Toggle vote off
final wasLiked = await voteProvider.toggleVote(
···
existingVoteRkey: anyNamed('existingVoteRkey'),
existingVoteDirection: anyNamed('existingVoteDirection'),
),
-
).thenThrow(
-
ApiException('Network error', statusCode: 500),
-
);
var notificationCount = 0;
voteProvider.addListener(() {
···
existingVoteRkey: anyNamed('existingVoteRkey'),
existingVoteDirection: anyNamed('existingVoteDirection'),
),
-
).thenThrow(
-
NetworkException('Connection failed'),
-
);
// Try to toggle vote off
expect(
···
existingVoteRkey: anyNamed('existingVoteRkey'),
existingVoteDirection: anyNamed('existingVoteDirection'),
),
-
).thenAnswer(
-
(_) async {
-
await Future.delayed(const Duration(milliseconds: 100));
-
return const VoteResponse(
-
uri: 'at://did:plc:test/social.coves.feed.vote/456',
-
cid: 'bafy123',
-
rkey: '456',
-
deleted: false,
-
);
-
},
-
);
// Start first request
final future1 = voteProvider.toggleVote(
···
expect(voteProvider.isLiked(testPostUri), true);
// Then clear it
-
voteProvider.setInitialVoteState(
-
postUri: testPostUri,
-
);
expect(voteProvider.isLiked(testPostUri), false);
expect(voteProvider.getVoteState(testPostUri), null);
···
existingVoteRkey: anyNamed('existingVoteRkey'),
existingVoteDirection: anyNamed('existingVoteDirection'),
),
-
).thenAnswer(
-
(_) async {
-
await Future.delayed(const Duration(milliseconds: 50));
-
return const VoteResponse(
-
uri: 'at://did:plc:test/social.coves.feed.vote/456',
-
cid: 'bafy123',
-
rkey: '456',
-
deleted: false,
-
);
-
},
-
);
expect(voteProvider.isPending(testPostUri), false);
···
existingVoteRkey: anyNamed('existingVoteRkey'),
existingVoteDirection: anyNamed('existingVoteDirection'),
),
-
).thenAnswer(
-
(_) async => const VoteResponse(deleted: true),
-
);
const serverScore = 10;
···
expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 9);
});
-
test('should adjust score when switching from upvote to downvote',
-
() async {
-
// Set initial state with upvote
-
voteProvider.setInitialVoteState(
-
postUri: testPostUri,
-
voteDirection: 'up',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
-
);
-
when(
-
mockVoteService.createVote(
-
postUri: anyNamed('postUri'),
-
postCid: anyNamed('postCid'),
-
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
-
),
-
).thenAnswer(
-
(_) async => const VoteResponse(
-
uri: 'at://did:plc:test/social.coves.feed.vote/789',
-
cid: 'bafy789',
-
rkey: '789',
-
deleted: false,
-
),
-
);
-
const serverScore = 10;
-
// Switch to downvote
-
await voteProvider.toggleVote(
-
postUri: testPostUri,
-
postCid: testPostCid,
-
direction: 'down',
-
);
-
// Should have -2 adjustment (remove +1, add -1)
-
expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 8);
-
});
-
test('should adjust score when switching from downvote to upvote',
-
() async {
-
// Set initial state with downvote
-
voteProvider.setInitialVoteState(
-
postUri: testPostUri,
-
voteDirection: 'down',
-
voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
-
);
-
when(
-
mockVoteService.createVote(
-
postUri: anyNamed('postUri'),
-
postCid: anyNamed('postCid'),
-
direction: anyNamed('direction'),
-
existingVoteRkey: anyNamed('existingVoteRkey'),
-
existingVoteDirection: anyNamed('existingVoteDirection'),
-
),
-
).thenAnswer(
-
(_) async => const VoteResponse(
-
uri: 'at://did:plc:test/social.coves.feed.vote/789',
-
cid: 'bafy789',
-
rkey: '789',
-
deleted: false,
-
),
-
);
-
const serverScore = 10;
-
// Switch to upvote
-
await voteProvider.toggleVote(
-
postUri: testPostUri,
-
postCid: testPostCid,
-
direction: 'up',
-
);
-
// Should have +2 adjustment (remove -1, add +1)
-
expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 12);
-
});
test('should rollback score adjustment on error', () async {
const serverScore = 10;
···
existingVoteRkey: anyNamed('existingVoteRkey'),
existingVoteDirection: anyNamed('existingVoteDirection'),
),
-
).thenThrow(
-
ApiException('Network error', statusCode: 500),
-
);
// Try to vote (will fail)
expect(
···
existingVoteRkey: anyNamed('existingVoteRkey'),
existingVoteDirection: anyNamed('existingVoteDirection'),
),
+
).thenAnswer((_) async => const VoteResponse(deleted: true));
// Toggle vote off
final wasLiked = await voteProvider.toggleVote(
···
existingVoteRkey: anyNamed('existingVoteRkey'),
existingVoteDirection: anyNamed('existingVoteDirection'),
),
+
).thenThrow(ApiException('Network error', statusCode: 500));
var notificationCount = 0;
voteProvider.addListener(() {
···
existingVoteRkey: anyNamed('existingVoteRkey'),
existingVoteDirection: anyNamed('existingVoteDirection'),
),
+
).thenThrow(NetworkException('Connection failed'));
// Try to toggle vote off
expect(
···
existingVoteRkey: anyNamed('existingVoteRkey'),
existingVoteDirection: anyNamed('existingVoteDirection'),
),
+
).thenAnswer((_) async {
+
await Future.delayed(const Duration(milliseconds: 100));
+
return const VoteResponse(
+
uri: 'at://did:plc:test/social.coves.feed.vote/456',
+
cid: 'bafy123',
+
rkey: '456',
+
deleted: false,
+
);
+
});
// Start first request
final future1 = voteProvider.toggleVote(
···
expect(voteProvider.isLiked(testPostUri), true);
// Then clear it
+
voteProvider.setInitialVoteState(postUri: testPostUri);
expect(voteProvider.isLiked(testPostUri), false);
expect(voteProvider.getVoteState(testPostUri), null);
···
existingVoteRkey: anyNamed('existingVoteRkey'),
existingVoteDirection: anyNamed('existingVoteDirection'),
),
+
).thenAnswer((_) async {
+
await Future.delayed(const Duration(milliseconds: 50));
+
return const VoteResponse(
+
uri: 'at://did:plc:test/social.coves.feed.vote/456',
+
cid: 'bafy123',
+
rkey: '456',
+
deleted: false,
+
);
+
});
expect(voteProvider.isPending(testPostUri), false);
···
existingVoteRkey: anyNamed('existingVoteRkey'),
existingVoteDirection: anyNamed('existingVoteDirection'),
),
+
).thenAnswer((_) async => const VoteResponse(deleted: true));
const serverScore = 10;
···
expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 9);
});
+
test(
+
'should adjust score when switching from upvote to downvote',
+
() async {
+
// Set initial state with upvote
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
+
);
+
when(
+
mockVoteService.createVote(
+
postUri: anyNamed('postUri'),
+
postCid: anyNamed('postCid'),
+
direction: anyNamed('direction'),
+
existingVoteRkey: anyNamed('existingVoteRkey'),
+
existingVoteDirection: anyNamed('existingVoteDirection'),
+
),
+
).thenAnswer(
+
(_) async => const VoteResponse(
+
uri: 'at://did:plc:test/social.coves.feed.vote/789',
+
cid: 'bafy789',
+
rkey: '789',
+
deleted: false,
+
),
+
);
+
const serverScore = 10;
+
// Switch to downvote
+
await voteProvider.toggleVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
direction: 'down',
+
);
+
// Should have -2 adjustment (remove +1, add -1)
+
expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 8);
+
},
+
);
+
test(
+
'should adjust score when switching from downvote to upvote',
+
() async {
+
// Set initial state with downvote
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'down',
+
voteUri: 'at://did:plc:test/social.coves.feed.vote/456',
+
);
+
when(
+
mockVoteService.createVote(
+
postUri: anyNamed('postUri'),
+
postCid: anyNamed('postCid'),
+
direction: anyNamed('direction'),
+
existingVoteRkey: anyNamed('existingVoteRkey'),
+
existingVoteDirection: anyNamed('existingVoteDirection'),
+
),
+
).thenAnswer(
+
(_) async => const VoteResponse(
+
uri: 'at://did:plc:test/social.coves.feed.vote/789',
+
cid: 'bafy789',
+
rkey: '789',
+
deleted: false,
+
),
+
);
+
const serverScore = 10;
+
// Switch to upvote
+
await voteProvider.toggleVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
direction: 'up',
+
);
+
// Should have +2 adjustment (remove -1, add +1)
+
expect(voteProvider.getAdjustedScore(testPostUri, serverScore), 12);
+
},
+
);
test('should rollback score adjustment on error', () async {
const serverScore = 10;
···
existingVoteRkey: anyNamed('existingVoteRkey'),
existingVoteDirection: anyNamed('existingVoteDirection'),
),
+
).thenThrow(ApiException('Network error', statusCode: 500));
// Try to vote (will fail)
expect(
+13 -15
test/services/vote_service_test.dart
···
headers: anyNamed('headers'),
body: anyNamed('body'),
),
-
).thenAnswer(
-
(_) async => http.Response(jsonEncode({}), 200),
-
);
// Test that vote is found via reflection (private method)
// This is verified indirectly through createVote behavior
···
'uri': 'at://did:plc:test/social.coves.feed.vote/abc1',
'value': {
'subject': {
-
'uri': 'at://did:plc:author/social.coves.post.record/other1',
'cid': 'bafy001',
},
'direction': 'up',
···
'uri': 'at://did:plc:test/social.coves.feed.vote/abc123',
'value': {
'subject': {
-
'uri': 'at://did:plc:author/social.coves.post.record/target',
'cid': 'bafy123',
},
'direction': 'up',
···
when(
mockSession.fetchHandler(
-
argThat(allOf(contains('listRecords'), contains('cursor=cursor123'))),
method: 'GET',
),
).thenAnswer((_) async => secondPageResponse);
···
headers: anyNamed('headers'),
body: anyNamed('body'),
),
-
).thenAnswer(
-
(_) async => http.Response(jsonEncode({}), 200),
-
);
// Test that pagination works by creating vote that exists on page 2
final response = await service.createVote(
···
verify(
mockSession.fetchHandler(
-
argThat(allOf(contains('listRecords'), contains('cursor=cursor123'))),
method: 'GET',
),
).called(1);
···
});
group('createVote', () {
-
test('should create vote successfully', () async {
// Create a real VoteService instance that we can test with
// We'll use a minimal test to verify the VoteResponse parsing logic
···
final exception = ApiException.fromDioError(dioError);
expect(exception, isA<NetworkException>());
-
expect(
-
exception.message,
-
contains('Connection failed'),
-
);
});
test('should throw ApiException on Dio timeout', () {
···
headers: anyNamed('headers'),
body: anyNamed('body'),
),
+
).thenAnswer((_) async => http.Response(jsonEncode({}), 200));
// Test that vote is found via reflection (private method)
// This is verified indirectly through createVote behavior
···
'uri': 'at://did:plc:test/social.coves.feed.vote/abc1',
'value': {
'subject': {
+
'uri':
+
'at://did:plc:author/social.coves.post.record/other1',
'cid': 'bafy001',
},
'direction': 'up',
···
'uri': 'at://did:plc:test/social.coves.feed.vote/abc123',
'value': {
'subject': {
+
'uri':
+
'at://did:plc:author/social.coves.post.record/target',
'cid': 'bafy123',
},
'direction': 'up',
···
when(
mockSession.fetchHandler(
+
argThat(
+
allOf(contains('listRecords'), contains('cursor=cursor123')),
+
),
method: 'GET',
),
).thenAnswer((_) async => secondPageResponse);
···
headers: anyNamed('headers'),
body: anyNamed('body'),
),
+
).thenAnswer((_) async => http.Response(jsonEncode({}), 200));
// Test that pagination works by creating vote that exists on page 2
final response = await service.createVote(
···
verify(
mockSession.fetchHandler(
+
argThat(
+
allOf(contains('listRecords'), contains('cursor=cursor123')),
+
),
method: 'GET',
),
).called(1);
···
});
group('createVote', () {
test('should create vote successfully', () async {
// Create a real VoteService instance that we can test with
// We'll use a minimal test to verify the VoteResponse parsing logic
···
final exception = ApiException.fromDioError(dioError);
expect(exception, isA<NetworkException>());
+
expect(exception.message, contains('Connection failed'));
});
test('should throw ApiException on Dio timeout', () {
+43 -76
test/widgets/animated_heart_icon_test.dart
···
testWidgets('should render with default size', (tester) async {
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: false),
-
),
),
);
···
// Find the SizedBox that defines the size
final sizedBox = tester.widget<SizedBox>(
-
find.descendant(
-
of: find.byType(AnimatedHeartIcon),
-
matching: find.byType(SizedBox),
-
).first,
);
// Default size should be 18
···
testWidgets('should render with custom size', (tester) async {
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: false, size: 32),
-
),
),
);
// Find the SizedBox that defines the size
final sizedBox = tester.widget<SizedBox>(
-
find.descendant(
-
of: find.byType(AnimatedHeartIcon),
-
matching: find.byType(SizedBox),
-
).first,
);
// Custom size should be 32
···
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
-
body: AnimatedHeartIcon(
-
isLiked: false,
-
color: customColor,
-
),
),
),
);
···
expect(find.byType(AnimatedHeartIcon), findsOneWidget);
});
-
testWidgets('should start animation when isLiked changes to true',
-
(tester) async {
// Start with unliked state
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: false),
-
),
),
);
···
// Change to liked state
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: true),
-
),
),
);
···
expect(find.byType(AnimatedHeartIcon), findsOneWidget);
});
-
testWidgets('should not animate when isLiked changes to false',
-
(tester) async {
// Start with liked state
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: true),
-
),
),
);
···
// Change to unliked state
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: false),
-
),
),
);
···
// Start with unliked state
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: false),
-
),
),
);
// Change to liked state
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: true),
-
),
),
);
···
// Start with unliked state
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: false),
-
),
),
);
// Rapidly toggle states
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: true),
-
),
),
);
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: false),
-
),
),
);
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: true),
-
),
),
);
await tester.pump(const Duration(milliseconds: 50));
···
expect(find.byType(AnimatedHeartIcon), findsOneWidget);
});
-
testWidgets('should use OverflowBox to allow animation overflow',
-
(tester) async {
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: true),
-
),
),
);
// Find the OverflowBox
expect(find.byType(OverflowBox), findsOneWidget);
-
final overflowBox = tester.widget<OverflowBox>(
-
find.byType(OverflowBox),
-
);
// OverflowBox should have larger max dimensions (2.5x the icon size)
// to accommodate the 1.3x scale and particle burst
···
testWidgets('should render CustomPaint for heart icon', (tester) async {
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: false),
-
),
),
);
···
expect(find.byType(CustomPaint), findsAtLeastNWidgets(1));
});
-
testWidgets('should not animate on initial render when isLiked is true',
-
(tester) async {
// Render with isLiked=true initially
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: true),
-
),
),
);
···
testWidgets('should dispose controller properly', (tester) async {
await tester.pumpWidget(
const MaterialApp(
-
home: Scaffold(
-
body: AnimatedHeartIcon(isLiked: false),
-
),
),
);
// Remove the widget
await tester.pumpWidget(
-
const MaterialApp(
-
home: Scaffold(
-
body: SizedBox.shrink(),
-
),
-
),
);
// Should dispose without error
···
testWidgets('should render with default size', (tester) async {
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: false)),
),
);
···
// Find the SizedBox that defines the size
final sizedBox = tester.widget<SizedBox>(
+
find
+
.descendant(
+
of: find.byType(AnimatedHeartIcon),
+
matching: find.byType(SizedBox),
+
)
+
.first,
);
// Default size should be 18
···
testWidgets('should render with custom size', (tester) async {
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: false, size: 32)),
),
);
// Find the SizedBox that defines the size
final sizedBox = tester.widget<SizedBox>(
+
find
+
.descendant(
+
of: find.byType(AnimatedHeartIcon),
+
matching: find.byType(SizedBox),
+
)
+
.first,
);
// Custom size should be 32
···
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
+
body: AnimatedHeartIcon(isLiked: false, color: customColor),
),
),
);
···
expect(find.byType(AnimatedHeartIcon), findsOneWidget);
});
+
testWidgets('should start animation when isLiked changes to true', (
+
tester,
+
) async {
// Start with unliked state
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: false)),
),
);
···
// Change to liked state
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: true)),
),
);
···
expect(find.byType(AnimatedHeartIcon), findsOneWidget);
});
+
testWidgets('should not animate when isLiked changes to false', (
+
tester,
+
) async {
// Start with liked state
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: true)),
),
);
···
// Change to unliked state
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: false)),
),
);
···
// Start with unliked state
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: false)),
),
);
// Change to liked state
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: true)),
),
);
···
// Start with unliked state
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: false)),
),
);
// Rapidly toggle states
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: true)),
),
);
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: false)),
),
);
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: true)),
),
);
await tester.pump(const Duration(milliseconds: 50));
···
expect(find.byType(AnimatedHeartIcon), findsOneWidget);
});
+
testWidgets('should use OverflowBox to allow animation overflow', (
+
tester,
+
) async {
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: true)),
),
);
// Find the OverflowBox
expect(find.byType(OverflowBox), findsOneWidget);
+
final overflowBox = tester.widget<OverflowBox>(find.byType(OverflowBox));
// OverflowBox should have larger max dimensions (2.5x the icon size)
// to accommodate the 1.3x scale and particle burst
···
testWidgets('should render CustomPaint for heart icon', (tester) async {
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: false)),
),
);
···
expect(find.byType(CustomPaint), findsAtLeastNWidgets(1));
});
+
testWidgets('should not animate on initial render when isLiked is true', (
+
tester,
+
) async {
// Render with isLiked=true initially
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: true)),
),
);
···
testWidgets('should dispose controller properly', (tester) async {
await tester.pumpWidget(
const MaterialApp(
+
home: Scaffold(body: AnimatedHeartIcon(isLiked: false)),
),
);
// Remove the widget
await tester.pumpWidget(
+
const MaterialApp(home: Scaffold(body: SizedBox.shrink())),
);
// Should dispose without error
+8 -8
test/widgets/feed_screen_test.dart
···
// Fake VoteProvider for testing
class FakeVoteProvider extends VoteProvider {
FakeVoteProvider()
-
: super(
-
voteService: VoteService(
-
sessionGetter: () async => null,
-
didGetter: () => null,
-
pdsUrlGetter: () => null,
-
),
-
authProvider: FakeAuthProvider(),
-
);
final Map<String, bool> _likes = {};
···
// Fake VoteProvider for testing
class FakeVoteProvider extends VoteProvider {
FakeVoteProvider()
+
: super(
+
voteService: VoteService(
+
sessionGetter: () async => null,
+
didGetter: () => null,
+
pdsUrlGetter: () => null,
+
),
+
authProvider: FakeAuthProvider(),
+
);
final Map<String, bool> _likes = {};
+7 -8
test/widgets/sign_in_dialog_test.dart
···
body: Builder(
builder: (context) {
return ElevatedButton(
-
onPressed: () => SignInDialog.show(
-
context,
-
title: 'Custom Title',
-
message: 'Custom message here',
-
),
child: const Text('Show Dialog'),
);
},
···
await tester.pumpAndSettle();
// Find the AlertDialog widget
-
final alertDialog = tester.widget<AlertDialog>(
-
find.byType(AlertDialog),
-
);
// Verify background color is set
expect(alertDialog.backgroundColor, isNotNull);
···
body: Builder(
builder: (context) {
return ElevatedButton(
+
onPressed:
+
() => SignInDialog.show(
+
context,
+
title: 'Custom Title',
+
message: 'Custom message here',
+
),
child: const Text('Show Dialog'),
);
},
···
await tester.pumpAndSettle();
// Find the AlertDialog widget
+
final alertDialog = tester.widget<AlertDialog>(find.byType(AlertDialog));
// Verify background color is set
expect(alertDialog.backgroundColor, isNotNull);