1import 'package:flutter/foundation.dart'; 2 3import '../services/api_exceptions.dart'; 4import '../services/vote_service.dart' show VoteService; 5import 'auth_provider.dart'; 6 7/// Vote Provider 8/// 9/// Manages vote state with optimistic UI updates. 10/// Tracks local vote state keyed by post URI for instant feedback. 11/// Automatically clears state when user signs out. 12class VoteProvider with ChangeNotifier { 13 VoteProvider({ 14 required VoteService voteService, 15 required AuthProvider authProvider, 16 }) : _voteService = voteService, 17 _authProvider = authProvider { 18 // Listen to auth state changes and clear votes on sign-out 19 _authProvider.addListener(_onAuthChanged); 20 } 21 22 @override 23 void dispose() { 24 _authProvider.removeListener(_onAuthChanged); 25 super.dispose(); 26 } 27 28 void _onAuthChanged() { 29 // Clear vote state when user signs out 30 if (!_authProvider.isAuthenticated) { 31 if (_votes.isNotEmpty) { 32 clear(); 33 if (kDebugMode) { 34 debugPrint('🧹 Cleared vote state on sign-out'); 35 } 36 } 37 } 38 } 39 40 final VoteService _voteService; 41 final AuthProvider _authProvider; 42 43 // Map of post URI -> vote state 44 final Map<String, VoteState> _votes = {}; 45 46 // Map of post URI -> in-flight request flag 47 final Map<String, bool> _pendingRequests = {}; 48 49 // Map of post URI -> score adjustment (for optimistic UI updates) 50 // Tracks the local delta from the server's score 51 final Map<String, int> _scoreAdjustments = {}; 52 53 /// Get vote state for a post 54 VoteState? getVoteState(String postUri) => _votes[postUri]; 55 56 /// Check if a post is liked/upvoted 57 bool isLiked(String postUri) => 58 _votes[postUri]?.direction == 'up' && 59 !(_votes[postUri]?.deleted ?? false); 60 61 /// Check if a request is pending for a post 62 bool isPending(String postUri) => _pendingRequests[postUri] ?? false; 63 64 /// Get adjusted score for a post (server score + local optimistic adjustment) 65 /// 66 /// This allows the UI to show immediate feedback when users vote, even before 67 /// the backend processes the vote and returns updated counts. 68 /// 69 /// Parameters: 70 /// - [postUri]: AT-URI of the post 71 /// - [serverScore]: The score from the server (upvotes - downvotes) 72 /// 73 /// Returns: The adjusted score based on local vote state 74 int getAdjustedScore(String postUri, int serverScore) { 75 final adjustment = _scoreAdjustments[postUri] ?? 0; 76 return serverScore + adjustment; 77 } 78 79 /// Toggle vote (like/unlike) 80 /// 81 /// Uses optimistic updates: 82 /// 1. Immediately updates local state 83 /// 2. Makes API call 84 /// 3. Reverts on error 85 /// 86 /// Parameters: 87 /// - [postUri]: AT-URI of the post 88 /// - [postCid]: Content ID of the post (for strong reference) 89 /// - [direction]: Vote direction (defaults to "up" for like) 90 /// 91 /// Returns: 92 /// - true if vote was created 93 /// - false if vote was removed (toggled off) 94 /// 95 /// Throws: 96 /// - ApiException if the request fails 97 Future<bool> toggleVote({ 98 required String postUri, 99 required String postCid, 100 String direction = 'up', 101 }) async { 102 // Prevent concurrent requests for the same post 103 if (_pendingRequests[postUri] ?? false) { 104 if (kDebugMode) { 105 debugPrint('⚠️ Vote request already in progress for $postUri'); 106 } 107 return false; 108 } 109 110 // Save current state for rollback on error 111 final previousState = _votes[postUri]; 112 final previousAdjustment = _scoreAdjustments[postUri] ?? 0; 113 final currentState = previousState; 114 115 // Calculate score adjustment for optimistic update 116 var newAdjustment = previousAdjustment; 117 118 if (currentState?.direction == direction && 119 !(currentState?.deleted ?? false)) { 120 // Toggle off - removing vote 121 if (direction == 'up') { 122 newAdjustment -= 1; // Remove upvote 123 } else { 124 newAdjustment += 1; // Remove downvote 125 } 126 } else if (currentState?.direction != null && 127 currentState?.direction != direction && 128 !(currentState?.deleted ?? false)) { 129 // Switching vote direction 130 if (direction == 'up') { 131 newAdjustment += 2; // Remove downvote (-1) and add upvote (+1) 132 } else { 133 newAdjustment -= 2; // Remove upvote (-1) and add downvote (+1) 134 } 135 } else { 136 // Creating new vote (or re-creating after delete) 137 if (direction == 'up') { 138 newAdjustment += 1; // Add upvote 139 } else { 140 newAdjustment -= 1; // Add downvote 141 } 142 } 143 144 // Optimistic update 145 if (currentState?.direction == direction && 146 !(currentState?.deleted ?? false)) { 147 // Toggle off - mark as deleted 148 _votes[postUri] = VoteState( 149 direction: direction, 150 uri: currentState?.uri, 151 rkey: currentState?.rkey, 152 deleted: true, 153 ); 154 } else { 155 // Create or switch direction 156 _votes[postUri] = VoteState(direction: direction, deleted: false); 157 } 158 159 // Apply score adjustment 160 _scoreAdjustments[postUri] = newAdjustment; 161 notifyListeners(); 162 163 // Mark request as pending 164 _pendingRequests[postUri] = true; 165 166 try { 167 // Make API call 168 final response = await _voteService.createVote( 169 postUri: postUri, 170 postCid: postCid, 171 direction: direction, 172 ); 173 174 // Update with server response 175 if (response.deleted) { 176 // Vote was removed 177 _votes[postUri] = VoteState(direction: direction, deleted: true); 178 } else { 179 // Vote was created or updated 180 _votes[postUri] = VoteState( 181 direction: direction, 182 uri: response.uri, 183 rkey: response.rkey, 184 deleted: false, 185 ); 186 } 187 188 notifyListeners(); 189 return !response.deleted; 190 } on ApiException catch (e) { 191 if (kDebugMode) { 192 debugPrint('❌ Failed to toggle vote: ${e.message}'); 193 } 194 195 // Rollback optimistic update 196 if (previousState != null) { 197 _votes[postUri] = previousState; 198 } else { 199 _votes.remove(postUri); 200 } 201 202 // Rollback score adjustment 203 if (previousAdjustment != 0) { 204 _scoreAdjustments[postUri] = previousAdjustment; 205 } else { 206 _scoreAdjustments.remove(postUri); 207 } 208 209 notifyListeners(); 210 211 rethrow; 212 } finally { 213 _pendingRequests.remove(postUri); 214 } 215 } 216 217 /// Initialize vote state from post data 218 /// 219 /// Call this when loading posts to populate initial vote state 220 /// from the backend's viewer state. 221 /// 222 /// Parameters: 223 /// - [postUri]: AT-URI of the post 224 /// - [voteDirection]: Current vote direction ("up", "down", or null) 225 /// - [voteUri]: AT-URI of the vote record 226 void setInitialVoteState({ 227 required String postUri, 228 String? voteDirection, 229 String? voteUri, 230 }) { 231 if (voteDirection != null) { 232 _votes[postUri] = VoteState( 233 direction: voteDirection, 234 uri: voteUri, 235 rkey: VoteState.extractRkeyFromUri(voteUri), 236 deleted: false, 237 ); 238 } else { 239 _votes.remove(postUri); 240 } 241 242 // IMPORTANT: Clear any stale score adjustment for this post. 243 // When we receive fresh data from the server (via feed/comments refresh), 244 // the server's score already reflects the actual vote state. Any local 245 // delta from a previous optimistic update is now stale and would cause 246 // double-counting (e.g., server score already includes +1, plus our +1). 247 _scoreAdjustments.remove(postUri); 248 249 // Don't notify listeners - this is just initial state 250 } 251 252 /// Clear all vote state (e.g., on sign out) 253 void clear() { 254 _votes.clear(); 255 _pendingRequests.clear(); 256 _scoreAdjustments.clear(); 257 notifyListeners(); 258 } 259} 260 261/// Vote State 262/// 263/// Represents the current vote state for a post. 264class VoteState { 265 const VoteState({ 266 required this.direction, 267 this.uri, 268 this.rkey, 269 required this.deleted, 270 }); 271 272 /// Vote direction ("up" or "down") 273 final String direction; 274 275 /// AT-URI of the vote record (null if not yet created) 276 final String? uri; 277 278 /// Record key (rkey) of the vote - needed for deletion 279 /// This is the last segment of the AT-URI (e.g., "3kby..." from 280 /// "at://did:plc:xyz/social.coves.feed.vote/3kby...") 281 final String? rkey; 282 283 /// Whether the vote has been deleted 284 final bool deleted; 285 286 /// Extract rkey (record key) from an AT-URI 287 /// 288 /// AT-URI format: at://did:plc:xyz/social.coves.feed.vote/3kby... 289 /// Returns the last segment (rkey) or null if URI is null/invalid. 290 static String? extractRkeyFromUri(String? uri) { 291 if (uri == null) return null; 292 final parts = uri.split('/'); 293 return parts.isNotEmpty ? parts.last : null; 294 } 295}