Main coves client
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}