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 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}