1import 'package:flutter/foundation.dart'; 2 3import '../services/api_exceptions.dart'; 4import '../services/vote_service.dart'; 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 /// Get vote state for a post 50 VoteState? getVoteState(String postUri) => _votes[postUri]; 51 52 /// Check if a post is liked/upvoted 53 bool isLiked(String postUri) => 54 _votes[postUri]?.direction == 'up' && 55 !(_votes[postUri]?.deleted ?? false); 56 57 /// Check if a request is pending for a post 58 bool isPending(String postUri) => _pendingRequests[postUri] ?? false; 59 60 /// Toggle vote (like/unlike) 61 /// 62 /// Uses optimistic updates: 63 /// 1. Immediately updates local state 64 /// 2. Makes API call 65 /// 3. Reverts on error 66 /// 67 /// Parameters: 68 /// - [postUri]: AT-URI of the post 69 /// - [postCid]: Content ID of the post (for strong reference) 70 /// - [direction]: Vote direction (defaults to "up" for like) 71 /// 72 /// Returns: 73 /// - true if vote was created 74 /// - false if vote was removed (toggled off) 75 /// 76 /// Throws: 77 /// - ApiException if the request fails 78 Future<bool> toggleVote({ 79 required String postUri, 80 required String postCid, 81 String direction = 'up', 82 }) async { 83 // Prevent concurrent requests for the same post 84 if (_pendingRequests[postUri] ?? false) { 85 if (kDebugMode) { 86 debugPrint('⚠️ Vote request already in progress for $postUri'); 87 } 88 return false; 89 } 90 91 // Save current state for rollback on error 92 final previousState = _votes[postUri]; 93 final currentState = previousState; 94 95 // Optimistic update 96 if (currentState?.direction == direction && 97 !(currentState?.deleted ?? false)) { 98 // Toggle off - mark as deleted 99 _votes[postUri] = VoteState( 100 direction: direction, 101 uri: currentState?.uri, 102 rkey: currentState?.rkey, 103 deleted: true, 104 ); 105 } else { 106 // Create or switch direction 107 _votes[postUri] = VoteState( 108 direction: direction, 109 deleted: false, 110 ); 111 } 112 notifyListeners(); 113 114 // Mark request as pending 115 _pendingRequests[postUri] = true; 116 117 try { 118 // Make API call 119 final response = await _voteService.createVote( 120 postUri: postUri, 121 postCid: postCid, 122 direction: direction, 123 ); 124 125 // Update with server response 126 if (response.deleted) { 127 // Vote was removed 128 _votes[postUri] = VoteState( 129 direction: direction, 130 deleted: true, 131 ); 132 } else { 133 // Vote was created or updated 134 _votes[postUri] = VoteState( 135 direction: direction, 136 uri: response.uri, 137 rkey: response.rkey, 138 deleted: false, 139 ); 140 } 141 142 notifyListeners(); 143 return !response.deleted; 144 } on ApiException catch (e) { 145 if (kDebugMode) { 146 debugPrint('❌ Failed to toggle vote: ${e.message}'); 147 } 148 149 // Rollback optimistic update 150 if (previousState != null) { 151 _votes[postUri] = previousState; 152 } else { 153 _votes.remove(postUri); 154 } 155 notifyListeners(); 156 157 rethrow; 158 } finally { 159 _pendingRequests.remove(postUri); 160 } 161 } 162 163 /// Initialize vote state from post data 164 /// 165 /// Call this when loading posts to populate initial vote state 166 /// from the backend's viewer state. 167 /// 168 /// Parameters: 169 /// - [postUri]: AT-URI of the post 170 /// - [voteDirection]: Current vote direction ("up", "down", or null) 171 /// - [voteUri]: AT-URI of the vote record 172 void setInitialVoteState({ 173 required String postUri, 174 String? voteDirection, 175 String? voteUri, 176 }) { 177 if (voteDirection != null) { 178 // Extract rkey from vote URI if available 179 // URI format: at://did:plc:xyz/social.coves.interaction.vote/3kby... 180 String? rkey; 181 if (voteUri != null) { 182 final parts = voteUri.split('/'); 183 if (parts.isNotEmpty) { 184 rkey = parts.last; 185 } 186 } 187 188 _votes[postUri] = VoteState( 189 direction: voteDirection, 190 uri: voteUri, 191 rkey: rkey, 192 deleted: false, 193 ); 194 } else { 195 _votes.remove(postUri); 196 } 197 // Don't notify listeners - this is just initial state 198 } 199 200 /// Clear all vote state (e.g., on sign out) 201 void clear() { 202 _votes.clear(); 203 _pendingRequests.clear(); 204 notifyListeners(); 205 } 206} 207 208/// Vote State 209/// 210/// Represents the current vote state for a post. 211class VoteState { 212 const VoteState({ 213 required this.direction, 214 this.uri, 215 this.rkey, 216 required this.deleted, 217 }); 218 219 /// Vote direction ("up" or "down") 220 final String direction; 221 222 /// AT-URI of the vote record (null if not yet created) 223 final String? uri; 224 225 /// Record key (rkey) of the vote - needed for deletion 226 /// This is the last segment of the AT-URI (e.g., "3kby..." from 227 /// "at://did:plc:xyz/social.coves.interaction.vote/3kby...") 228 final String? rkey; 229 230 /// Whether the vote has been deleted 231 final bool deleted; 232}