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