Main coves client
1import 'package:flutter/foundation.dart';
2
3import '../services/api_exceptions.dart';
4import '../services/vote_service.dart' show VoteService;
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
168 final response = await _voteService.createVote(
169 postUri: postUri,
170 postCid: postCid,
171 direction: direction,
172 );
173
174 // Update with server response
175 if (response.deleted) {
176 // Vote was removed
177 _votes[postUri] = VoteState(direction: direction, deleted: true);
178 } else {
179 // Vote was created or updated
180 _votes[postUri] = VoteState(
181 direction: direction,
182 uri: response.uri,
183 rkey: response.rkey,
184 deleted: false,
185 );
186 }
187
188 notifyListeners();
189 return !response.deleted;
190 } on ApiException catch (e) {
191 if (kDebugMode) {
192 debugPrint('❌ Failed to toggle vote: ${e.message}');
193 }
194
195 // Rollback optimistic update
196 if (previousState != null) {
197 _votes[postUri] = previousState;
198 } else {
199 _votes.remove(postUri);
200 }
201
202 // Rollback score adjustment
203 if (previousAdjustment != 0) {
204 _scoreAdjustments[postUri] = previousAdjustment;
205 } else {
206 _scoreAdjustments.remove(postUri);
207 }
208
209 notifyListeners();
210
211 rethrow;
212 } finally {
213 _pendingRequests.remove(postUri);
214 }
215 }
216
217 /// Initialize vote state from post data
218 ///
219 /// Call this when loading posts to populate initial vote state
220 /// from the backend's viewer state.
221 ///
222 /// Parameters:
223 /// - [postUri]: AT-URI of the post
224 /// - [voteDirection]: Current vote direction ("up", "down", or null)
225 /// - [voteUri]: AT-URI of the vote record
226 void setInitialVoteState({
227 required String postUri,
228 String? voteDirection,
229 String? voteUri,
230 }) {
231 if (voteDirection != null) {
232 _votes[postUri] = VoteState(
233 direction: voteDirection,
234 uri: voteUri,
235 rkey: VoteState.extractRkeyFromUri(voteUri),
236 deleted: false,
237 );
238 } else {
239 _votes.remove(postUri);
240 }
241
242 // IMPORTANT: Clear any stale score adjustment for this post.
243 // When we receive fresh data from the server (via feed/comments refresh),
244 // the server's score already reflects the actual vote state. Any local
245 // delta from a previous optimistic update is now stale and would cause
246 // double-counting (e.g., server score already includes +1, plus our +1).
247 _scoreAdjustments.remove(postUri);
248
249 // Don't notify listeners - this is just initial state
250 }
251
252 /// Clear all vote state (e.g., on sign out)
253 void clear() {
254 _votes.clear();
255 _pendingRequests.clear();
256 _scoreAdjustments.clear();
257 notifyListeners();
258 }
259}
260
261/// Vote State
262///
263/// Represents the current vote state for a post.
264class VoteState {
265 const VoteState({
266 required this.direction,
267 this.uri,
268 this.rkey,
269 required this.deleted,
270 });
271
272 /// Vote direction ("up" or "down")
273 final String direction;
274
275 /// AT-URI of the vote record (null if not yet created)
276 final String? uri;
277
278 /// Record key (rkey) of the vote - needed for deletion
279 /// This is the last segment of the AT-URI (e.g., "3kby..." from
280 /// "at://did:plc:xyz/social.coves.feed.vote/3kby...")
281 final String? rkey;
282
283 /// Whether the vote has been deleted
284 final bool deleted;
285
286 /// Extract rkey (record key) from an AT-URI
287 ///
288 /// AT-URI format: at://did:plc:xyz/social.coves.feed.vote/3kby...
289 /// Returns the last segment (rkey) or null if URI is null/invalid.
290 static String? extractRkeyFromUri(String? uri) {
291 if (uri == null) return null;
292 final parts = uri.split('/');
293 return parts.isNotEmpty ? parts.last : null;
294 }
295}