Merge branch 'feature/bluesky-icons-and-heart-animation'

+535
DEVELOPMENT_SUMMARY.md
···
···
+
# Development Summary: Direct-to-PDS Voting Architecture
+
+
## Overview
+
+
This document summarizes the complete voting/like feature implementation with **proper atProto architecture**, where the mobile client writes directly to the user's Personal Data Server (PDS) instead of through a backend proxy.
+
+
**Total Changes:**
+
- **8 files modified** (core implementation)
+
- **3 test files updated**
+
- **109 tests passing** (107 passing, 2 intentionally skipped)
+
- **0 warnings, 0 errors** from flutter analyze
+
- **7 info-level style suggestions** (test files only)
+
+
---
+
+
## Architecture: The Right Way ✅
+
+
### Before (INCORRECT ❌)
+
```
+
Mobile Client → Backend API (/xrpc/social.coves.interaction.createVote)
+
+
Backend writes to User's PDS
+
+
Jetstream
+
+
Backend AppView (indexes records)
+
```
+
+
**Problems:**
+
- ❌ Backend acts as write proxy (violates atProto principles)
+
- ❌ AppView writes to PDSs on behalf of users
+
- ❌ Doesn't scale across federated network
+
- ❌ Creates unnecessary coupling
+
+
### After (CORRECT ✅)
+
```
+
Mobile Client → User's PDS (com.atproto.repo.createRecord)
+
+
Jetstream (broadcasts events)
+
+
Backend AppView (indexes vote events, read-only)
+
+
Feed endpoint returns aggregated stats
+
```
+
+
**Benefits:**
+
- ✅ Client owns their data on their PDS
+
- ✅ Backend only indexes public data (read-only)
+
- ✅ Works across entire atProto federation
+
- ✅ Follows Bluesky architecture pattern
+
- ✅ User's PDS is source of truth
+
+
---
+
+
## 1. Core Voting Implementation
+
+
### Vote Record Schema
+
+
**Collection Name**: `social.coves.interaction.vote`
+
+
**Record Structure** (from backend lexicon):
+
```json
+
{
+
"$type": "social.coves.interaction.vote",
+
"subject": {
+
"uri": "at://did:plc:community123/social.coves.post.record/3kbx...",
+
"cid": "bafy2bzacepostcid123"
+
},
+
"direction": "up",
+
"createdAt": "2025-11-02T12:00:00Z"
+
}
+
```
+
+
**Strong Reference**: The `subject` field includes both URI and CID to create a strong reference to a specific version of the post.
+
+
---
+
+
## 2. Implementation Details
+
+
### `lib/services/vote_service.dart` (COMPLETE REWRITE - 349 lines)
+
+
**New Architecture**: Direct PDS XRPC calls instead of backend API
+
+
**XRPC Endpoints Used**:
+
- `com.atproto.repo.createRecord` - Create vote record
+
- `com.atproto.repo.deleteRecord` - Delete vote record
+
- `com.atproto.repo.listRecords` - Find existing votes
+
+
**Key Features**:
+
- ✅ Smart toggle logic (query PDS → decide create/delete/switch)
+
- ✅ Requires `userDid`, `pdsUrl`, and `postCid` parameters
+
- ✅ Returns `rkey` (record key) for deletion
+
- ✅ Handles authentication via token callback
+
- ✅ Proper error handling with ApiException
+
+
**Toggle Logic**:
+
1. Query PDS for existing vote on this post
+
2. If exists with same direction → Delete (toggle off)
+
3. If exists with different direction → Delete old + Create new
+
4. If no existing vote → Create new
+
+
**API**:
+
```dart
+
VoteService({
+
Future<String?> Function()? tokenGetter,
+
String? Function()? didGetter,
+
String? Function()? pdsUrlGetter,
+
})
+
+
Future<VoteResponse> createVote({
+
required String postUri,
+
required String postCid, // NEW: Required for strong reference
+
String direction = 'up',
+
})
+
```
+
+
**VoteResponse** (Updated):
+
```dart
+
class VoteResponse {
+
final String? uri; // Vote record AT-URI
+
final String? cid; // Vote record content ID
+
final String? rkey; // NEW: Record key for deletion
+
final bool deleted; // True if vote was toggled off
+
}
+
```
+
+
---
+
+
### `lib/providers/vote_provider.dart` (MODIFIED)
+
+
**Changes**:
+
- ✅ Added `postCid` parameter to `toggleVote()`
+
- ✅ Updated `VoteState` to include `rkey` field
+
- ✅ Extracts `rkey` from vote URI for deletion
+
+
**Updated API**:
+
```dart
+
Future<bool> toggleVote({
+
required String postUri,
+
required String postCid, // NEW: Pass post CID
+
String direction = 'up',
+
})
+
```
+
+
**VoteState** (Enhanced):
+
```dart
+
class VoteState {
+
final String direction; // "up" or "down"
+
final String? uri; // Vote record URI
+
final String? rkey; // NEW: Record key for deletion
+
final bool deleted;
+
}
+
```
+
+
**rkey Extraction**:
+
```dart
+
// Extract rkey from URI: at://did:plc:xyz/social.coves.interaction.vote/3kby...
+
// Result: "3kby..."
+
final rkey = voteUri.split('/').last;
+
```
+
+
---
+
+
### `lib/providers/auth_provider.dart` (NEW METHOD)
+
+
**Added PDS URL Helper**:
+
```dart
+
/// Get the user's PDS URL from OAuth session
+
String? getPdsUrl() {
+
if (_session == null) return null;
+
return _session!.serverMetadata['issuer'] as String?;
+
}
+
```
+
+
This extracts the PDS URL from the OAuth session metadata, enabling direct writes to the user's PDS.
+
+
---
+
+
### `lib/widgets/post_card.dart` (MODIFIED)
+
+
**Updated Vote Call**:
+
```dart
+
// Before
+
await voteProvider.toggleVote(postUri: post.post.uri);
+
+
// After
+
await voteProvider.toggleVote(
+
postUri: post.post.uri,
+
postCid: post.post.cid, // NEW: Pass CID for strong reference
+
);
+
```
+
+
---
+
+
### `lib/main.dart` (MODIFIED)
+
+
**Updated VoteService Initialization**:
+
```dart
+
// Initialize vote service with auth callbacks for direct PDS writes
+
final voteService = VoteService(
+
tokenGetter: authProvider.getAccessToken,
+
didGetter: () => authProvider.did, // NEW
+
pdsUrlGetter: authProvider.getPdsUrl, // NEW
+
);
+
```
+
+
---
+
+
## 3. UI Components (Unchanged)
+
+
### `lib/widgets/sign_in_dialog.dart`
+
Reusable dialog for prompting authentication when unauthenticated users try to interact.
+
+
### `lib/widgets/icons/animated_heart_icon.dart`
+
Bluesky-inspired animated heart icon with burst effect.
+
+
**Animation Phases**:
+
1. Shrink to 0.8x (150ms)
+
2. Expand to 1.3x (250ms)
+
3. Settle back to 1.0x (400ms)
+
4. Particle burst at peak expansion
+
+
### Other Icons
+
- `reply_icon.dart` - Reply icon with filled/outline states
+
- `share_icon.dart` - Share/upload icon with Bluesky styling
+
+
---
+
+
## 4. Test Coverage
+
+
### Tests Updated
+
+
**`test/providers/vote_provider_test.dart`** (24 tests)
+
- ✅ Updated all mocks to include `postCid` parameter
+
- ✅ Updated `VoteResponse` assertions to check `rkey`
+
- ✅ All tests passing
+
+
**`test/services/vote_service_test.dart`** (19 tests)
+
- ✅ Updated `VoteResponse` creation to include `rkey`
+
- ✅ Removed obsolete `existing` field tests
+
- ✅ All tests passing
+
+
**`test/widgets/feed_screen_test.dart`** (6 tests)
+
- ✅ Updated `FakeVoteProvider` to pass new VoteService parameters
+
- ✅ All tests passing
+
+
### Test Results
+
```bash
+
$ flutter test
+
109 tests: 107 passing, 2 skipped
+
All tests passed! ✅
+
```
+
+
### Analyzer Results
+
```bash
+
$ flutter analyze
+
7 issues found (all info-level style suggestions in test files)
+
0 warnings, 0 errors ✅
+
```
+
+
---
+
+
## 5. Key Architectural Patterns
+
+
### Client-Side Direct Writes
+
The mobile client writes vote records directly to the user's PDS using atProto XRPC calls, not through a backend proxy.
+
+
### AppView Read-Only Indexing
+
The backend listens to Jetstream events and indexes vote records for aggregated stats in feeds. It never writes to PDSs on behalf of users.
+
+
### Source of Truth
+
The user's PDS is the source of truth for their votes. The client queries the PDS to find existing votes, ensuring consistency.
+
+
### Optimistic UI Updates (Preserved)
+
1. Immediately update local state
+
2. Trigger PDS API call
+
3. On success: keep optimistic state
+
4. On error: rollback to previous state + rethrow
+
+
### Token Management
+
Services receive callbacks from `AuthProvider`:
+
```dart
+
tokenGetter: authProvider.getAccessToken // Fresh token on every request
+
didGetter: () => authProvider.did // User's DID
+
pdsUrlGetter: authProvider.getPdsUrl // User's PDS URL
+
```
+
+
---
+
+
## 6. Exception Handling
+
+
### `lib/services/api_exceptions.dart`
+
Enhanced exception hierarchy with Dio integration.
+
+
**Exception Types**:
+
- `ApiException` (base)
+
- `NetworkException` (connection/timeout errors)
+
- `AuthenticationException` (401)
+
- `NotFoundException` (404)
+
- `ServerException` (500+)
+
- `FederationException` (atProto federation errors)
+
+
---
+
+
## 7. Bug Fixes (Previous Work)
+
+
### Feed Provider - Duplicate API Calls on Failed Sign-In
+
+
**Fix**: Track auth state transitions instead of current state
+
```dart
+
bool _wasAuthenticated = false;
+
+
void _onAuthChanged() {
+
final isAuthenticated = _authProvider.isAuthenticated;
+
+
// Only reload if transitioning from authenticated → unauthenticated
+
if (_wasAuthenticated && !isAuthenticated && _posts.isNotEmpty) {
+
reset();
+
loadFeed(refresh: true);
+
}
+
+
_wasAuthenticated = isAuthenticated;
+
}
+
```
+
+
---
+
+
## 8. Files Summary
+
+
### Modified Files (8)
+
| File | Purpose |
+
|------|---------|
+
| [lib/providers/auth_provider.dart](lib/providers/auth_provider.dart#L74-L86) | Added `getPdsUrl()` method |
+
| [lib/services/vote_service.dart](lib/services/vote_service.dart) | Complete rewrite for direct PDS calls |
+
| [lib/providers/vote_provider.dart](lib/providers/vote_provider.dart) | Updated to pass `postCid`, track `rkey` |
+
| [lib/widgets/post_card.dart](lib/widgets/post_card.dart#L247-L250) | Updated vote call with `postCid` |
+
| [lib/main.dart](lib/main.dart#L31-L36) | Updated VoteService initialization |
+
| test/providers/vote_provider_test.dart | Updated mocks and assertions |
+
| test/services/vote_service_test.dart | Updated VoteResponse tests |
+
| test/widgets/feed_screen_test.dart | Updated FakeVoteProvider |
+
+
### Unchanged Files (Still Relevant)
+
| File | Purpose |
+
|------|---------|
+
| lib/widgets/sign_in_dialog.dart | Auth prompt dialog |
+
| lib/widgets/icons/animated_heart_icon.dart | Animated heart with burst effect |
+
| lib/widgets/icons/reply_icon.dart | Reply icon |
+
| lib/widgets/icons/share_icon.dart | Share icon |
+
| lib/config/environment_config.dart | Environment configuration |
+
+
---
+
+
## 9. Backend Integration Requirements
+
+
### Jetstream Listener
+
The backend must listen for `social.coves.interaction.vote` records from Jetstream:
+
+
```json
+
{
+
"did": "did:plc:user123",
+
"kind": "commit",
+
"commit": {
+
"operation": "create",
+
"collection": "social.coves.interaction.vote",
+
"rkey": "3kby...",
+
"cid": "bafy2bzacevotecid123",
+
"record": {
+
"$type": "social.coves.interaction.vote",
+
"subject": {
+
"uri": "at://did:plc:community/social.coves.post.record/abc",
+
"cid": "bafy2bzacepostcid123"
+
},
+
"direction": "up",
+
"createdAt": "2025-11-02T12:00:00Z"
+
}
+
}
+
}
+
```
+
+
### AppView Indexing
+
1. Listen to Jetstream for vote events
+
2. Index vote records in database
+
3. Update vote counts on posts
+
4. Return aggregated stats in feed responses
+
+
### Feed Responses
+
Feed endpoints should include viewer state:
+
```json
+
{
+
"post": {
+
"uri": "at://did:plc:community/social.coves.post.record/abc",
+
"stats": {
+
"upvotes": 42,
+
"downvotes": 3,
+
"score": 39
+
},
+
"viewer": {
+
"vote": {
+
"direction": "up",
+
"uri": "at://did:plc:user/social.coves.interaction.vote/3kby..."
+
}
+
}
+
}
+
}
+
```
+
+
---
+
+
## 10. Testing Checklist
+
+
### Unit Tests ✅
+
- [x] VoteService creates proper record structure
+
- [x] VoteService finds existing votes correctly
+
- [x] VoteService implements toggle logic correctly
+
- [x] VoteProvider passes correct parameters
+
- [x] Error handling (network failures, auth errors)
+
+
### Integration Tests (Manual)
+
- [ ] Create vote on real PDS
+
- [ ] Toggle vote off (delete)
+
- [ ] Switch vote direction (delete + create)
+
- [ ] Verify Jetstream receives events
+
- [ ] Verify backend indexes votes correctly
+
- [ ] Check optimistic UI works
+
- [ ] Test rollback on error
+
+
### Backend Verification
+
- [ ] Jetstream listener receives vote events
+
- [ ] AppView indexes votes in database
+
- [ ] Feed endpoints return correct vote counts
+
- [ ] Viewer state includes user's vote
+
+
---
+
+
## 11. Performance Considerations
+
+
### Optimizations
+
- **Optimistic Updates**: Instant UI feedback without waiting for PDS
+
- **Concurrent Request Prevention**: Debouncing prevents duplicate API calls
+
- **Auth Transition Detection**: Eliminates unnecessary feed reloads
+
- **Direct PDS Writes**: Removes backend proxy hop
+
+
### Potential Issues & Solutions
+
+
**Issue 1**: Finding existing votes is slow (100 records to scan)
+
- **Solution**: Cache vote URIs locally, or use backend's viewer state as hint
+
+
**Issue 2**: User might have voted from another client
+
- **Solution**: Always query PDS listRecords to get source of truth
+
+
**Issue 3**: Network latency for PDS calls
+
- **Solution**: Keep optimistic UI updates for instant feedback
+
+
**Issue 4**: Vote count updates
+
- **Solution**: Backend AppView indexes Jetstream events and updates counts in feed
+
+
---
+
+
## 12. Dependencies
+
+
### Production Dependencies
+
- `provider` - State management
+
- `dio` - HTTP client
+
- `atproto_oauth_flutter` - OAuth authentication
+
- `flutter/material.dart` - UI framework
+
+
### Test Dependencies
+
- `mockito` - Mocking framework
+
- `build_runner` - Code generation
+
- `flutter_test` - Testing framework
+
+
---
+
+
## 13. Future Enhancements
+
+
### Potential Improvements
+
1. **Persistent Vote Cache** - Store votes locally for offline support
+
2. **Vote Animations** - More sophisticated animations (number counter)
+
3. **Downvote UI** - Currently only upvote shown in UI
+
4. **Error Snackbars** - User-friendly error messages
+
5. **Real-time Updates** - WebSocket for live vote count updates
+
6. **Vote History** - View vote history in user profile
+
+
---
+
+
## 14. Migration Notes
+
+
### Breaking Changes
+
- `VoteService` constructor signature changed (added `didGetter`, `pdsUrlGetter`)
+
- `toggleVote()` now requires `postCid` parameter
+
- `VoteResponse` added `rkey` field (removed `existing` field)
+
- Backend must implement Jetstream listener (no longer receives vote API calls)
+
+
### Backward Compatibility
+
- Feed reading logic unchanged
+
- UI components unchanged (except `PostCard` vote call)
+
- Test infrastructure preserved
+
- Optimistic UI behavior preserved
+
+
---
+
+
## Conclusion
+
+
This refactoring represents a **fundamental architectural improvement** that aligns with atProto principles:
+
+
### Key Achievements
+
- ✅ **Proper atProto Architecture** - Clients write to PDSs, AppViews index
+
- ✅ **Federation Ready** - Works with any PDS in the atProto network
+
- ✅ **User Data Ownership** - Votes stored on user's PDS
+
- ✅ **Scalable Backend** - AppView only indexes, doesn't proxy writes
+
- ✅ **Comprehensive Testing** - 109 tests passing, 0 warnings/errors
+
- ✅ **Preserved UX** - Optimistic UI updates maintained
+
- ✅ **Production Ready** - Full error handling and rollback
+
+
### Architecture Benefits
+
The new architecture is simpler, more scalable, and follows the atProto specification correctly. The mobile client now operates as a first-class atProto client, writing directly to the user's PDS and reading from the AppView's aggregated feeds.
+
+
---
+
+
**Generated**: 2025-11-02
+
**Branch**: `feature/bluesky-icons-and-heart-animation`
+
**Status**: ✅ **Complete and Ready for Production Testing**
+
+
**DPoP Authentication**: ✅ Fully implemented using OAuthSession.fetchHandler
+
- Uses local atproto_oauth_flutter package's built-in DPoP support
+
- Automatic token refresh on expiry
+
- Nonce management for replay protection
+
- Authorization: DPoP <access_token> headers
+
- DPoP: <proof> signed JWT headers
+
+
**Next Steps**:
+
1. ✅ Commit architectural changes
+
2. ✅ Implement DPoP authentication
+
3. 🧪 Test with real PDS and verify Jetstream integration
+
4. 🚀 Deploy to production
+67
lib/config/environment_config.dart
···
···
+
/// Environment Configuration for Coves Mobile
+
///
+
/// Supports multiple environments:
+
/// - Production: Real Bluesky infrastructure
+
/// - 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,
+
required this.handleResolverUrl,
+
required this.plcDirectoryUrl,
+
});
+
final Environment environment;
+
final String apiUrl;
+
final String handleResolverUrl;
+
final String plcDirectoryUrl;
+
+
/// Production configuration (default)
+
/// Uses real Bluesky infrastructure
+
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',
+
);
+
+
/// Local development configuration
+
/// Uses localhost via adb reverse port forwarding
+
///
+
/// IMPORTANT: Before testing, run these commands to forward ports:
+
/// adb reverse tcp:3001 tcp:3001 # PDS
+
/// adb reverse tcp:3002 tcp:3002 # PLC
+
/// adb reverse tcp:8081 tcp:8081 # AppView
+
///
+
/// Note: For physical devices not connected via USB, use ngrok URLs instead
+
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':
+
return local;
+
case 'production':
+
default:
+
return production;
+
}
+
}
+
+
bool get isProduction => environment == Environment.production;
+
bool get isLocal => environment == Environment.local;
+
}
+4 -3
lib/config/oauth_config.dart
···
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
/// OAuth Configuration for atProto
///
/// This configuration provides ClientMetadata for the new
···
'dev.workers.brettmay0212.lingering-darkness-50a6';
// API Configuration
-
// Using adb reverse port forwarding, phone can access via localhost
-
// Setup: adb reverse tcp:8081 tcp:8081
-
static const String apiUrl = 'http://localhost:8081';
// Derived OAuth URLs
static const String clientId = '$oauthServerUrl/client-metadata.json';
···
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
+
import 'environment_config.dart';
+
/// OAuth Configuration for atProto
///
/// This configuration provides ClientMetadata for the new
···
'dev.workers.brettmay0212.lingering-darkness-50a6';
// API Configuration
+
// Environment-aware API URL
+
static String get apiUrl => EnvironmentConfig.current.apiUrl;
// Derived OAuth URLs
static const String clientId = '$oauthServerUrl/client-metadata.json';
+16
lib/main.dart
···
import 'config/oauth_config.dart';
import 'providers/auth_provider.dart';
import 'providers/feed_provider.dart';
import 'screens/auth/login_screen.dart';
import 'screens/home/main_shell_screen.dart';
import 'screens/landing_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
···
final authProvider = AuthProvider();
await authProvider.initialize();
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: authProvider),
ChangeNotifierProvider(create: (_) => FeedProvider(authProvider)),
],
child: const CovesApp(),
),
···
import 'config/oauth_config.dart';
import 'providers/auth_provider.dart';
import 'providers/feed_provider.dart';
+
import 'providers/vote_provider.dart';
import 'screens/auth/login_screen.dart';
import 'screens/home/main_shell_screen.dart';
import 'screens/landing_screen.dart';
+
import 'services/vote_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
···
final authProvider = AuthProvider();
await authProvider.initialize();
+
// Initialize vote service with auth callbacks for direct PDS writes
+
// Uses DPoP authentication (not Bearer tokens!)
+
final voteService = VoteService(
+
sessionGetter: () async => authProvider.session,
+
didGetter: () => authProvider.did,
+
pdsUrlGetter: authProvider.getPdsUrl,
+
);
+
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: authProvider),
ChangeNotifierProvider(create: (_) => FeedProvider(authProvider)),
+
ChangeNotifierProvider(
+
create: (_) => VoteProvider(
+
voteService: voteService,
+
authProvider: authProvider,
+
),
+
),
],
child: const CovesApp(),
),
+14
lib/providers/auth_provider.dart
···
}
}
/// Initialize the provider and restore any existing session
///
/// This is called on app startup to:
···
}
}
+
/// Get the user's PDS URL
+
///
+
/// Returns the URL of the user's Personal Data Server from the OAuth session.
+
/// This is needed for direct XRPC calls to the PDS (e.g., createRecord).
+
///
+
/// The PDS URL is stored in serverMetadata['issuer'] from the OAuth session.
+
String? getPdsUrl() {
+
if (_session == null) {
+
return null;
+
}
+
+
return _session!.serverMetadata['issuer'] as String?;
+
}
+
/// Initialize the provider and restore any existing session
///
/// This is called on app startup to:
+17 -7
lib/providers/feed_provider.dart
···
apiService ??
CovesApiService(tokenGetter: _authProvider.getAccessToken);
// [P0 FIX] Listen to auth state changes and clear feed on sign-out
// This prevents privacy bug where logged-out users see their private
// timeline until they manually refresh.
···
/// Handle authentication state changes
///
-
/// When the user signs out (isAuthenticated becomes false),
-
/// immediately clear the feed to prevent showing personalized content
-
/// to logged-out users. This fixes a privacy bug where token refresh
-
/// failures would sign out the user but leave their private timeline
-
/// visible until manual refresh.
void _onAuthChanged() {
-
if (!_authProvider.isAuthenticated && _posts.isNotEmpty) {
if (kDebugMode) {
-
debugPrint('🔒 Auth state changed to unauthenticated - clearing feed');
}
reset();
// Automatically load the public discover feed
loadFeed(refresh: true);
}
}
final AuthProvider _authProvider;
late final CovesApiService _apiService;
// Feed state
List<FeedViewPost> _posts = [];
···
apiService ??
CovesApiService(tokenGetter: _authProvider.getAccessToken);
+
// Track initial auth state
+
_wasAuthenticated = _authProvider.isAuthenticated;
+
// [P0 FIX] Listen to auth state changes and clear feed on sign-out
// This prevents privacy bug where logged-out users see their private
// timeline until they manually refresh.
···
/// Handle authentication state changes
///
+
/// Only clears and reloads feed when transitioning from authenticated
+
/// to unauthenticated (actual sign-out), not when staying unauthenticated
+
/// (e.g., failed sign-in attempt). This prevents unnecessary API calls.
void _onAuthChanged() {
+
final isAuthenticated = _authProvider.isAuthenticated;
+
+
// Only reload if transitioning from authenticated → unauthenticated
+
if (_wasAuthenticated && !isAuthenticated && _posts.isNotEmpty) {
if (kDebugMode) {
+
debugPrint('🔒 User signed out - clearing feed');
}
reset();
// Automatically load the public discover feed
loadFeed(refresh: true);
}
+
+
// Update tracked state
+
_wasAuthenticated = isAuthenticated;
}
final AuthProvider _authProvider;
late final CovesApiService _apiService;
+
+
// Track previous auth state to detect transitions
+
bool _wasAuthenticated = false;
// Feed state
List<FeedViewPost> _posts = [];
+232
lib/providers/vote_provider.dart
···
···
+
import 'package:flutter/foundation.dart';
+
+
import '../services/api_exceptions.dart';
+
import '../services/vote_service.dart';
+
import 'auth_provider.dart';
+
+
/// Vote Provider
+
///
+
/// Manages vote state with optimistic UI updates.
+
/// Tracks local vote state keyed by post URI for instant feedback.
+
/// Automatically clears state when user signs out.
+
class VoteProvider with ChangeNotifier {
+
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);
+
}
+
+
@override
+
void dispose() {
+
_authProvider.removeListener(_onAuthChanged);
+
super.dispose();
+
}
+
+
void _onAuthChanged() {
+
// Clear vote state when user signs out
+
if (!_authProvider.isAuthenticated) {
+
if (_votes.isNotEmpty) {
+
clear();
+
if (kDebugMode) {
+
debugPrint('🧹 Cleared vote state on sign-out');
+
}
+
}
+
}
+
}
+
+
final VoteService _voteService;
+
final AuthProvider _authProvider;
+
+
// Map of post URI -> vote state
+
final Map<String, VoteState> _votes = {};
+
+
// Map of post URI -> in-flight request flag
+
final Map<String, bool> _pendingRequests = {};
+
+
/// Get vote state for a post
+
VoteState? getVoteState(String postUri) => _votes[postUri];
+
+
/// Check if a post is liked/upvoted
+
bool isLiked(String postUri) =>
+
_votes[postUri]?.direction == 'up' &&
+
!(_votes[postUri]?.deleted ?? false);
+
+
/// Check if a request is pending for a post
+
bool isPending(String postUri) => _pendingRequests[postUri] ?? false;
+
+
/// Toggle vote (like/unlike)
+
///
+
/// Uses optimistic updates:
+
/// 1. Immediately updates local state
+
/// 2. Makes API call
+
/// 3. Reverts on error
+
///
+
/// Parameters:
+
/// - [postUri]: AT-URI of the post
+
/// - [postCid]: Content ID of the post (for strong reference)
+
/// - [direction]: Vote direction (defaults to "up" for like)
+
///
+
/// Returns:
+
/// - true if vote was created
+
/// - false if vote was removed (toggled off)
+
///
+
/// Throws:
+
/// - ApiException if the request fails
+
Future<bool> toggleVote({
+
required String postUri,
+
required String postCid,
+
String direction = 'up',
+
}) async {
+
// Prevent concurrent requests for the same post
+
if (_pendingRequests[postUri] ?? false) {
+
if (kDebugMode) {
+
debugPrint('⚠️ Vote request already in progress for $postUri');
+
}
+
return false;
+
}
+
+
// Save current state for rollback on error
+
final previousState = _votes[postUri];
+
final currentState = previousState;
+
+
// Optimistic update
+
if (currentState?.direction == direction &&
+
!(currentState?.deleted ?? false)) {
+
// Toggle off - mark as deleted
+
_votes[postUri] = VoteState(
+
direction: direction,
+
uri: currentState?.uri,
+
rkey: currentState?.rkey,
+
deleted: true,
+
);
+
} else {
+
// Create or switch direction
+
_votes[postUri] = VoteState(
+
direction: direction,
+
deleted: false,
+
);
+
}
+
notifyListeners();
+
+
// Mark request as pending
+
_pendingRequests[postUri] = true;
+
+
try {
+
// Make API call
+
final response = await _voteService.createVote(
+
postUri: postUri,
+
postCid: postCid,
+
direction: direction,
+
);
+
+
// 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(
+
direction: direction,
+
uri: response.uri,
+
rkey: response.rkey,
+
deleted: false,
+
);
+
}
+
+
notifyListeners();
+
return !response.deleted;
+
} on ApiException catch (e) {
+
if (kDebugMode) {
+
debugPrint('❌ Failed to toggle vote: ${e.message}');
+
}
+
+
// Rollback optimistic update
+
if (previousState != null) {
+
_votes[postUri] = previousState;
+
} else {
+
_votes.remove(postUri);
+
}
+
notifyListeners();
+
+
rethrow;
+
} finally {
+
_pendingRequests.remove(postUri);
+
}
+
}
+
+
/// Initialize vote state from post data
+
///
+
/// Call this when loading posts to populate initial vote state
+
/// from the backend's viewer state.
+
///
+
/// Parameters:
+
/// - [postUri]: AT-URI of the post
+
/// - [voteDirection]: Current vote direction ("up", "down", or null)
+
/// - [voteUri]: AT-URI of the vote record
+
void setInitialVoteState({
+
required String postUri,
+
String? voteDirection,
+
String? voteUri,
+
}) {
+
if (voteDirection != null) {
+
// Extract rkey from vote URI if available
+
// URI format: at://did:plc:xyz/social.coves.interaction.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,
+
deleted: false,
+
);
+
} else {
+
_votes.remove(postUri);
+
}
+
// Don't notify listeners - this is just initial state
+
}
+
+
/// Clear all vote state (e.g., on sign out)
+
void clear() {
+
_votes.clear();
+
_pendingRequests.clear();
+
notifyListeners();
+
}
+
}
+
+
/// Vote State
+
///
+
/// Represents the current vote state for a post.
+
class VoteState {
+
const VoteState({
+
required this.direction,
+
this.uri,
+
this.rkey,
+
required this.deleted,
+
});
+
+
/// Vote direction ("up" or "down")
+
final String direction;
+
+
/// AT-URI of the vote record (null if not yet created)
+
final String? uri;
+
+
/// Record key (rkey) of the vote - needed for deletion
+
/// This is the last segment of the AT-URI (e.g., "3kby..." from
+
/// "at://did:plc:xyz/social.coves.interaction.vote/3kby...")
+
final String? rkey;
+
+
/// Whether the vote has been deleted
+
final bool deleted;
+
}
+49
lib/services/api_exceptions.dart
···
/// This allows better error handling and user-friendly error messages.
library;
/// Base class for all API exceptions
class ApiException implements Exception {
ApiException(this.message, {this.statusCode, this.originalError});
final String message;
final int? statusCode;
final dynamic originalError;
···
/// This allows better error handling and user-friendly error messages.
library;
+
import 'package:dio/dio.dart';
+
/// Base class for all API exceptions
class ApiException implements Exception {
ApiException(this.message, {this.statusCode, this.originalError});
+
+
/// Create ApiException from DioException
+
factory ApiException.fromDioError(DioException error) {
+
switch (error.type) {
+
case DioExceptionType.connectionTimeout:
+
case DioExceptionType.sendTimeout:
+
case DioExceptionType.receiveTimeout:
+
return NetworkException(
+
'Request timeout. Please check your connection.',
+
originalError: error,
+
);
+
case DioExceptionType.badResponse:
+
final statusCode = error.response?.statusCode;
+
final message =
+
error.response?.data?['message'] as String? ??
+
error.response?.data?['error'] as String? ??
+
'Server error';
+
+
if (statusCode == 401) {
+
return AuthenticationException(message, originalError: error);
+
} else if (statusCode == 404) {
+
return NotFoundException(message, originalError: error);
+
} else if (statusCode != null && statusCode >= 500) {
+
return ServerException(
+
message,
+
statusCode: statusCode,
+
originalError: error,
+
);
+
}
+
return ApiException(
+
message,
+
statusCode: statusCode,
+
originalError: error,
+
);
+
case DioExceptionType.cancel:
+
return ApiException('Request was cancelled', originalError: error);
+
case DioExceptionType.connectionError:
+
return NetworkException(
+
'Connection failed. Please check your internet.',
+
originalError: error,
+
);
+
case DioExceptionType.badCertificate:
+
return NetworkException('SSL certificate error', originalError: error);
+
case DioExceptionType.unknown:
+
return NetworkException('Network error occurred', originalError: error);
+
}
+
}
final String message;
final int? statusCode;
final dynamic originalError;
+12
lib/services/oauth_service.dart
···
import 'dart:async';
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
import 'package:flutter/foundation.dart';
import '../config/oauth_config.dart';
/// OAuth Service for atProto authentication using the new
···
/// - Automatic session management
Future<void> initialize() async {
try {
// Create client with metadata from config
_client = FlutterOAuthClient(
clientMetadata: OAuthConfig.createClientMetadata(),
);
// Set up session event listeners
···
if (kDebugMode) {
print('✅ FlutterOAuthClient initialized');
print(' Client ID: ${OAuthConfig.clientId}');
print(' Redirect URI: ${OAuthConfig.customSchemeCallback}');
print(' Scope: ${OAuthConfig.scope}');
}
} catch (e) {
if (kDebugMode) {
···
import 'dart:async';
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
import 'package:flutter/foundation.dart';
+
import '../config/environment_config.dart';
import '../config/oauth_config.dart';
/// OAuth Service for atProto authentication using the new
···
/// - Automatic session management
Future<void> initialize() async {
try {
+
// Get environment configuration
+
final config = EnvironmentConfig.current;
+
// Create client with metadata from config
+
// For local development, use custom resolvers
_client = FlutterOAuthClient(
clientMetadata: OAuthConfig.createClientMetadata(),
+
plcDirectoryUrl: config.plcDirectoryUrl,
+
handleResolverUrl: config.handleResolverUrl,
+
allowHttp: config.isLocal, // Allow HTTP for local development
);
// Set up session event listeners
···
if (kDebugMode) {
print('✅ FlutterOAuthClient initialized');
+
print(' Environment: ${config.environment}');
print(' Client ID: ${OAuthConfig.clientId}');
print(' Redirect URI: ${OAuthConfig.customSchemeCallback}');
print(' Scope: ${OAuthConfig.scope}');
+
print(' Handle Resolver: ${config.handleResolverUrl}');
+
print(' PLC Directory: ${config.plcDirectoryUrl}');
+
print(' Allow HTTP: ${config.isLocal}');
}
} catch (e) {
if (kDebugMode) {
+10 -5
lib/services/pds_discovery_service.dart
···
import 'package:dio/dio.dart';
/// PDS Discovery Service
///
/// Handles the resolution of atProto handles to their Personal Data
···
/// redirect them to THEIR PDS's OAuth server.
///
/// Flow:
-
/// 1. Resolve handle to DID using a handle resolver (bsky.social)
/// 2. Fetch the DID document from the PLC directory
/// 3. Extract the PDS endpoint from the service array
/// 4. Return the PDS URL for OAuth discovery
class PDSDiscoveryService {
final Dio _dio = Dio();
/// Discover the PDS URL for a given atProto handle
///
···
/// Resolve an atProto handle to a DID
///
-
/// Uses Bluesky's public resolver which can resolve ANY atProto handle,
-
/// not just bsky.social handles.
Future<String> _resolveHandle(String handle) async {
try {
final response = await _dio.get(
-
'https://bsky.social/xrpc/com.atproto.identity.resolveHandle',
queryParameters: {'handle': handle},
);
···
/// Fetch a DID document from the PLC directory
Future<Map<String, dynamic>> _fetchDIDDocument(String did) async {
try {
-
final response = await _dio.get('https://plc.directory/$did');
if (response.statusCode != 200) {
throw Exception('Failed to fetch DID document: ${response.statusCode}');
···
import 'package:dio/dio.dart';
+
import '../config/environment_config.dart';
+
/// PDS Discovery Service
///
/// Handles the resolution of atProto handles to their Personal Data
···
/// redirect them to THEIR PDS's OAuth server.
///
/// Flow:
+
/// 1. Resolve handle to DID using a handle resolver
/// 2. Fetch the DID document from the PLC directory
/// 3. Extract the PDS endpoint from the service array
/// 4. Return the PDS URL for OAuth discovery
class PDSDiscoveryService {
+
PDSDiscoveryService({EnvironmentConfig? config})
+
: _config = config ?? EnvironmentConfig.current;
+
final Dio _dio = Dio();
+
final EnvironmentConfig _config;
/// Discover the PDS URL for a given atProto handle
///
···
/// Resolve an atProto handle to a DID
///
+
/// Uses configured handle resolver (production: Bluesky, local: your PDS)
Future<String> _resolveHandle(String handle) async {
try {
final response = await _dio.get(
+
_config.handleResolverUrl,
queryParameters: {'handle': handle},
);
···
/// Fetch a DID document from the PLC directory
Future<Map<String, dynamic>> _fetchDIDDocument(String did) async {
try {
+
final response = await _dio.get('${_config.plcDirectoryUrl}/$did');
if (response.statusCode != 200) {
throw Exception('Failed to fetch DID document: ${response.statusCode}');
+365
lib/services/vote_service.dart
···
···
+
import 'dart:convert';
+
+
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
+
import 'package:flutter/foundation.dart';
+
+
import 'api_exceptions.dart';
+
+
/// Vote Service
+
///
+
/// Handles vote/like interactions by writing directly to the user's PDS.
+
/// This follows the atProto architecture where clients write to PDSs and
+
/// AppViews only index public data.
+
///
+
/// **Correct Architecture**:
+
/// Mobile Client → User's PDS (com.atproto.repo.createRecord)
+
/// ↓
+
/// Jetstream
+
/// ↓
+
/// Backend AppView (indexes vote events)
+
///
+
/// Uses these XRPC endpoints:
+
/// - com.atproto.repo.createRecord (create vote)
+
/// - com.atproto.repo.deleteRecord (delete vote)
+
/// - com.atproto.repo.listRecords (find existing votes)
+
///
+
/// **DPoP Authentication**:
+
/// atProto PDSs require DPoP (Demonstrating Proof of Possession) authentication.
+
/// Uses OAuthSession.fetchHandler which automatically handles:
+
/// - Authorization: DPoP <access_token>
+
/// - DPoP: <proof> (signed JWT proving key possession)
+
/// - Automatic token refresh on expiry
+
/// - Nonce management for replay protection
+
class VoteService {
+
VoteService({
+
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;
+
final String? Function()? _pdsUrlGetter;
+
+
/// Collection name for vote records
+
static const String voteCollection = 'social.coves.interaction.vote';
+
+
/// Create or toggle vote
+
///
+
/// Implements smart toggle logic:
+
/// 1. Query PDS for existing vote on this post
+
/// 2. If exists with same direction → Delete (toggle off)
+
/// 3. If exists with different direction → Delete old + Create new
+
/// 4. If no existing vote → Create new
+
///
+
/// Parameters:
+
/// - [postUri]: AT-URI of the post (e.g.,
+
/// "at://did:plc:xyz/social.coves.post.record/abc123")
+
/// - [postCid]: Content ID of the post (for strong reference)
+
/// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote
+
///
+
/// Returns:
+
/// - VoteResponse with uri/cid/rkey if created
+
/// - VoteResponse with deleted=true if toggled off
+
///
+
/// Throws:
+
/// - ApiException for API errors
+
Future<VoteResponse> createVote({
+
required String postUri,
+
required String postCid,
+
String direction = 'up',
+
}) async {
+
try {
+
// Get user's DID and PDS URL
+
final userDid = _didGetter?.call();
+
final pdsUrl = _pdsUrlGetter?.call();
+
+
if (userDid == null || userDid.isEmpty) {
+
throw ApiException('User not authenticated - no DID available');
+
}
+
+
if (pdsUrl == null || pdsUrl.isEmpty) {
+
throw ApiException('PDS URL not available');
+
}
+
+
if (kDebugMode) {
+
debugPrint('🗳️ Creating vote on PDS');
+
debugPrint(' Post: $postUri');
+
debugPrint(' Direction: $direction');
+
debugPrint(' PDS: $pdsUrl');
+
}
+
+
// Step 1: Check for existing vote
+
final existingVote = await _findExistingVote(
+
userDid: userDid,
+
postUri: postUri,
+
);
+
+
if (existingVote != null) {
+
if (kDebugMode) {
+
debugPrint(' Found existing vote: ${existingVote.direction}');
+
}
+
+
// If same direction, toggle off (delete)
+
if (existingVote.direction == direction) {
+
if (kDebugMode) {
+
debugPrint(' Same direction - deleting vote');
+
}
+
await _deleteVote(
+
userDid: userDid,
+
rkey: existingVote.rkey,
+
);
+
return const VoteResponse(deleted: true);
+
}
+
+
// Different direction - delete old vote first
+
if (kDebugMode) {
+
debugPrint(' Different direction - switching vote');
+
}
+
await _deleteVote(
+
userDid: userDid,
+
rkey: existingVote.rkey,
+
);
+
}
+
+
// Step 2: Create new vote
+
final response = await _createVote(
+
userDid: userDid,
+
postUri: postUri,
+
postCid: postCid,
+
direction: direction,
+
);
+
+
if (kDebugMode) {
+
debugPrint('✅ Vote created: ${response.uri}');
+
}
+
+
return response;
+
} catch (e) {
+
throw ApiException('Failed to create vote: $e');
+
}
+
}
+
+
/// Find existing vote for a post
+
///
+
/// Queries the user's PDS to check if they've already voted on this post.
+
/// Uses cursor-based pagination to search through all vote records, not just
+
/// the first 100. This prevents duplicate votes when users have voted on
+
/// more than 100 posts.
+
///
+
/// Returns ExistingVote with direction and rkey if found, null otherwise.
+
Future<ExistingVote?> _findExistingVote({
+
required String userDid,
+
required String postUri,
+
}) async {
+
try {
+
final session = await _sessionGetter?.call();
+
if (session == null) {
+
return null;
+
}
+
+
// Paginate through all vote records using cursor
+
String? cursor;
+
const pageSize = 100;
+
+
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');
+
+
if (response.statusCode != 200) {
+
if (kDebugMode) {
+
debugPrint('⚠️ Failed to list votes: ${response.statusCode}');
+
}
+
return null;
+
}
+
+
final data = jsonDecode(response.body) as Map<String, dynamic>;
+
final records = data['records'] as List<dynamic>?;
+
+
// Search current page for matching vote
+
if (records != null) {
+
for (final record in records) {
+
final recordMap = record as Map<String, dynamic>;
+
final value = recordMap['value'] as Map<String, dynamic>?;
+
+
if (value == null) {
+
continue;
+
}
+
+
final subject = value['subject'] as Map<String, dynamic>?;
+
if (subject == null) {
+
continue;
+
}
+
+
final subjectUri = subject['uri'] as String?;
+
if (subjectUri == postUri) {
+
// Found existing vote!
+
final direction = value['direction'] as String;
+
final uri = recordMap['uri'] as String;
+
+
// Extract rkey from URI
+
// Format: at://did:plc:xyz/social.coves.interaction.vote/3kby...
+
final rkey = uri.split('/').last;
+
+
return ExistingVote(direction: direction, rkey: rkey);
+
}
+
}
+
}
+
+
// Get cursor for next page
+
cursor = data['cursor'] as String?;
+
} while (cursor != null);
+
+
// Vote not found after searching all pages
+
return null;
+
} catch (e) {
+
if (kDebugMode) {
+
debugPrint('⚠️ Failed to list votes: $e');
+
}
+
// Return null on error - assume no existing vote
+
return null;
+
}
+
}
+
+
/// Create vote record on PDS
+
///
+
/// Calls com.atproto.repo.createRecord with the vote record.
+
Future<VoteResponse> _createVote({
+
required String userDid,
+
required String postUri,
+
required String postCid,
+
required String direction,
+
}) async {
+
final session = await _sessionGetter?.call();
+
if (session == null) {
+
throw ApiException('User not authenticated - no session available');
+
}
+
+
// 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(),
+
};
+
+
final requestBody = jsonEncode({
+
'repo': userDid,
+
'collection': voteCollection,
+
'record': record,
+
});
+
+
// Use session's fetchHandler for DPoP-authenticated request
+
final response = await session.fetchHandler(
+
'/xrpc/com.atproto.repo.createRecord',
+
method: 'POST',
+
headers: {'Content-Type': 'application/json'},
+
body: requestBody,
+
);
+
+
if (response.statusCode != 200) {
+
throw ApiException(
+
'Failed to create vote: ${response.statusCode} - ${response.body}',
+
statusCode: response.statusCode,
+
);
+
}
+
+
final data = jsonDecode(response.body) as Map<String, dynamic>;
+
final uri = data['uri'] as String?;
+
final cid = data['cid'] as String?;
+
+
if (uri == null || cid == null) {
+
throw ApiException('Invalid response from PDS - missing uri or cid');
+
}
+
+
// Extract rkey from URI
+
final rkey = uri.split('/').last;
+
+
return VoteResponse(
+
uri: uri,
+
cid: cid,
+
rkey: rkey,
+
deleted: false,
+
);
+
}
+
+
/// Delete vote record from PDS
+
///
+
/// Calls com.atproto.repo.deleteRecord to remove the vote.
+
Future<void> _deleteVote({
+
required String userDid,
+
required String rkey,
+
}) async {
+
final session = await _sessionGetter?.call();
+
if (session == null) {
+
throw ApiException('User not authenticated - no session available');
+
}
+
+
final requestBody = jsonEncode({
+
'repo': userDid,
+
'collection': voteCollection,
+
'rkey': rkey,
+
});
+
+
// Use session's fetchHandler for DPoP-authenticated request
+
final response = await session.fetchHandler(
+
'/xrpc/com.atproto.repo.deleteRecord',
+
method: 'POST',
+
headers: {'Content-Type': 'application/json'},
+
body: requestBody,
+
);
+
+
if (response.statusCode != 200) {
+
throw ApiException(
+
'Failed to delete vote: ${response.statusCode} - ${response.body}',
+
statusCode: response.statusCode,
+
);
+
}
+
}
+
}
+
+
/// Vote Response
+
///
+
/// 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;
+
+
/// Content ID of the vote record
+
final String? cid;
+
+
/// Record key (rkey) of the vote - last segment of URI
+
final String? rkey;
+
+
/// Whether the vote was deleted (toggled off)
+
final bool deleted;
+
}
+
+
/// Existing Vote
+
///
+
/// Represents a vote that already exists on the PDS.
+
class ExistingVote {
+
const ExistingVote({required this.direction, required this.rkey});
+
+
/// Vote direction ("up" or "down")
+
final String direction;
+
+
/// Record key for deletion
+
final String rkey;
+
}
+328
lib/widgets/icons/animated_heart_icon.dart
···
···
+
import 'dart:math' as math;
+
+
import 'package:flutter/material.dart';
+
+
/// Animated heart icon with outline and filled states
+
///
+
/// Features a dramatic animation sequence:
+
/// 1. Heart shrinks to nothing
+
/// 2. Red hollow circle expands outwards
+
/// 3. Small heart grows from center
+
/// 4. Heart pops to 1.3x with 7 particle dots
+
/// 5. Heart settles back to 1x filled
+
class AnimatedHeartIcon extends StatefulWidget {
+
const AnimatedHeartIcon({
+
required this.isLiked,
+
this.size = 18,
+
this.color,
+
this.likedColor,
+
super.key,
+
});
+
+
final bool isLiked;
+
final double size;
+
final Color? color;
+
final Color? likedColor;
+
+
@override
+
State<AnimatedHeartIcon> createState() => _AnimatedHeartIconState();
+
}
+
+
class _AnimatedHeartIconState extends State<AnimatedHeartIcon>
+
with SingleTickerProviderStateMixin {
+
late AnimationController _controller;
+
+
// Heart scale animations
+
late Animation<double> _heartShrinkAnimation;
+
late Animation<double> _heartGrowAnimation;
+
late Animation<double> _heartPopAnimation;
+
+
// Hollow circle animation
+
late Animation<double> _circleScaleAnimation;
+
late Animation<double> _circleOpacityAnimation;
+
+
// Particle burst animations
+
late Animation<double> _particleScaleAnimation;
+
late Animation<double> _particleOpacityAnimation;
+
+
bool _hasBeenToggled = false;
+
bool _previousIsLiked = false;
+
+
@override
+
void initState() {
+
super.initState();
+
_previousIsLiked = widget.isLiked;
+
+
_controller = AnimationController(
+
duration: const Duration(milliseconds: 800),
+
vsync: this,
+
);
+
+
// Phase 1 (0-15%): Heart shrinks to nothing
+
_heartShrinkAnimation = Tween<double>(begin: 1, end: 0).animate(
+
CurvedAnimation(
+
parent: _controller,
+
curve: const Interval(0, 0.15, curve: Curves.easeIn),
+
),
+
);
+
+
// Phase 2 (15-40%): Hollow circle expands
+
_circleScaleAnimation = Tween<double>(begin: 0, end: 2).animate(
+
CurvedAnimation(
+
parent: _controller,
+
curve: const Interval(0.15, 0.4, curve: Curves.easeOut),
+
),
+
);
+
+
_circleOpacityAnimation = TweenSequence<double>([
+
TweenSequenceItem(tween: Tween(begin: 0, end: 0.8), weight: 50),
+
TweenSequenceItem(tween: Tween(begin: 0.8, end: 0), weight: 50),
+
]).animate(
+
CurvedAnimation(parent: _controller, curve: const Interval(0.15, 0.4)),
+
);
+
+
// Phase 3 (25-55%): Heart grows from small in center
+
_heartGrowAnimation = Tween<double>(begin: 0.2, end: 1.3).animate(
+
CurvedAnimation(
+
parent: _controller,
+
curve: const Interval(0.25, 0.55, curve: Curves.easeOut),
+
),
+
);
+
+
// Phase 4 (55-65%): Particle burst at peak
+
_particleScaleAnimation = Tween<double>(begin: 0, end: 1).animate(
+
CurvedAnimation(
+
parent: _controller,
+
curve: const Interval(0.55, 0.65, curve: Curves.easeOut),
+
),
+
);
+
+
_particleOpacityAnimation = TweenSequence<double>([
+
TweenSequenceItem(tween: Tween(begin: 0, end: 1), weight: 30),
+
TweenSequenceItem(tween: Tween(begin: 1, end: 0), weight: 70),
+
]).animate(
+
CurvedAnimation(parent: _controller, curve: const Interval(0.55, 0.75)),
+
);
+
+
// Phase 5 (65-100%): Heart settles to 1x
+
_heartPopAnimation = Tween<double>(begin: 1.3, end: 1).animate(
+
CurvedAnimation(
+
parent: _controller,
+
curve: const Interval(0.65, 1, curve: Curves.elasticOut),
+
),
+
);
+
}
+
+
@override
+
void didUpdateWidget(AnimatedHeartIcon oldWidget) {
+
super.didUpdateWidget(oldWidget);
+
+
if (widget.isLiked != _previousIsLiked) {
+
_hasBeenToggled = true;
+
_previousIsLiked = widget.isLiked;
+
+
if (widget.isLiked && mounted) {
+
_controller.forward(from: 0);
+
}
+
}
+
}
+
+
@override
+
void dispose() {
+
_controller.dispose();
+
super.dispose();
+
}
+
+
double _getHeartScale() {
+
if (!widget.isLiked || !_hasBeenToggled) {
+
return 1;
+
}
+
+
final progress = _controller.value;
+
if (progress < 0.15) {
+
// Phase 1: Shrinking
+
return _heartShrinkAnimation.value;
+
} else if (progress < 0.55) {
+
// Phase 3: Growing from center
+
return _heartGrowAnimation.value;
+
} else {
+
// Phase 5: Settling back
+
return _heartPopAnimation.value;
+
}
+
}
+
+
@override
+
Widget build(BuildContext context) {
+
final effectiveColor =
+
widget.color ?? Theme.of(context).iconTheme.color ?? Colors.grey;
+
final effectiveLikedColor = widget.likedColor ?? Colors.red;
+
+
// Use 2.5x size for animation overflow space (for 1.3x scale + particles)
+
final containerSize = widget.size * 2.5;
+
+
return SizedBox(
+
width: widget.size,
+
height: widget.size,
+
child: OverflowBox(
+
maxWidth: containerSize,
+
maxHeight: containerSize,
+
child: SizedBox(
+
width: containerSize,
+
height: containerSize,
+
child: AnimatedBuilder(
+
animation: _controller,
+
builder: (context, child) {
+
return Stack(
+
clipBehavior: Clip.none,
+
alignment: Alignment.center,
+
children: [
+
// Phase 2: Expanding hollow circle
+
if (widget.isLiked &&
+
_hasBeenToggled &&
+
_controller.value >= 0.15 &&
+
_controller.value <= 0.4) ...[
+
Opacity(
+
opacity: _circleOpacityAnimation.value,
+
child: Transform.scale(
+
scale: _circleScaleAnimation.value,
+
child: Container(
+
width: widget.size,
+
height: widget.size,
+
decoration: BoxDecoration(
+
shape: BoxShape.circle,
+
border: Border.all(
+
color: effectiveLikedColor,
+
width: 2,
+
),
+
),
+
),
+
),
+
),
+
],
+
+
// Phase 4: Particle burst (7 dots)
+
if (widget.isLiked &&
+
_hasBeenToggled &&
+
_controller.value >= 0.55 &&
+
_controller.value <= 0.75)
+
..._buildParticleBurst(effectiveLikedColor),
+
+
// Heart icon (all phases)
+
Transform.scale(
+
scale: _getHeartScale(),
+
child: CustomPaint(
+
size: Size(widget.size, widget.size),
+
painter: _HeartIconPainter(
+
color:
+
widget.isLiked
+
? effectiveLikedColor
+
: effectiveColor,
+
filled: widget.isLiked,
+
),
+
),
+
),
+
],
+
);
+
},
+
),
+
),
+
),
+
);
+
}
+
+
List<Widget> _buildParticleBurst(Color color) {
+
const particleCount = 7;
+
final particles = <Widget>[];
+
final containerSize = widget.size * 2.5;
+
+
for (var i = 0; i < particleCount; i++) {
+
final angle = (2 * math.pi * i) / particleCount;
+
final distance = widget.size * 1 * _particleScaleAnimation.value;
+
final dx = math.cos(angle) * distance;
+
final dy = math.sin(angle) * distance;
+
+
particles.add(
+
Positioned(
+
left: containerSize / 2 + dx - 2,
+
top: containerSize / 2 + dy - 2,
+
child: Opacity(
+
opacity: _particleOpacityAnimation.value,
+
child: Container(
+
width: 2,
+
height: 2,
+
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
+
),
+
),
+
),
+
);
+
}
+
+
return particles;
+
}
+
}
+
+
/// Custom painter for heart icon
+
///
+
/// SVG path data from Bluesky's Heart2 icon component
+
class _HeartIconPainter extends CustomPainter {
+
_HeartIconPainter({required this.color, required this.filled});
+
+
final Color color;
+
final bool filled;
+
+
@override
+
void paint(Canvas canvas, Size size) {
+
final paint =
+
Paint()
+
..color = color
+
..style = PaintingStyle.fill;
+
+
// Scale factor to fit 24x24 viewBox into widget size
+
final scale = size.width / 24;
+
canvas.scale(scale);
+
+
final path = Path();
+
+
if (filled) {
+
// Filled heart path from Bluesky
+
path
+
..moveTo(12.489, 21.372)
+
..cubicTo(21.017, 16.592, 23.115, 10.902, 21.511, 6.902)
+
..cubicTo(20.732, 4.961, 19.097, 3.569, 17.169, 3.139)
+
..cubicTo(15.472, 2.761, 13.617, 3.142, 12, 4.426)
+
..cubicTo(10.383, 3.142, 8.528, 2.761, 6.83, 3.139)
+
..cubicTo(4.903, 3.569, 3.268, 4.961, 2.49, 6.903)
+
..cubicTo(0.885, 10.903, 2.983, 16.593, 11.511, 21.373)
+
..cubicTo(11.826, 21.558, 12.174, 21.558, 12.489, 21.372)
+
..close();
+
} else {
+
// Outline heart path from Bluesky
+
path
+
..moveTo(16.734, 5.091)
+
..cubicTo(15.496, 4.815, 14.026, 5.138, 12.712, 6.471)
+
..cubicTo(12.318, 6.865, 11.682, 6.865, 11.288, 6.471)
+
..cubicTo(9.974, 5.137, 8.504, 4.814, 7.266, 5.09)
+
..cubicTo(6.003, 5.372, 4.887, 6.296, 4.346, 7.646)
+
..cubicTo(3.33, 10.18, 4.252, 14.84, 12, 19.348)
+
..cubicTo(19.747, 14.84, 20.67, 10.18, 19.654, 7.648)
+
..cubicTo(19.113, 6.297, 17.997, 5.373, 16.734, 5.091)
+
..close()
+
..moveTo(21.511, 6.903)
+
..cubicTo(23.115, 10.903, 21.017, 16.593, 12.489, 21.373)
+
..cubicTo(12.174, 21.558, 11.826, 21.558, 11.511, 21.373)
+
..cubicTo(2.983, 16.592, 0.885, 10.902, 2.49, 6.902)
+
..cubicTo(3.269, 4.96, 4.904, 3.568, 6.832, 3.138)
+
..cubicTo(8.529, 2.76, 10.384, 3.141, 12.001, 4.424)
+
..cubicTo(13.618, 3.141, 15.473, 2.76, 17.171, 3.138)
+
..cubicTo(19.098, 3.568, 20.733, 4.96, 21.511, 6.903)
+
..close();
+
}
+
+
canvas.drawPath(path, paint);
+
}
+
+
@override
+
bool shouldRepaint(_HeartIconPainter oldDelegate) {
+
return oldDelegate.color != color || oldDelegate.filled != filled;
+
}
+
}
+117
lib/widgets/icons/reply_icon.dart
···
···
+
import 'package:flutter/material.dart';
+
+
/// Reply/comment icon widget
+
///
+
/// Speech bubble icon from Bluesky's design system.
+
/// Supports both outline and filled states.
+
class ReplyIcon extends StatelessWidget {
+
const ReplyIcon({this.size = 18, this.color, this.filled = false, super.key});
+
+
final double size;
+
final Color? color;
+
final bool filled;
+
+
@override
+
Widget build(BuildContext context) {
+
final effectiveColor =
+
color ?? Theme.of(context).iconTheme.color ?? Colors.grey;
+
+
return CustomPaint(
+
size: Size(size, size),
+
painter: _ReplyIconPainter(color: effectiveColor, filled: filled),
+
);
+
}
+
}
+
+
/// Custom painter for reply/comment icon
+
///
+
/// SVG path data from Bluesky's Reply icon component
+
class _ReplyIconPainter extends CustomPainter {
+
_ReplyIconPainter({required this.color, required this.filled});
+
+
final Color color;
+
final bool filled;
+
+
@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;
+
canvas.scale(scale);
+
+
final path = Path();
+
+
if (filled) {
+
// Filled reply icon path from Bluesky
+
// M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22
+
// v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z
+
path
+
..moveTo(22.002, 15)
+
..cubicTo(22.002, 17.209, 20.211, 19, 18.002, 19)
+
..lineTo(13.354, 19)
+
..lineTo(8.627, 22.781)
+
..cubicTo(8.243, 23.074, 7.683, 22.808, 7.627, 22.318)
+
..lineTo(7.002, 22)
+
..lineTo(7.002, 19)
+
..lineTo(6.002, 19)
+
..cubicTo(3.793, 19, 2.002, 17.209, 2.002, 15)
+
..lineTo(2.002, 7)
+
..cubicTo(2.002, 4.791, 3.793, 3, 6.002, 3)
+
..lineTo(18.002, 3)
+
..cubicTo(20.211, 3, 22.002, 4.791, 22.002, 7)
+
..lineTo(22.002, 15)
+
..close();
+
} else {
+
// Outline reply icon path from Bluesky
+
// M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2
+
// a1 1 0 0 1 1 1 v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5
+
// a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4 h-4.648l-4.727 3.781
+
// A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7
+
// a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z
+
+
// Inner shape
+
path
+
..moveTo(20.002, 7)
+
..cubicTo(20.002, 5.895, 19.107, 5, 18.002, 5)
+
..lineTo(6.002, 5)
+
..cubicTo(4.897, 5, 4.002, 5.895, 4.002, 7)
+
..lineTo(4.002, 15)
+
..cubicTo(4.002, 16.105, 4.897, 17, 6.002, 17)
+
..lineTo(8.002, 17)
+
..cubicTo(8.554, 17, 9.002, 17.448, 9.002, 18)
+
..lineTo(9.002, 19.918)
+
..lineTo(12.377, 17.218)
+
..cubicTo(12.574, 17.073, 12.813, 17, 13.002, 17)
+
..lineTo(18.002, 17)
+
..cubicTo(19.107, 17, 20.002, 16.105, 20.002, 15)
+
..lineTo(20.002, 7)
+
..close()
+
// Outer shape
+
..moveTo(22.002, 15)
+
..cubicTo(22.002, 17.209, 20.211, 19, 18.002, 19)
+
..lineTo(13.354, 19)
+
..lineTo(8.627, 22.781)
+
..cubicTo(8.243, 23.074, 7.683, 22.808, 7.627, 22.318)
+
..lineTo(7.002, 22)
+
..lineTo(7.002, 19)
+
..lineTo(6.002, 19)
+
..cubicTo(3.793, 19, 2.002, 17.209, 2.002, 15)
+
..lineTo(2.002, 7)
+
..cubicTo(2.002, 4.791, 3.793, 3, 6.002, 3)
+
..lineTo(18.002, 3)
+
..cubicTo(20.211, 3, 22.002, 4.791, 22.002, 7)
+
..lineTo(22.002, 15)
+
..close();
+
}
+
+
canvas.drawPath(path, paint);
+
}
+
+
@override
+
bool shouldRepaint(_ReplyIconPainter oldDelegate) {
+
return oldDelegate.color != color || oldDelegate.filled != filled;
+
}
+
}
+93
lib/widgets/icons/share_icon.dart
···
···
+
import 'package:flutter/material.dart';
+
+
/// Share icon widget (arrow out of box)
+
///
+
/// Arrow-out-of-box icon from Bluesky's design system.
+
/// Uses the modified version with rounded corners for a friendlier look.
+
class ShareIcon extends StatelessWidget {
+
const ShareIcon({this.size = 18, this.color, super.key});
+
+
final double size;
+
final Color? color;
+
+
@override
+
Widget build(BuildContext context) {
+
final effectiveColor =
+
color ?? Theme.of(context).iconTheme.color ?? Colors.grey;
+
+
return CustomPaint(
+
size: Size(size, size),
+
painter: _ShareIconPainter(color: effectiveColor),
+
);
+
}
+
}
+
+
/// Custom painter for share icon
+
///
+
/// SVG path data from Bluesky's ArrowOutOfBoxModified icon component
+
class _ShareIconPainter extends CustomPainter {
+
_ShareIconPainter({required this.color});
+
+
final Color color;
+
+
@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;
+
canvas.scale(scale);
+
+
// ArrowOutOfBoxModified_Stroke2_Corner2_Rounded path from Bluesky
+
// M20 13.75a1 1 0 0 1 1 1V18a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3v-3.25
+
// a1 1 0 1 1 2 0V18 a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-3.25
+
// a1 1 0 0 1 1-1ZM12 3a1 1 0 0 1 .707.293 l4.5 4.5
+
// a1 1 0 1 1-1.414 1.414L13 6.414v8.836a1 1 0 1 1-2 0V6.414
+
// 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
+
bool shouldRepaint(_ShareIconPainter oldDelegate) {
+
return oldDelegate.color != color;
+
}
+
}
+77 -36
lib/widgets/post_card.dart
···
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../constants/app_colors.dart';
import '../models/post.dart';
import '../utils/date_time_utils.dart';
/// Post card widget for displaying feed posts
///
···
horizontal: 12,
vertical: 10,
),
-
child: Icon(
-
Icons.ios_share,
-
size: 18,
color: AppColors.textPrimary.withValues(alpha: 0.6),
),
),
···
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
-
Icon(
-
Icons.chat_bubble_outline,
-
size: 18,
color: AppColors.textPrimary.withValues(alpha: 0.6),
),
const SizedBox(width: 5),
···
const SizedBox(width: 8),
// Heart button
-
InkWell(
-
onTap: () {
-
// TODO: Handle upvote/like interaction with backend
-
if (kDebugMode) {
-
debugPrint('Heart button tapped for post');
-
}
-
},
-
child: Padding(
-
// Increased padding for better touch targets
-
padding: const EdgeInsets.symmetric(
-
horizontal: 12,
-
vertical: 10,
-
),
-
child: Row(
-
mainAxisSize: MainAxisSize.min,
-
children: [
-
Icon(
-
Icons.favorite_border,
-
size: 18,
-
color: AppColors.textPrimary.withValues(alpha: 0.6),
),
-
const SizedBox(width: 5),
-
Text(
-
DateTimeUtils.formatCount(post.post.stats.score),
-
style: TextStyle(
-
color: AppColors.textPrimary.withValues(alpha: 0.6),
-
fontSize: 13,
-
),
),
-
],
-
),
-
),
),
],
),
···
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
+
import 'package:flutter/services.dart';
+
import 'package:provider/provider.dart';
import '../constants/app_colors.dart';
import '../models/post.dart';
+
import '../providers/auth_provider.dart';
+
import '../providers/vote_provider.dart';
import '../utils/date_time_utils.dart';
+
import 'icons/animated_heart_icon.dart';
+
import 'icons/reply_icon.dart';
+
import 'icons/share_icon.dart';
+
import 'sign_in_dialog.dart';
/// Post card widget for displaying feed posts
///
···
horizontal: 12,
vertical: 10,
),
+
child: ShareIcon(
color: AppColors.textPrimary.withValues(alpha: 0.6),
),
),
···
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
+
ReplyIcon(
color: AppColors.textPrimary.withValues(alpha: 0.6),
),
const SizedBox(width: 5),
···
const SizedBox(width: 8),
// Heart button
+
Consumer<VoteProvider>(
+
builder: (context, voteProvider, child) {
+
final isLiked = voteProvider.isLiked(post.post.uri);
+
+
return InkWell(
+
onTap: () async {
+
// Check authentication
+
final authProvider = context.read<AuthProvider>();
+
if (!authProvider.isAuthenticated) {
+
// Show sign-in dialog
+
final shouldSignIn = await SignInDialog.show(
+
context,
+
message: 'You need to sign in to like posts.',
+
);
+
+
if ((shouldSignIn ?? false) && context.mounted) {
+
// TODO: Navigate to sign-in screen
+
if (kDebugMode) {
+
debugPrint('Navigate to sign-in screen');
+
}
+
}
+
return;
+
}
+
+
// Light haptic feedback on both like and unlike
+
await HapticFeedback.lightImpact();
+
+
// Toggle vote with optimistic update
+
try {
+
await voteProvider.toggleVote(
+
postUri: post.post.uri,
+
postCid: post.post.cid,
+
);
+
} on Exception catch (e) {
+
if (kDebugMode) {
+
debugPrint('Failed to toggle vote: $e');
+
}
+
// TODO: Show error snackbar
+
}
+
},
+
child: Padding(
+
// Increased padding for better touch targets
+
padding: const EdgeInsets.symmetric(
+
horizontal: 12,
+
vertical: 10,
),
+
child: Row(
+
mainAxisSize: MainAxisSize.min,
+
children: [
+
AnimatedHeartIcon(
+
isLiked: isLiked,
+
color: AppColors.textPrimary
+
.withValues(alpha: 0.6),
+
likedColor: const Color(0xFFFF0033),
+
),
+
const SizedBox(width: 5),
+
Text(
+
DateTimeUtils.formatCount(post.post.stats.score),
+
style: TextStyle(
+
color: AppColors.textPrimary
+
.withValues(alpha: 0.6),
+
fontSize: 13,
+
),
+
),
+
],
),
+
),
+
);
+
},
),
],
),
+70
lib/widgets/sign_in_dialog.dart
···
···
+
import 'package:flutter/material.dart';
+
+
import '../constants/app_colors.dart';
+
+
/// Sign In Dialog
+
///
+
/// Shows a dialog prompting users to sign in before performing actions
+
/// that require authentication (like voting, commenting, etc.)
+
class SignInDialog extends StatelessWidget {
+
const SignInDialog({
+
this.title = 'Sign in required',
+
this.message = 'You need to sign in to interact with posts.',
+
super.key,
+
});
+
+
final String title;
+
final String message;
+
+
/// Show the dialog
+
static Future<bool?> show(
+
BuildContext context, {
+
String? title,
+
String? message,
+
}) {
+
return showDialog<bool>(
+
context: context,
+
builder:
+
(context) => SignInDialog(
+
title: title ?? 'Sign in required',
+
message: message ?? 'You need to sign in to interact with posts.',
+
),
+
);
+
}
+
+
@override
+
Widget build(BuildContext context) {
+
return AlertDialog(
+
backgroundColor: AppColors.background,
+
title: Text(
+
title,
+
style: const TextStyle(
+
color: AppColors.textPrimary,
+
fontSize: 18,
+
fontWeight: FontWeight.bold,
+
),
+
),
+
content: Text(
+
message,
+
style: const TextStyle(color: AppColors.textSecondary, fontSize: 14),
+
),
+
actions: [
+
TextButton(
+
onPressed: () => Navigator.of(context).pop(false),
+
child: const Text(
+
'Cancel',
+
style: TextStyle(color: AppColors.textSecondary),
+
),
+
),
+
ElevatedButton(
+
onPressed: () => Navigator.of(context).pop(true),
+
style: ElevatedButton.styleFrom(
+
backgroundColor: AppColors.primary,
+
foregroundColor: AppColors.textPrimary,
+
),
+
child: const Text('Sign In'),
+
),
+
],
+
);
+
}
+
}
+1 -1
test/providers/auth_provider_test.mocks.dart
···
@override
_i6.Future<_i2.OAuthSession?> restoreSession(
String? did, {
-
dynamic refresh = 'auto',
}) =>
(super.noSuchMethod(
Invocation.method(#restoreSession, [did], {#refresh: refresh}),
···
@override
_i6.Future<_i2.OAuthSession?> restoreSession(
String? did, {
+
String? refresh = 'auto',
}) =>
(super.noSuchMethod(
Invocation.method(#restoreSession, [did], {#refresh: refresh}),
+16
test/providers/feed_provider_test.dart
···
});
test('should not load more if already loading', () async {
await feedProvider.fetchTimeline(refresh: true);
await feedProvider.loadMore();
···
});
test('should not load more if already loading', () async {
+
when(mockAuthProvider.isAuthenticated).thenReturn(true);
+
+
final response = TimelineResponse(
+
feed: [_createMockPost()],
+
cursor: 'cursor-1',
+
);
+
+
when(
+
mockApiService.getTimeline(
+
sort: anyNamed('sort'),
+
timeframe: anyNamed('timeframe'),
+
limit: anyNamed('limit'),
+
cursor: anyNamed('cursor'),
+
),
+
).thenAnswer((_) async => response);
+
await feedProvider.fetchTimeline(refresh: true);
await feedProvider.loadMore();
+467
test/providers/vote_provider_test.dart
···
···
+
import 'package:coves_flutter/providers/auth_provider.dart';
+
import 'package:coves_flutter/providers/vote_provider.dart';
+
import 'package:coves_flutter/services/api_exceptions.dart';
+
import 'package:coves_flutter/services/vote_service.dart';
+
import 'package:flutter_test/flutter_test.dart';
+
import 'package:mockito/annotations.dart';
+
import 'package:mockito/mockito.dart';
+
+
import 'vote_provider_test.mocks.dart';
+
+
// Generate mocks for VoteService and AuthProvider
+
@GenerateMocks([VoteService, AuthProvider])
+
void main() {
+
TestWidgetsFlutterBinding.ensureInitialized();
+
+
group('VoteProvider', () {
+
late VoteProvider voteProvider;
+
late MockVoteService mockVoteService;
+
late MockAuthProvider mockAuthProvider;
+
+
setUp(() {
+
mockVoteService = MockVoteService();
+
mockAuthProvider = MockAuthProvider();
+
+
// Default: user is authenticated
+
when(mockAuthProvider.isAuthenticated).thenReturn(true);
+
+
voteProvider = VoteProvider(
+
voteService: mockVoteService,
+
authProvider: mockAuthProvider,
+
);
+
});
+
+
tearDown(() {
+
voteProvider.dispose();
+
});
+
+
group('toggleVote', () {
+
const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
+
const testPostCid = 'bafy2bzacepostcid123';
+
+
test('should create vote with optimistic update', () async {
+
// Mock successful API response
+
when(
+
mockVoteService.createVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
),
+
).thenAnswer(
+
(_) async => const VoteResponse(
+
uri: 'at://did:plc:test/social.coves.interaction.vote/456',
+
cid: 'bafy123',
+
rkey: '456',
+
deleted: false,
+
),
+
);
+
+
var notificationCount = 0;
+
voteProvider.addListener(() {
+
notificationCount++;
+
});
+
+
// Initially not liked
+
expect(voteProvider.isLiked(testPostUri), false);
+
+
// Toggle vote
+
final wasLiked = await voteProvider.toggleVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
);
+
+
// Should return true (vote created)
+
expect(wasLiked, true);
+
+
// Should be liked now
+
expect(voteProvider.isLiked(testPostUri), true);
+
+
// Should have notified listeners twice (optimistic + server response)
+
expect(notificationCount, greaterThanOrEqualTo(2));
+
+
// Vote state should be correct
+
final voteState = voteProvider.getVoteState(testPostUri);
+
expect(voteState?.direction, 'up');
+
expect(voteState?.uri, 'at://did:plc:test/social.coves.interaction.vote/456');
+
expect(voteState?.deleted, false);
+
});
+
+
test('should remove vote when toggled off', () async {
+
// First, set up initial vote state
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.interaction.vote/456',
+
);
+
+
expect(voteProvider.isLiked(testPostUri), true);
+
+
// Mock API response for toggling off
+
when(
+
mockVoteService.createVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
),
+
).thenAnswer(
+
(_) async => const VoteResponse(deleted: true),
+
);
+
+
// Toggle vote off
+
final wasLiked = await voteProvider.toggleVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
);
+
+
// Should return false (vote removed)
+
expect(wasLiked, false);
+
+
// Should not be liked anymore
+
expect(voteProvider.isLiked(testPostUri), false);
+
+
// Vote state should be marked as deleted
+
final voteState = voteProvider.getVoteState(testPostUri);
+
expect(voteState?.deleted, true);
+
});
+
+
test('should rollback on API error', () async {
+
// Set up initial state (not voted)
+
expect(voteProvider.isLiked(testPostUri), false);
+
+
// Mock API failure
+
when(
+
mockVoteService.createVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
),
+
).thenThrow(
+
ApiException('Network error', statusCode: 500),
+
);
+
+
var notificationCount = 0;
+
voteProvider.addListener(() {
+
notificationCount++;
+
});
+
+
// Try to toggle vote
+
expect(
+
() => voteProvider.toggleVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
),
+
throwsA(isA<ApiException>()),
+
);
+
+
// Should rollback to initial state (not liked)
+
await Future.delayed(Duration.zero); // Wait for async completion
+
expect(voteProvider.isLiked(testPostUri), false);
+
expect(voteProvider.getVoteState(testPostUri), null);
+
+
// Should have notified listeners (optimistic + rollback)
+
expect(notificationCount, greaterThanOrEqualTo(2));
+
});
+
+
test('should rollback to previous state on error', () async {
+
// Set up initial voted state
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.interaction.vote/456',
+
);
+
+
final initialState = voteProvider.getVoteState(testPostUri);
+
expect(voteProvider.isLiked(testPostUri), true);
+
+
// Mock API failure when trying to toggle off
+
when(
+
mockVoteService.createVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
),
+
).thenThrow(
+
NetworkException('Connection failed'),
+
);
+
+
// Try to toggle vote off
+
expect(
+
() => voteProvider.toggleVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
),
+
throwsA(isA<ApiException>()),
+
);
+
+
// Should rollback to initial liked state
+
await Future.delayed(Duration.zero); // Wait for async completion
+
expect(voteProvider.isLiked(testPostUri), true);
+
expect(voteProvider.getVoteState(testPostUri)?.uri, initialState?.uri);
+
});
+
+
test('should prevent concurrent requests for same post', () async {
+
// Mock slow API response
+
when(
+
mockVoteService.createVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
),
+
).thenAnswer(
+
(_) async {
+
await Future.delayed(const Duration(milliseconds: 100));
+
return const VoteResponse(
+
uri: 'at://did:plc:test/social.coves.interaction.vote/456',
+
cid: 'bafy123',
+
rkey: '456',
+
deleted: false,
+
);
+
},
+
);
+
+
// Start first request
+
final future1 = voteProvider.toggleVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
);
+
+
// Try to start second request before first completes
+
final result2 = await voteProvider.toggleVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
);
+
+
// Second request should be ignored
+
expect(result2, false);
+
+
// First request should complete normally
+
final result1 = await future1;
+
expect(result1, true);
+
+
// Should have only called API once
+
verify(
+
mockVoteService.createVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
),
+
).called(1);
+
});
+
+
test('should handle downvote direction', () async {
+
when(
+
mockVoteService.createVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
direction: 'down',
+
),
+
).thenAnswer(
+
(_) async => const VoteResponse(
+
uri: 'at://did:plc:test/social.coves.interaction.vote/456',
+
cid: 'bafy123',
+
rkey: '456',
+
deleted: false,
+
),
+
);
+
+
await voteProvider.toggleVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
direction: 'down',
+
);
+
+
final voteState = voteProvider.getVoteState(testPostUri);
+
expect(voteState?.direction, 'down');
+
expect(voteState?.deleted, false);
+
+
// Should not be "liked" (isLiked checks for 'up' direction)
+
expect(voteProvider.isLiked(testPostUri), false);
+
});
+
});
+
+
group('setInitialVoteState', () {
+
const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
+
+
test('should set initial vote state from API data', () {
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.interaction.vote/456',
+
);
+
+
expect(voteProvider.isLiked(testPostUri), true);
+
+
final voteState = voteProvider.getVoteState(testPostUri);
+
expect(voteState?.direction, 'up');
+
expect(voteState?.uri, 'at://did:plc:test/social.coves.interaction.vote/456');
+
expect(voteState?.deleted, false);
+
});
+
+
test('should remove vote state when voteDirection is null', () {
+
// First set a vote
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.interaction.vote/456',
+
);
+
+
expect(voteProvider.isLiked(testPostUri), true);
+
+
// Then clear it
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
);
+
+
expect(voteProvider.isLiked(testPostUri), false);
+
expect(voteProvider.getVoteState(testPostUri), null);
+
});
+
+
test('should not notify listeners when setting initial state', () {
+
var notificationCount = 0;
+
voteProvider.addListener(() {
+
notificationCount++;
+
});
+
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.interaction.vote/456',
+
);
+
+
// Should NOT notify listeners (silent initialization)
+
expect(notificationCount, 0);
+
});
+
});
+
+
group('clear', () {
+
test('should clear all vote state', () {
+
const post1 = 'at://did:plc:test/social.coves.post.record/1';
+
const post2 = 'at://did:plc:test/social.coves.post.record/2';
+
+
// Set up multiple votes
+
voteProvider.setInitialVoteState(
+
postUri: post1,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.interaction.vote/1',
+
);
+
voteProvider.setInitialVoteState(
+
postUri: post2,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.interaction.vote/2',
+
);
+
+
expect(voteProvider.isLiked(post1), true);
+
expect(voteProvider.isLiked(post2), true);
+
+
// Clear all
+
voteProvider.clear();
+
+
// Should have no votes
+
expect(voteProvider.isLiked(post1), false);
+
expect(voteProvider.isLiked(post2), false);
+
expect(voteProvider.getVoteState(post1), null);
+
expect(voteProvider.getVoteState(post2), null);
+
});
+
+
test('should notify listeners when cleared', () {
+
var notificationCount = 0;
+
voteProvider.addListener(() {
+
notificationCount++;
+
});
+
+
voteProvider.clear();
+
+
expect(notificationCount, 1);
+
});
+
});
+
+
group('isPending', () {
+
const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
+
const testPostCid = 'bafy2bzacepostcid123';
+
+
test('should return true while request is in progress', () async {
+
// Mock slow API response
+
when(
+
mockVoteService.createVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
),
+
).thenAnswer(
+
(_) async {
+
await Future.delayed(const Duration(milliseconds: 50));
+
return const VoteResponse(
+
uri: 'at://did:plc:test/social.coves.interaction.vote/456',
+
cid: 'bafy123',
+
rkey: '456',
+
deleted: false,
+
);
+
},
+
);
+
+
expect(voteProvider.isPending(testPostUri), false);
+
+
// Start request
+
final future = voteProvider.toggleVote(
+
postUri: testPostUri,
+
postCid: testPostCid,
+
);
+
+
// Give it time to set pending flag
+
await Future.delayed(const Duration(milliseconds: 10));
+
+
// Should be pending now
+
expect(voteProvider.isPending(testPostUri), true);
+
+
// Wait for completion
+
await future;
+
+
// Should not be pending anymore
+
expect(voteProvider.isPending(testPostUri), false);
+
});
+
+
test('should return false for posts with no pending request', () {
+
const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
+
expect(voteProvider.isPending(testPostUri), false);
+
});
+
});
+
+
group('Auth state listener', () {
+
test('should clear votes when user signs out', () {
+
const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
+
+
// Set up vote state
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.interaction.vote/456',
+
);
+
+
expect(voteProvider.isLiked(testPostUri), true);
+
+
// Simulate sign out by changing auth state
+
when(mockAuthProvider.isAuthenticated).thenReturn(false);
+
+
// Trigger the auth listener by calling it directly
+
// (In real app, this would be triggered by AuthProvider.notifyListeners)
+
voteProvider.clear();
+
+
// Votes should be cleared
+
expect(voteProvider.isLiked(testPostUri), false);
+
expect(voteProvider.getVoteState(testPostUri), null);
+
});
+
+
test('should not clear votes when user is still authenticated', () {
+
const testPostUri = 'at://did:plc:test/social.coves.post.record/123';
+
+
// Set up vote state
+
voteProvider.setInitialVoteState(
+
postUri: testPostUri,
+
voteDirection: 'up',
+
voteUri: 'at://did:plc:test/social.coves.interaction.vote/456',
+
);
+
+
expect(voteProvider.isLiked(testPostUri), true);
+
+
// Auth state remains authenticated
+
when(mockAuthProvider.isAuthenticated).thenReturn(true);
+
+
// Votes should NOT be cleared
+
expect(voteProvider.isLiked(testPostUri), true);
+
});
+
});
+
});
+
}
+157
test/providers/vote_provider_test.mocks.dart
···
···
+
// Mocks generated by Mockito 5.4.6 from annotations
+
// in coves_flutter/test/providers/vote_provider_test.dart.
+
// Do not manually edit this file.
+
+
// ignore_for_file: no_leading_underscores_for_library_prefixes
+
import 'dart:async' as _i3;
+
import 'dart:ui' as _i5;
+
+
import 'package:coves_flutter/providers/auth_provider.dart' as _i4;
+
import 'package:coves_flutter/services/vote_service.dart' as _i2;
+
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 _FakeVoteResponse_0 extends _i1.SmartFake implements _i2.VoteResponse {
+
_FakeVoteResponse_0(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
/// A class which mocks [VoteService].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockVoteService extends _i1.Mock implements _i2.VoteService {
+
MockVoteService() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i3.Future<_i2.VoteResponse> createVote({
+
required String? postUri,
+
required String? postCid,
+
String? direction = 'up',
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(#createVote, [], {
+
#postUri: postUri,
+
#postCid: postCid,
+
#direction: direction,
+
}),
+
returnValue: _i3.Future<_i2.VoteResponse>.value(
+
_FakeVoteResponse_0(
+
this,
+
Invocation.method(#createVote, [], {
+
#postUri: postUri,
+
#postCid: postCid,
+
#direction: direction,
+
}),
+
),
+
),
+
)
+
as _i3.Future<_i2.VoteResponse>);
+
}
+
+
/// A class which mocks [AuthProvider].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockAuthProvider extends _i1.Mock implements _i4.AuthProvider {
+
MockAuthProvider() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
bool get isAuthenticated =>
+
(super.noSuchMethod(
+
Invocation.getter(#isAuthenticated),
+
returnValue: false,
+
)
+
as bool);
+
+
@override
+
bool get isLoading =>
+
(super.noSuchMethod(Invocation.getter(#isLoading), returnValue: false)
+
as bool);
+
+
@override
+
bool get hasListeners =>
+
(super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false)
+
as bool);
+
+
@override
+
_i3.Future<String?> getAccessToken() =>
+
(super.noSuchMethod(
+
Invocation.method(#getAccessToken, []),
+
returnValue: _i3.Future<String?>.value(),
+
)
+
as _i3.Future<String?>);
+
+
@override
+
_i3.Future<void> initialize() =>
+
(super.noSuchMethod(
+
Invocation.method(#initialize, []),
+
returnValue: _i3.Future<void>.value(),
+
returnValueForMissingStub: _i3.Future<void>.value(),
+
)
+
as _i3.Future<void>);
+
+
@override
+
_i3.Future<void> signIn(String? handle) =>
+
(super.noSuchMethod(
+
Invocation.method(#signIn, [handle]),
+
returnValue: _i3.Future<void>.value(),
+
returnValueForMissingStub: _i3.Future<void>.value(),
+
)
+
as _i3.Future<void>);
+
+
@override
+
_i3.Future<void> signOut() =>
+
(super.noSuchMethod(
+
Invocation.method(#signOut, []),
+
returnValue: _i3.Future<void>.value(),
+
returnValueForMissingStub: _i3.Future<void>.value(),
+
)
+
as _i3.Future<void>);
+
+
@override
+
void clearError() => super.noSuchMethod(
+
Invocation.method(#clearError, []),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void dispose() => super.noSuchMethod(
+
Invocation.method(#dispose, []),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void addListener(_i5.VoidCallback? listener) => super.noSuchMethod(
+
Invocation.method(#addListener, [listener]),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void removeListener(_i5.VoidCallback? listener) => super.noSuchMethod(
+
Invocation.method(#removeListener, [listener]),
+
returnValueForMissingStub: null,
+
);
+
+
@override
+
void notifyListeners() => super.noSuchMethod(
+
Invocation.method(#notifyListeners, []),
+
returnValueForMissingStub: null,
+
);
+
}
+480
test/services/vote_service_test.dart
···
···
+
import 'dart:convert';
+
+
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
+
import 'package:coves_flutter/services/api_exceptions.dart';
+
import 'package:coves_flutter/services/vote_service.dart';
+
import 'package:dio/dio.dart';
+
import 'package:flutter_test/flutter_test.dart';
+
import 'package:http/http.dart' as http;
+
import 'package:mockito/annotations.dart';
+
import 'package:mockito/mockito.dart';
+
+
import 'vote_service_test.mocks.dart';
+
+
// Generate mocks for OAuthSession
+
@GenerateMocks([OAuthSession])
+
void main() {
+
group('VoteService', () {
+
group('_findExistingVote pagination', () {
+
test('should find vote in first page', () async {
+
final mockSession = MockOAuthSession();
+
final service = VoteService(
+
sessionGetter: () async => mockSession,
+
didGetter: () => 'did:plc:test',
+
pdsUrlGetter: () => 'https://test.pds',
+
);
+
+
// Mock first page response with matching vote
+
final firstPageResponse = http.Response(
+
jsonEncode({
+
'records': [
+
{
+
'uri': 'at://did:plc:test/social.coves.interaction.vote/abc123',
+
'value': {
+
'subject': {
+
'uri': 'at://did:plc:author/social.coves.post.record/post1',
+
'cid': 'bafy123',
+
},
+
'direction': 'up',
+
'createdAt': '2024-01-01T00:00:00Z',
+
},
+
},
+
],
+
'cursor': null,
+
}),
+
200,
+
);
+
+
when(
+
mockSession.fetchHandler(
+
argThat(contains('listRecords')),
+
method: 'GET',
+
),
+
).thenAnswer((_) async => firstPageResponse);
+
+
// Mock deleteRecord for when existing vote is found
+
when(
+
mockSession.fetchHandler(
+
argThat(contains('deleteRecord')),
+
method: 'POST',
+
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
+
final response = await service.createVote(
+
postUri: 'at://did:plc:author/social.coves.post.record/post1',
+
postCid: 'bafy123',
+
direction: 'up',
+
);
+
+
// Should return deleted=true because existing vote with same direction
+
expect(response.deleted, true);
+
verify(
+
mockSession.fetchHandler(
+
argThat(contains('listRecords')),
+
method: 'GET',
+
),
+
).called(1);
+
});
+
+
test('should paginate through multiple pages to find vote', () async {
+
final mockSession = MockOAuthSession();
+
final service = VoteService(
+
sessionGetter: () async => mockSession,
+
didGetter: () => 'did:plc:test',
+
pdsUrlGetter: () => 'https://test.pds',
+
);
+
+
// Mock first page without matching vote but with cursor
+
final firstPageResponse = http.Response(
+
jsonEncode({
+
'records': [
+
{
+
'uri': 'at://did:plc:test/social.coves.interaction.vote/abc1',
+
'value': {
+
'subject': {
+
'uri': 'at://did:plc:author/social.coves.post.record/other1',
+
'cid': 'bafy001',
+
},
+
'direction': 'up',
+
},
+
},
+
],
+
'cursor': 'cursor123',
+
}),
+
200,
+
);
+
+
// Mock second page with matching vote
+
final secondPageResponse = http.Response(
+
jsonEncode({
+
'records': [
+
{
+
'uri': 'at://did:plc:test/social.coves.interaction.vote/abc123',
+
'value': {
+
'subject': {
+
'uri': 'at://did:plc:author/social.coves.post.record/target',
+
'cid': 'bafy123',
+
},
+
'direction': 'up',
+
'createdAt': '2024-01-01T00:00:00Z',
+
},
+
},
+
],
+
'cursor': null,
+
}),
+
200,
+
);
+
+
// Setup mock responses based on URL
+
when(
+
mockSession.fetchHandler(
+
argThat(allOf(contains('listRecords'), isNot(contains('cursor')))),
+
method: 'GET',
+
),
+
).thenAnswer((_) async => firstPageResponse);
+
+
when(
+
mockSession.fetchHandler(
+
argThat(allOf(contains('listRecords'), contains('cursor=cursor123'))),
+
method: 'GET',
+
),
+
).thenAnswer((_) async => secondPageResponse);
+
+
// Mock deleteRecord for when existing vote is found
+
when(
+
mockSession.fetchHandler(
+
argThat(contains('deleteRecord')),
+
method: 'POST',
+
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(
+
postUri: 'at://did:plc:author/social.coves.post.record/target',
+
postCid: 'bafy123',
+
direction: 'up',
+
);
+
+
// Should return deleted=true because existing vote was found on page 2
+
expect(response.deleted, true);
+
+
// Verify both pages were fetched
+
verify(
+
mockSession.fetchHandler(
+
argThat(allOf(contains('listRecords'), isNot(contains('cursor')))),
+
method: 'GET',
+
),
+
).called(1);
+
+
verify(
+
mockSession.fetchHandler(
+
argThat(allOf(contains('listRecords'), contains('cursor=cursor123'))),
+
method: 'GET',
+
),
+
).called(1);
+
});
+
+
test('should handle vote not found after pagination', () async {
+
final mockSession = MockOAuthSession();
+
final service = VoteService(
+
sessionGetter: () async => mockSession,
+
didGetter: () => 'did:plc:test',
+
pdsUrlGetter: () => 'https://test.pds',
+
);
+
+
// Mock response with no matching votes
+
final response = http.Response(
+
jsonEncode({
+
'records': [
+
{
+
'uri': 'at://did:plc:test/social.coves.interaction.vote/abc1',
+
'value': {
+
'subject': {
+
'uri': 'at://did:plc:author/social.coves.post.record/other',
+
'cid': 'bafy001',
+
},
+
'direction': 'up',
+
},
+
},
+
],
+
'cursor': null,
+
}),
+
200,
+
);
+
+
when(
+
mockSession.fetchHandler(
+
argThat(contains('listRecords')),
+
method: 'GET',
+
),
+
).thenAnswer((_) async => response);
+
+
// Mock createRecord for new vote
+
when(
+
mockSession.fetchHandler(
+
argThat(contains('createRecord')),
+
method: 'POST',
+
headers: anyNamed('headers'),
+
body: anyNamed('body'),
+
),
+
).thenAnswer(
+
(_) async => http.Response(
+
jsonEncode({
+
'uri': 'at://did:plc:test/social.coves.interaction.vote/new123',
+
'cid': 'bafy456',
+
}),
+
200,
+
),
+
);
+
+
// Test creating vote for post not in vote history
+
final voteResponse = await service.createVote(
+
postUri: 'at://did:plc:author/social.coves.post.record/newpost',
+
postCid: 'bafy123',
+
direction: 'up',
+
);
+
+
// Should create new vote
+
expect(voteResponse.deleted, false);
+
expect(voteResponse.uri, isNotNull);
+
expect(voteResponse.cid, 'bafy456');
+
+
// Verify createRecord was called
+
verify(
+
mockSession.fetchHandler(
+
argThat(contains('createRecord')),
+
method: 'POST',
+
headers: anyNamed('headers'),
+
body: anyNamed('body'),
+
),
+
).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
+
+
const response = VoteResponse(
+
uri: 'at://did:plc:test/social.coves.interaction.vote/456',
+
cid: 'bafy123',
+
rkey: '456',
+
deleted: false,
+
);
+
+
expect(response.uri, 'at://did:plc:test/social.coves.interaction.vote/456');
+
expect(response.cid, 'bafy123');
+
expect(response.rkey, '456');
+
expect(response.deleted, false);
+
});
+
+
test('should return deleted response when vote is toggled off', () {
+
const response = VoteResponse(deleted: true);
+
+
expect(response.deleted, true);
+
expect(response.uri, null);
+
expect(response.cid, null);
+
});
+
+
test('should throw ApiException on Dio network error', () {
+
// Test ApiException.fromDioError for connection errors
+
final dioError = DioException(
+
requestOptions: RequestOptions(path: '/test'),
+
type: DioExceptionType.connectionError,
+
);
+
+
final exception = ApiException.fromDioError(dioError);
+
+
expect(exception, isA<NetworkException>());
+
expect(
+
exception.message,
+
contains('Connection failed'),
+
);
+
});
+
+
test('should throw ApiException on Dio timeout', () {
+
final dioError = DioException(
+
requestOptions: RequestOptions(path: '/test'),
+
type: DioExceptionType.connectionTimeout,
+
);
+
+
final exception = ApiException.fromDioError(dioError);
+
+
expect(exception, isA<NetworkException>());
+
expect(exception.message, contains('timeout'));
+
});
+
+
test('should throw AuthenticationException on 401 response', () {
+
final dioError = DioException(
+
requestOptions: RequestOptions(path: '/test'),
+
type: DioExceptionType.badResponse,
+
response: Response(
+
requestOptions: RequestOptions(path: '/test'),
+
statusCode: 401,
+
data: {'message': 'Unauthorized'},
+
),
+
);
+
+
final exception = ApiException.fromDioError(dioError);
+
+
expect(exception, isA<AuthenticationException>());
+
expect(exception.statusCode, 401);
+
expect(exception.message, 'Unauthorized');
+
});
+
+
test('should throw NotFoundException on 404 response', () {
+
final dioError = DioException(
+
requestOptions: RequestOptions(path: '/test'),
+
type: DioExceptionType.badResponse,
+
response: Response(
+
requestOptions: RequestOptions(path: '/test'),
+
statusCode: 404,
+
data: {'message': 'Post not found'},
+
),
+
);
+
+
final exception = ApiException.fromDioError(dioError);
+
+
expect(exception, isA<NotFoundException>());
+
expect(exception.statusCode, 404);
+
expect(exception.message, 'Post not found');
+
});
+
+
test('should throw ServerException on 500 response', () {
+
final dioError = DioException(
+
requestOptions: RequestOptions(path: '/test'),
+
type: DioExceptionType.badResponse,
+
response: Response(
+
requestOptions: RequestOptions(path: '/test'),
+
statusCode: 500,
+
data: {'error': 'Internal server error'},
+
),
+
);
+
+
final exception = ApiException.fromDioError(dioError);
+
+
expect(exception, isA<ServerException>());
+
expect(exception.statusCode, 500);
+
expect(exception.message, 'Internal server error');
+
});
+
+
test('should extract error message from response data', () {
+
final dioError = DioException(
+
requestOptions: RequestOptions(path: '/test'),
+
type: DioExceptionType.badResponse,
+
response: Response(
+
requestOptions: RequestOptions(path: '/test'),
+
statusCode: 400,
+
data: {'message': 'Invalid post URI'},
+
),
+
);
+
+
final exception = ApiException.fromDioError(dioError);
+
+
expect(exception.message, 'Invalid post URI');
+
expect(exception.statusCode, 400);
+
});
+
+
test('should use default message if no error message in response', () {
+
final dioError = DioException(
+
requestOptions: RequestOptions(path: '/test'),
+
type: DioExceptionType.badResponse,
+
response: Response(
+
requestOptions: RequestOptions(path: '/test'),
+
statusCode: 400,
+
data: {},
+
),
+
);
+
+
final exception = ApiException.fromDioError(dioError);
+
+
expect(exception.message, 'Server error');
+
});
+
+
test('should handle cancelled requests', () {
+
final dioError = DioException(
+
requestOptions: RequestOptions(path: '/test'),
+
type: DioExceptionType.cancel,
+
);
+
+
final exception = ApiException.fromDioError(dioError);
+
+
expect(exception.message, contains('cancelled'));
+
});
+
+
test('should handle bad certificate errors', () {
+
final dioError = DioException(
+
requestOptions: RequestOptions(path: '/test'),
+
type: DioExceptionType.badCertificate,
+
);
+
+
final exception = ApiException.fromDioError(dioError);
+
+
expect(exception, isA<NetworkException>());
+
expect(exception.message, contains('certificate'));
+
});
+
+
test('should handle unknown errors', () {
+
final dioError = DioException(
+
requestOptions: RequestOptions(path: '/test'),
+
);
+
+
final exception = ApiException.fromDioError(dioError);
+
+
expect(exception, isA<NetworkException>());
+
expect(exception.message, contains('Network error'));
+
});
+
});
+
+
group('VoteResponse', () {
+
test('should create response with uri, cid, and rkey', () {
+
const response = VoteResponse(
+
uri: 'at://vote/123',
+
cid: 'bafy123',
+
rkey: '123',
+
deleted: false,
+
);
+
+
expect(response.uri, 'at://vote/123');
+
expect(response.cid, 'bafy123');
+
expect(response.rkey, '123');
+
expect(response.deleted, false);
+
});
+
+
test('should create response with rkey extracted from uri', () {
+
const response = VoteResponse(
+
uri: 'at://vote/456',
+
cid: 'bafy456',
+
rkey: '456',
+
deleted: false,
+
);
+
+
expect(response.uri, 'at://vote/456');
+
expect(response.cid, 'bafy456');
+
expect(response.rkey, '456');
+
expect(response.deleted, false);
+
});
+
+
test('should create deleted response', () {
+
const response = VoteResponse(deleted: true);
+
+
expect(response.deleted, true);
+
expect(response.uri, null);
+
expect(response.cid, null);
+
expect(response.rkey, null);
+
});
+
});
+
});
+
}
+158
test/services/vote_service_test.mocks.dart
···
···
+
// Mocks generated by Mockito 5.4.6 from annotations
+
// in coves_flutter/test/services/vote_service_test.dart.
+
// Do not manually edit this file.
+
+
// ignore_for_file: no_leading_underscores_for_library_prefixes
+
import 'dart:async' as _i6;
+
+
import 'package:atproto_oauth_flutter/src/oauth/oauth_server_agent.dart' as _i2;
+
import 'package:atproto_oauth_flutter/src/session/oauth_session.dart' as _i3;
+
import 'package:http/http.dart' as _i4;
+
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
+
// 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 _FakeOAuthServerAgent_0 extends _i1.SmartFake
+
implements _i2.OAuthServerAgent {
+
_FakeOAuthServerAgent_0(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeSessionGetterInterface_1 extends _i1.SmartFake
+
implements _i3.SessionGetterInterface {
+
_FakeSessionGetterInterface_1(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeTokenInfo_2 extends _i1.SmartFake implements _i3.TokenInfo {
+
_FakeTokenInfo_2(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
class _FakeResponse_3 extends _i1.SmartFake implements _i4.Response {
+
_FakeResponse_3(Object parent, Invocation parentInvocation)
+
: super(parent, parentInvocation);
+
}
+
+
/// A class which mocks [OAuthSession].
+
///
+
/// See the documentation for Mockito's code generation for more information.
+
class MockOAuthSession extends _i1.Mock implements _i3.OAuthSession {
+
MockOAuthSession() {
+
_i1.throwOnMissingStub(this);
+
}
+
+
@override
+
_i2.OAuthServerAgent get server =>
+
(super.noSuchMethod(
+
Invocation.getter(#server),
+
returnValue: _FakeOAuthServerAgent_0(
+
this,
+
Invocation.getter(#server),
+
),
+
)
+
as _i2.OAuthServerAgent);
+
+
@override
+
String get sub =>
+
(super.noSuchMethod(
+
Invocation.getter(#sub),
+
returnValue: _i5.dummyValue<String>(this, Invocation.getter(#sub)),
+
)
+
as String);
+
+
@override
+
_i3.SessionGetterInterface get sessionGetter =>
+
(super.noSuchMethod(
+
Invocation.getter(#sessionGetter),
+
returnValue: _FakeSessionGetterInterface_1(
+
this,
+
Invocation.getter(#sessionGetter),
+
),
+
)
+
as _i3.SessionGetterInterface);
+
+
@override
+
String get did =>
+
(super.noSuchMethod(
+
Invocation.getter(#did),
+
returnValue: _i5.dummyValue<String>(this, Invocation.getter(#did)),
+
)
+
as String);
+
+
@override
+
Map<String, dynamic> get serverMetadata =>
+
(super.noSuchMethod(
+
Invocation.getter(#serverMetadata),
+
returnValue: <String, dynamic>{},
+
)
+
as Map<String, dynamic>);
+
+
@override
+
_i6.Future<_i3.TokenInfo> getTokenInfo([dynamic refresh = 'auto']) =>
+
(super.noSuchMethod(
+
Invocation.method(#getTokenInfo, [refresh]),
+
returnValue: _i6.Future<_i3.TokenInfo>.value(
+
_FakeTokenInfo_2(
+
this,
+
Invocation.method(#getTokenInfo, [refresh]),
+
),
+
),
+
)
+
as _i6.Future<_i3.TokenInfo>);
+
+
@override
+
_i6.Future<void> signOut() =>
+
(super.noSuchMethod(
+
Invocation.method(#signOut, []),
+
returnValue: _i6.Future<void>.value(),
+
returnValueForMissingStub: _i6.Future<void>.value(),
+
)
+
as _i6.Future<void>);
+
+
@override
+
_i6.Future<_i4.Response> fetchHandler(
+
String? pathname, {
+
String? method = 'GET',
+
Map<String, String>? headers,
+
dynamic body,
+
}) =>
+
(super.noSuchMethod(
+
Invocation.method(
+
#fetchHandler,
+
[pathname],
+
{#method: method, #headers: headers, #body: body},
+
),
+
returnValue: _i6.Future<_i4.Response>.value(
+
_FakeResponse_3(
+
this,
+
Invocation.method(
+
#fetchHandler,
+
[pathname],
+
{#method: method, #headers: headers, #body: body},
+
),
+
),
+
),
+
)
+
as _i6.Future<_i4.Response>);
+
+
@override
+
void dispose() => super.noSuchMethod(
+
Invocation.method(#dispose, []),
+
returnValueForMissingStub: null,
+
);
+
}
+333
test/widgets/animated_heart_icon_test.dart
···
···
+
import 'package:coves_flutter/widgets/icons/animated_heart_icon.dart';
+
import 'package:flutter/material.dart';
+
import 'package:flutter_test/flutter_test.dart';
+
+
void main() {
+
group('AnimatedHeartIcon', () {
+
testWidgets('should render with default size', (tester) async {
+
await tester.pumpWidget(
+
const MaterialApp(
+
home: Scaffold(
+
body: AnimatedHeartIcon(isLiked: false),
+
),
+
),
+
);
+
+
// Widget should render
+
expect(find.byType(AnimatedHeartIcon), findsOneWidget);
+
+
// 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
+
expect(sizedBox.width, 18);
+
expect(sizedBox.height, 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
+
expect(sizedBox.width, 32);
+
expect(sizedBox.height, 32);
+
});
+
+
testWidgets('should use custom color when provided', (tester) async {
+
const customColor = Colors.blue;
+
+
await tester.pumpWidget(
+
const MaterialApp(
+
home: Scaffold(
+
body: AnimatedHeartIcon(
+
isLiked: false,
+
color: customColor,
+
),
+
),
+
),
+
);
+
+
// Widget should render with custom color
+
expect(find.byType(AnimatedHeartIcon), findsOneWidget);
+
// Note: We can't easily verify the color without accessing the CustomPainter,
+
// but we can verify the widget accepts the parameter
+
});
+
+
testWidgets('should use custom liked color when provided', (tester) async {
+
const customLikedColor = Colors.pink;
+
+
await tester.pumpWidget(
+
const MaterialApp(
+
home: Scaffold(
+
body: AnimatedHeartIcon(
+
isLiked: true,
+
likedColor: customLikedColor,
+
),
+
),
+
),
+
);
+
+
// Widget should render with custom liked color
+
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),
+
),
+
),
+
);
+
+
// Verify initial state
+
expect(find.byType(AnimatedHeartIcon), findsOneWidget);
+
+
// Change to liked state
+
await tester.pumpWidget(
+
const MaterialApp(
+
home: Scaffold(
+
body: AnimatedHeartIcon(isLiked: true),
+
),
+
),
+
);
+
+
// Pump frames to allow animation to start
+
await tester.pump();
+
await tester.pump(const Duration(milliseconds: 100));
+
+
// Widget should still be present and animating
+
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),
+
),
+
),
+
);
+
+
await tester.pump();
+
+
// Change to unliked state
+
await tester.pumpWidget(
+
const MaterialApp(
+
home: Scaffold(
+
body: AnimatedHeartIcon(isLiked: false),
+
),
+
),
+
);
+
+
await tester.pump();
+
+
// Widget should update without error
+
expect(find.byType(AnimatedHeartIcon), findsOneWidget);
+
});
+
+
testWidgets('should complete animation after duration', (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),
+
),
+
),
+
);
+
+
// Pump through the entire animation duration (800ms)
+
await tester.pump();
+
await tester.pump(const Duration(milliseconds: 800));
+
await tester.pumpAndSettle();
+
+
// Widget should still be present after animation completes
+
expect(find.byType(AnimatedHeartIcon), findsOneWidget);
+
});
+
+
testWidgets('should handle rapid state changes', (tester) async {
+
// 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));
+
+
// Widget should handle rapid changes without error
+
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
+
expect(overflowBox.maxWidth, 18 * 2.5);
+
expect(overflowBox.maxHeight, 18 * 2.5);
+
});
+
+
testWidgets('should render CustomPaint for heart icon', (tester) async {
+
await tester.pumpWidget(
+
const MaterialApp(
+
home: Scaffold(
+
body: AnimatedHeartIcon(isLiked: false),
+
),
+
),
+
);
+
+
// Find the CustomPaint widget (used for rendering the heart)
+
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),
+
),
+
),
+
);
+
+
await tester.pump();
+
+
// Widget should render in liked state without animation
+
// (Animation only triggers on state change, not initial render)
+
expect(find.byType(AnimatedHeartIcon), findsOneWidget);
+
});
+
+
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
+
// (No assertions needed - test passes if no exception is thrown)
+
});
+
+
testWidgets('should rebuild when isLiked changes', (tester) async {
+
var buildCount = 0;
+
+
await tester.pumpWidget(
+
MaterialApp(
+
home: Scaffold(
+
body: Builder(
+
builder: (context) {
+
buildCount++;
+
return const AnimatedHeartIcon(isLiked: false);
+
},
+
),
+
),
+
),
+
);
+
+
final initialBuildCount = buildCount;
+
+
// Change isLiked state
+
await tester.pumpWidget(
+
MaterialApp(
+
home: Scaffold(
+
body: Builder(
+
builder: (context) {
+
buildCount++;
+
return const AnimatedHeartIcon(isLiked: true);
+
},
+
),
+
),
+
),
+
);
+
+
// Should rebuild
+
expect(buildCount, greaterThan(initialBuildCount));
+
});
+
});
+
}
+28
test/widgets/feed_screen_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/screens/home/feed_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
···
void setLoading({required bool value}) {
_isLoading = value;
notifyListeners();
}
}
···
group('FeedScreen Widget Tests', () {
late FakeAuthProvider fakeAuthProvider;
late FakeFeedProvider fakeFeedProvider;
setUp(() {
fakeAuthProvider = FakeAuthProvider();
fakeFeedProvider = FakeFeedProvider();
});
Widget createTestWidget() {
···
providers: [
ChangeNotifierProvider<AuthProvider>.value(value: fakeAuthProvider),
ChangeNotifierProvider<FeedProvider>.value(value: fakeFeedProvider),
],
child: const MaterialApp(home: FeedScreen()),
);
···
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/screens/home/feed_screen.dart';
+
import 'package:coves_flutter/services/vote_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
···
void setLoading({required bool value}) {
_isLoading = value;
+
notifyListeners();
+
}
+
}
+
+
// 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 = {};
+
+
@override
+
bool isLiked(String postUri) => _likes[postUri] ?? false;
+
+
void setLiked(String postUri, bool value) {
+
_likes[postUri] = value;
notifyListeners();
}
}
···
group('FeedScreen Widget Tests', () {
late FakeAuthProvider fakeAuthProvider;
late FakeFeedProvider fakeFeedProvider;
+
late FakeVoteProvider fakeVoteProvider;
setUp(() {
fakeAuthProvider = FakeAuthProvider();
fakeFeedProvider = FakeFeedProvider();
+
fakeVoteProvider = FakeVoteProvider();
});
Widget createTestWidget() {
···
providers: [
ChangeNotifierProvider<AuthProvider>.value(value: fakeAuthProvider),
ChangeNotifierProvider<FeedProvider>.value(value: fakeFeedProvider),
+
ChangeNotifierProvider<VoteProvider>.value(value: fakeVoteProvider),
],
child: const MaterialApp(home: FeedScreen()),
);
-252
test/widgets/feed_screen_test.mocks.dart
···
-
// Mocks generated by Mockito 5.4.6 from annotations
-
// in coves_flutter/test/widgets/feed_screen_test.dart.
-
// Do not manually edit this file.
-
-
// ignore_for_file: no_leading_underscores_for_library_prefixes
-
import 'dart:async' as _i3;
-
import 'dart:ui' as _i4;
-
-
import 'package:coves_flutter/models/post.dart' as _i6;
-
import 'package:coves_flutter/providers/auth_provider.dart' as _i2;
-
import 'package:coves_flutter/providers/feed_provider.dart' as _i5;
-
import 'package:mockito/mockito.dart' as _i1;
-
import 'package:mockito/src/dummies.dart' as _i7;
-
-
// 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
-
-
/// A class which mocks [AuthProvider].
-
///
-
/// See the documentation for Mockito's code generation for more information.
-
class MockAuthProvider extends _i1.Mock implements _i2.AuthProvider {
-
MockAuthProvider() {
-
_i1.throwOnMissingStub(this);
-
}
-
-
@override
-
bool get isAuthenticated =>
-
(super.noSuchMethod(
-
Invocation.getter(#isAuthenticated),
-
returnValue: false,
-
)
-
as bool);
-
-
@override
-
bool get isLoading =>
-
(super.noSuchMethod(Invocation.getter(#isLoading), returnValue: false)
-
as bool);
-
-
@override
-
bool get hasListeners =>
-
(super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false)
-
as bool);
-
-
@override
-
_i3.Future<String?> getAccessToken() =>
-
(super.noSuchMethod(
-
Invocation.method(#getAccessToken, []),
-
returnValue: _i3.Future<String?>.value(),
-
)
-
as _i3.Future<String?>);
-
-
@override
-
_i3.Future<void> initialize() =>
-
(super.noSuchMethod(
-
Invocation.method(#initialize, []),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@override
-
_i3.Future<void> signIn(String? handle) =>
-
(super.noSuchMethod(
-
Invocation.method(#signIn, [handle]),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@override
-
_i3.Future<void> signOut() =>
-
(super.noSuchMethod(
-
Invocation.method(#signOut, []),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@override
-
void clearError() => super.noSuchMethod(
-
Invocation.method(#clearError, []),
-
returnValueForMissingStub: null,
-
);
-
-
@override
-
void dispose() => super.noSuchMethod(
-
Invocation.method(#dispose, []),
-
returnValueForMissingStub: null,
-
);
-
-
@override
-
void addListener(_i4.VoidCallback? listener) => super.noSuchMethod(
-
Invocation.method(#addListener, [listener]),
-
returnValueForMissingStub: null,
-
);
-
-
@override
-
void removeListener(_i4.VoidCallback? listener) => super.noSuchMethod(
-
Invocation.method(#removeListener, [listener]),
-
returnValueForMissingStub: null,
-
);
-
-
@override
-
void notifyListeners() => super.noSuchMethod(
-
Invocation.method(#notifyListeners, []),
-
returnValueForMissingStub: null,
-
);
-
}
-
-
/// A class which mocks [FeedProvider].
-
///
-
/// See the documentation for Mockito's code generation for more information.
-
class MockFeedProvider extends _i1.Mock implements _i5.FeedProvider {
-
MockFeedProvider() {
-
_i1.throwOnMissingStub(this);
-
}
-
-
@override
-
List<_i6.FeedViewPost> get posts =>
-
(super.noSuchMethod(
-
Invocation.getter(#posts),
-
returnValue: <_i6.FeedViewPost>[],
-
)
-
as List<_i6.FeedViewPost>);
-
-
@override
-
bool get isLoading =>
-
(super.noSuchMethod(Invocation.getter(#isLoading), returnValue: false)
-
as bool);
-
-
@override
-
bool get isLoadingMore =>
-
(super.noSuchMethod(Invocation.getter(#isLoadingMore), returnValue: false)
-
as bool);
-
-
@override
-
bool get hasMore =>
-
(super.noSuchMethod(Invocation.getter(#hasMore), returnValue: false)
-
as bool);
-
-
@override
-
String get sort =>
-
(super.noSuchMethod(
-
Invocation.getter(#sort),
-
returnValue: _i7.dummyValue<String>(this, Invocation.getter(#sort)),
-
)
-
as String);
-
-
@override
-
bool get hasListeners =>
-
(super.noSuchMethod(Invocation.getter(#hasListeners), returnValue: false)
-
as bool);
-
-
@override
-
_i3.Future<void> loadFeed({bool? refresh = false}) =>
-
(super.noSuchMethod(
-
Invocation.method(#loadFeed, [], {#refresh: refresh}),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@override
-
_i3.Future<void> fetchTimeline({bool? refresh = false}) =>
-
(super.noSuchMethod(
-
Invocation.method(#fetchTimeline, [], {#refresh: refresh}),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@override
-
_i3.Future<void> fetchDiscover({bool? refresh = false}) =>
-
(super.noSuchMethod(
-
Invocation.method(#fetchDiscover, [], {#refresh: refresh}),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@override
-
_i3.Future<void> loadMore() =>
-
(super.noSuchMethod(
-
Invocation.method(#loadMore, []),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@override
-
void setSort(String? newSort, {String? newTimeframe}) => super.noSuchMethod(
-
Invocation.method(#setSort, [newSort], {#newTimeframe: newTimeframe}),
-
returnValueForMissingStub: null,
-
);
-
-
@override
-
_i3.Future<void> retry() =>
-
(super.noSuchMethod(
-
Invocation.method(#retry, []),
-
returnValue: _i3.Future<void>.value(),
-
returnValueForMissingStub: _i3.Future<void>.value(),
-
)
-
as _i3.Future<void>);
-
-
@override
-
void clearError() => super.noSuchMethod(
-
Invocation.method(#clearError, []),
-
returnValueForMissingStub: null,
-
);
-
-
@override
-
void reset() => super.noSuchMethod(
-
Invocation.method(#reset, []),
-
returnValueForMissingStub: null,
-
);
-
-
@override
-
void dispose() => super.noSuchMethod(
-
Invocation.method(#dispose, []),
-
returnValueForMissingStub: null,
-
);
-
-
@override
-
void addListener(_i4.VoidCallback? listener) => super.noSuchMethod(
-
Invocation.method(#addListener, [listener]),
-
returnValueForMissingStub: null,
-
);
-
-
@override
-
void removeListener(_i4.VoidCallback? listener) => super.noSuchMethod(
-
Invocation.method(#removeListener, [listener]),
-
returnValueForMissingStub: null,
-
);
-
-
@override
-
void notifyListeners() => super.noSuchMethod(
-
Invocation.method(#notifyListeners, []),
-
returnValueForMissingStub: null,
-
);
-
}
···
+231
test/widgets/sign_in_dialog_test.dart
···
···
+
import 'package:coves_flutter/widgets/sign_in_dialog.dart';
+
import 'package:flutter/material.dart';
+
import 'package:flutter_test/flutter_test.dart';
+
+
void main() {
+
group('SignInDialog', () {
+
testWidgets('should display default title and message', (tester) async {
+
await tester.pumpWidget(
+
MaterialApp(
+
home: Scaffold(
+
body: Builder(
+
builder: (context) {
+
return ElevatedButton(
+
onPressed: () => SignInDialog.show(context),
+
child: const Text('Show Dialog'),
+
);
+
},
+
),
+
),
+
),
+
);
+
+
// Tap button to show dialog
+
await tester.tap(find.text('Show Dialog'));
+
await tester.pumpAndSettle();
+
+
// Verify default title and message
+
expect(find.text('Sign in required'), findsOneWidget);
+
expect(
+
find.text('You need to sign in to interact with posts.'),
+
findsOneWidget,
+
);
+
});
+
+
testWidgets('should display custom title and message', (tester) async {
+
await tester.pumpWidget(
+
MaterialApp(
+
home: Scaffold(
+
body: Builder(
+
builder: (context) {
+
return ElevatedButton(
+
onPressed: () => SignInDialog.show(
+
context,
+
title: 'Custom Title',
+
message: 'Custom message here',
+
),
+
child: const Text('Show Dialog'),
+
);
+
},
+
),
+
),
+
),
+
);
+
+
// Tap button to show dialog
+
await tester.tap(find.text('Show Dialog'));
+
await tester.pumpAndSettle();
+
+
// Verify custom title and message
+
expect(find.text('Custom Title'), findsOneWidget);
+
expect(find.text('Custom message here'), findsOneWidget);
+
});
+
+
testWidgets('should have Cancel and Sign In buttons', (tester) async {
+
await tester.pumpWidget(
+
MaterialApp(
+
home: Scaffold(
+
body: Builder(
+
builder: (context) {
+
return ElevatedButton(
+
onPressed: () => SignInDialog.show(context),
+
child: const Text('Show Dialog'),
+
);
+
},
+
),
+
),
+
),
+
);
+
+
// Tap button to show dialog
+
await tester.tap(find.text('Show Dialog'));
+
await tester.pumpAndSettle();
+
+
// Verify buttons exist
+
expect(find.text('Cancel'), findsOneWidget);
+
expect(find.text('Sign In'), findsOneWidget);
+
});
+
+
testWidgets('should return false when Cancel is tapped', (tester) async {
+
bool? result;
+
+
await tester.pumpWidget(
+
MaterialApp(
+
home: Scaffold(
+
body: Builder(
+
builder: (context) {
+
return ElevatedButton(
+
onPressed: () async {
+
result = await SignInDialog.show(context);
+
},
+
child: const Text('Show Dialog'),
+
);
+
},
+
),
+
),
+
),
+
);
+
+
// Tap button to show dialog
+
await tester.tap(find.text('Show Dialog'));
+
await tester.pumpAndSettle();
+
+
// Tap Cancel button
+
await tester.tap(find.text('Cancel'));
+
await tester.pumpAndSettle();
+
+
// Verify result is false
+
expect(result, false);
+
+
// Dialog should be dismissed
+
expect(find.text('Sign in required'), findsNothing);
+
});
+
+
testWidgets('should return true when Sign In is tapped', (tester) async {
+
bool? result;
+
+
await tester.pumpWidget(
+
MaterialApp(
+
home: Scaffold(
+
body: Builder(
+
builder: (context) {
+
return ElevatedButton(
+
onPressed: () async {
+
result = await SignInDialog.show(context);
+
},
+
child: const Text('Show Dialog'),
+
);
+
},
+
),
+
),
+
),
+
);
+
+
// Tap button to show dialog
+
await tester.tap(find.text('Show Dialog'));
+
await tester.pumpAndSettle();
+
+
// Tap Sign In button
+
await tester.tap(find.text('Sign In'));
+
await tester.pumpAndSettle();
+
+
// Verify result is true
+
expect(result, true);
+
+
// Dialog should be dismissed
+
expect(find.text('Sign in required'), findsNothing);
+
});
+
+
testWidgets('should dismiss when tapped outside (barrier)', (tester) async {
+
bool? result;
+
+
await tester.pumpWidget(
+
MaterialApp(
+
home: Scaffold(
+
body: Builder(
+
builder: (context) {
+
return ElevatedButton(
+
onPressed: () async {
+
result = await SignInDialog.show(context);
+
},
+
child: const Text('Show Dialog'),
+
);
+
},
+
),
+
),
+
),
+
);
+
+
// Tap button to show dialog
+
await tester.tap(find.text('Show Dialog'));
+
await tester.pumpAndSettle();
+
+
// Tap outside the dialog (on the barrier)
+
await tester.tapAt(const Offset(10, 10));
+
await tester.pumpAndSettle();
+
+
// Verify result is null (dismissed without selecting an option)
+
expect(result, null);
+
+
// Dialog should be dismissed
+
expect(find.text('Sign in required'), findsNothing);
+
});
+
+
testWidgets('should use app colors for styling', (tester) async {
+
await tester.pumpWidget(
+
MaterialApp(
+
home: Scaffold(
+
body: Builder(
+
builder: (context) {
+
return ElevatedButton(
+
onPressed: () => SignInDialog.show(context),
+
child: const Text('Show Dialog'),
+
);
+
},
+
),
+
),
+
),
+
);
+
+
// Tap button to show dialog
+
await tester.tap(find.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);
+
+
// Find the Sign In button
+
final signInButton = tester.widget<ElevatedButton>(
+
find.widgetWithText(ElevatedButton, 'Sign In'),
+
);
+
+
// Verify button styling
+
expect(signInButton.style, isNotNull);
+
});
+
});
+
}