1import 'package:flutter/foundation.dart'; 2 3import '../services/api_exceptions.dart'; 4import '../services/vote_service.dart' show VoteService, VoteInfo; 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 int 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( 157 direction: direction, 158 deleted: false, 159 ); 160 } 161 162 // Apply score adjustment 163 _scoreAdjustments[postUri] = newAdjustment; 164 notifyListeners(); 165 166 // Mark request as pending 167 _pendingRequests[postUri] = true; 168 169 try { 170 // Make API call - pass existing vote info to avoid O(n) PDS lookup 171 final response = await _voteService.createVote( 172 postUri: postUri, 173 postCid: postCid, 174 direction: direction, 175 existingVoteRkey: currentState?.rkey, 176 existingVoteDirection: currentState?.direction, 177 ); 178 179 // Update with server response 180 if (response.deleted) { 181 // Vote was removed 182 _votes[postUri] = VoteState( 183 direction: direction, 184 deleted: true, 185 ); 186 } else { 187 // Vote was created or updated 188 _votes[postUri] = VoteState( 189 direction: direction, 190 uri: response.uri, 191 rkey: response.rkey, 192 deleted: false, 193 ); 194 } 195 196 notifyListeners(); 197 return !response.deleted; 198 } on ApiException catch (e) { 199 if (kDebugMode) { 200 debugPrint('❌ Failed to toggle vote: ${e.message}'); 201 } 202 203 // Rollback optimistic update 204 if (previousState != null) { 205 _votes[postUri] = previousState; 206 } else { 207 _votes.remove(postUri); 208 } 209 210 // Rollback score adjustment 211 if (previousAdjustment != 0) { 212 _scoreAdjustments[postUri] = previousAdjustment; 213 } else { 214 _scoreAdjustments.remove(postUri); 215 } 216 217 notifyListeners(); 218 219 rethrow; 220 } finally { 221 _pendingRequests.remove(postUri); 222 } 223 } 224 225 /// Initialize vote state from post data 226 /// 227 /// Call this when loading posts to populate initial vote state 228 /// from the backend's viewer state. 229 /// 230 /// Parameters: 231 /// - [postUri]: AT-URI of the post 232 /// - [voteDirection]: Current vote direction ("up", "down", or null) 233 /// - [voteUri]: AT-URI of the vote record 234 void setInitialVoteState({ 235 required String postUri, 236 String? voteDirection, 237 String? voteUri, 238 }) { 239 if (voteDirection != null) { 240 // Extract rkey from vote URI if available 241 // URI format: at://did:plc:xyz/social.coves.feed.vote/3kby... 242 String? rkey; 243 if (voteUri != null) { 244 final parts = voteUri.split('/'); 245 if (parts.isNotEmpty) { 246 rkey = parts.last; 247 } 248 } 249 250 _votes[postUri] = VoteState( 251 direction: voteDirection, 252 uri: voteUri, 253 rkey: rkey, 254 deleted: false, 255 ); 256 } else { 257 _votes.remove(postUri); 258 } 259 // Don't notify listeners - this is just initial state 260 } 261 262 /// Load initial vote states from a map of votes 263 /// 264 /// This is used to bulk-load vote state after querying the user's PDS. 265 /// Typically called after loading feed posts to fill in which posts 266 /// the user has voted on. 267 /// 268 /// IMPORTANT: This clears score adjustments since the server score 269 /// already reflects the loaded votes. If we kept stale adjustments, 270 /// we'd double-count votes (server score + our adjustment). 271 /// 272 /// Parameters: 273 /// - [votes]: Map of post URI -> vote info from VoteService.getUserVotes() 274 void loadInitialVotes(Map<String, VoteInfo> votes) { 275 for (final entry in votes.entries) { 276 final postUri = entry.key; 277 final voteInfo = entry.value; 278 279 _votes[postUri] = VoteState( 280 direction: voteInfo.direction, 281 uri: voteInfo.voteUri, 282 rkey: voteInfo.rkey, 283 deleted: false, 284 ); 285 286 // Clear any stale score adjustments for this post 287 // The server score already includes this vote 288 _scoreAdjustments.remove(postUri); 289 } 290 291 if (kDebugMode) { 292 debugPrint('📊 Initialized ${votes.length} vote states'); 293 } 294 295 // Notify once after loading all votes 296 notifyListeners(); 297 } 298 299 /// Clear all vote state (e.g., on sign out) 300 void clear() { 301 _votes.clear(); 302 _pendingRequests.clear(); 303 _scoreAdjustments.clear(); 304 notifyListeners(); 305 } 306} 307 308/// Vote State 309/// 310/// Represents the current vote state for a post. 311class VoteState { 312 const VoteState({ 313 required this.direction, 314 this.uri, 315 this.rkey, 316 required this.deleted, 317 }); 318 319 /// Vote direction ("up" or "down") 320 final String direction; 321 322 /// AT-URI of the vote record (null if not yet created) 323 final String? uri; 324 325 /// Record key (rkey) of the vote - needed for deletion 326 /// This is the last segment of the AT-URI (e.g., "3kby..." from 327 /// "at://did:plc:xyz/social.coves.feed.vote/3kby...") 328 final String? rkey; 329 330 /// Whether the vote has been deleted 331 final bool deleted; 332}