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