Main coves client
1import 'package:dio/dio.dart';
2import 'package:flutter/foundation.dart';
3
4import '../config/environment_config.dart';
5import '../models/coves_session.dart';
6import 'api_exceptions.dart';
7
8/// Vote Service
9///
10/// Handles vote/like interactions through the Coves backend.
11///
12/// **Architecture with Backend OAuth**:
13/// With sealed tokens, the client cannot write directly to the user's PDS
14/// (no DPoP keys available). Instead, votes go through the Coves backend:
15///
16/// Mobile Client → Coves Backend (sealed token) → User's PDS (DPoP)
17///
18/// The backend:
19/// 1. Unseals the token to get the actual access/refresh tokens
20/// 2. Uses stored DPoP keys to sign requests
21/// 3. Writes to the user's PDS on their behalf
22///
23/// TODO: Backend vote endpoints need to be implemented:
24/// - POST /xrpc/social.coves.feed.vote.create
25/// - POST /xrpc/social.coves.feed.vote.delete
26/// - GET /xrpc/social.coves.feed.vote.list (or included in feed response)
27class VoteService {
28 VoteService({
29 Future<CovesSession?> Function()? sessionGetter,
30 String? Function()? didGetter,
31 Future<bool> Function()? tokenRefresher,
32 Future<void> Function()? signOutHandler,
33 Dio? dio,
34 }) : _sessionGetter = sessionGetter,
35 _didGetter = didGetter,
36 _tokenRefresher = tokenRefresher,
37 _signOutHandler = signOutHandler {
38 _dio = dio ??
39 Dio(
40 BaseOptions(
41 baseUrl: EnvironmentConfig.current.apiUrl,
42 connectTimeout: const Duration(seconds: 30),
43 receiveTimeout: const Duration(seconds: 30),
44 headers: {'Content-Type': 'application/json'},
45 ),
46 );
47
48 // Add 401 retry interceptor (same pattern as CovesApiService)
49 _dio.interceptors.add(
50 InterceptorsWrapper(
51 onRequest: (options, handler) async {
52 // Fetch fresh token before each request
53 final session = await _sessionGetter?.call();
54 if (session != null) {
55 options.headers['Authorization'] = 'Bearer ${session.token}';
56 if (kDebugMode) {
57 debugPrint('🔐 VoteService: Adding fresh Authorization header');
58 }
59 } else {
60 if (kDebugMode) {
61 debugPrint(
62 '⚠️ VoteService: Session getter returned null - '
63 'making unauthenticated request',
64 );
65 }
66 }
67 return handler.next(options);
68 },
69 onError: (error, handler) async {
70 // Handle 401 errors with automatic token refresh
71 if (error.response?.statusCode == 401 && _tokenRefresher != null) {
72 if (kDebugMode) {
73 debugPrint('🔄 VoteService: 401 detected, attempting token refresh...');
74 }
75
76 // Check if we already retried this request (prevent infinite loop)
77 if (error.requestOptions.extra['retried'] == true) {
78 if (kDebugMode) {
79 debugPrint(
80 '⚠️ VoteService: Request already retried after token refresh, '
81 'signing out user',
82 );
83 }
84 // Already retried once, don't retry again
85 if (_signOutHandler != null) {
86 await _signOutHandler();
87 }
88 return handler.next(error);
89 }
90
91 try {
92 // Attempt to refresh the token
93 final refreshSucceeded = await _tokenRefresher();
94
95 if (refreshSucceeded) {
96 if (kDebugMode) {
97 debugPrint('✅ VoteService: Token refresh successful, retrying request');
98 }
99
100 // Get the new session
101 final newSession = await _sessionGetter?.call();
102
103 if (newSession != null) {
104 // Mark this request as retried to prevent infinite loops
105 error.requestOptions.extra['retried'] = true;
106
107 // Update the Authorization header with the new token
108 error.requestOptions.headers['Authorization'] =
109 'Bearer ${newSession.token}';
110
111 // Retry the original request with the new token
112 try {
113 final response = await _dio.fetch(error.requestOptions);
114 return handler.resolve(response);
115 } on DioException catch (retryError) {
116 // If retry failed with 401 and already retried, we already
117 // signed out in the retry limit check above, so just pass
118 // the error through without signing out again
119 if (retryError.response?.statusCode == 401 &&
120 retryError.requestOptions.extra['retried'] == true) {
121 return handler.next(retryError);
122 }
123 // For other errors during retry, rethrow to outer catch
124 rethrow;
125 }
126 }
127 }
128
129 // Refresh failed, sign out the user
130 if (kDebugMode) {
131 debugPrint('❌ VoteService: Token refresh failed, signing out user');
132 }
133 if (_signOutHandler != null) {
134 await _signOutHandler();
135 }
136 } catch (e) {
137 if (kDebugMode) {
138 debugPrint('❌ VoteService: Error during token refresh: $e');
139 }
140 // Only sign out if we haven't already (avoid double sign-out)
141 // Check if this is a DioException from a retried request
142 final isRetriedRequest = e is DioException &&
143 e.response?.statusCode == 401 &&
144 e.requestOptions.extra['retried'] == true;
145
146 if (!isRetriedRequest && _signOutHandler != null) {
147 await _signOutHandler();
148 }
149 }
150 }
151
152 // Log the error for debugging
153 if (kDebugMode) {
154 debugPrint('❌ VoteService API Error: ${error.message}');
155 if (error.response != null) {
156 debugPrint(' Status: ${error.response?.statusCode}');
157 debugPrint(' Data: ${error.response?.data}');
158 }
159 }
160 return handler.next(error);
161 },
162 ),
163 );
164 }
165
166 final Future<CovesSession?> Function()? _sessionGetter;
167 final String? Function()? _didGetter;
168 final Future<bool> Function()? _tokenRefresher;
169 final Future<void> Function()? _signOutHandler;
170 late final Dio _dio;
171
172 /// Collection name for vote records
173 static const String voteCollection = 'social.coves.feed.vote';
174
175 /// Get all votes for the current user
176 ///
177 /// TODO: This needs a backend endpoint to list user's votes.
178 /// For now, returns empty map - votes will be fetched with feed data.
179 ///
180 /// Returns:
181 /// - `Map<String, VoteInfo>` where key is the post URI
182 /// - Empty map if not authenticated or no votes found
183 Future<Map<String, VoteInfo>> getUserVotes() async {
184 try {
185 final userDid = _didGetter?.call();
186 if (userDid == null || userDid.isEmpty) {
187 return {};
188 }
189
190 final session = await _sessionGetter?.call();
191 if (session == null) {
192 return {};
193 }
194
195 // TODO: Implement backend endpoint for listing user votes
196 // For now, vote state should come from feed responses
197 if (kDebugMode) {
198 debugPrint(
199 '⚠️ getUserVotes: Backend endpoint not yet implemented. '
200 'Vote state should come from feed responses.',
201 );
202 }
203
204 return {};
205 } on Exception catch (e) {
206 if (kDebugMode) {
207 debugPrint('⚠️ Failed to load user votes: $e');
208 }
209 return {};
210 }
211 }
212
213 /// Create or toggle vote
214 ///
215 /// Sends vote request to the Coves backend, which proxies to the user's PDS.
216 ///
217 /// Parameters:
218 /// - [postUri]: AT-URI of the post
219 /// - [postCid]: Content ID of the post (for strong reference)
220 /// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote
221 /// - [existingVoteRkey]: Optional rkey from cached state
222 /// - [existingVoteDirection]: Optional direction from cached state
223 ///
224 /// Returns:
225 /// - VoteResponse with uri/cid/rkey if created
226 /// - VoteResponse with deleted=true if toggled off
227 ///
228 /// Throws:
229 /// - ApiException for API errors
230 Future<VoteResponse> createVote({
231 required String postUri,
232 required String postCid,
233 String direction = 'up',
234 String? existingVoteRkey,
235 String? existingVoteDirection,
236 }) async {
237 try {
238 final userDid = _didGetter?.call();
239 final session = await _sessionGetter?.call();
240
241 if (userDid == null || userDid.isEmpty) {
242 throw ApiException('User not authenticated - no DID available');
243 }
244
245 if (session == null) {
246 throw ApiException('User not authenticated - no session available');
247 }
248
249 if (kDebugMode) {
250 debugPrint('🗳️ Creating vote via backend');
251 debugPrint(' Post: $postUri');
252 debugPrint(' Direction: $direction');
253 }
254
255 // Determine if this is a toggle (delete) or create
256 final isToggleOff =
257 existingVoteRkey != null && existingVoteDirection == direction;
258
259 if (isToggleOff) {
260 // Delete existing vote
261 return _deleteVote(
262 session: session,
263 rkey: existingVoteRkey,
264 );
265 }
266
267 // If switching direction, delete old vote first
268 if (existingVoteRkey != null && existingVoteDirection != null) {
269 if (kDebugMode) {
270 debugPrint(' Switching vote direction - deleting old vote first');
271 }
272 await _deleteVote(session: session, rkey: existingVoteRkey);
273 }
274
275 // Create new vote via backend
276 // Note: Authorization header is added by the interceptor
277 final response = await _dio.post<Map<String, dynamic>>(
278 '/xrpc/social.coves.feed.vote.create',
279 data: {
280 'subject': {
281 'uri': postUri,
282 'cid': postCid,
283 },
284 'direction': direction,
285 },
286 );
287
288 final data = response.data;
289 if (data == null) {
290 throw ApiException('Invalid response from server - no data');
291 }
292
293 final uri = data['uri'] as String?;
294 final cid = data['cid'] as String?;
295
296 if (uri == null || cid == null) {
297 throw ApiException('Invalid response from server - missing uri or cid');
298 }
299
300 // Extract rkey from URI
301 final rkey = uri.split('/').last;
302
303 if (kDebugMode) {
304 debugPrint('✅ Vote created: $uri');
305 }
306
307 return VoteResponse(uri: uri, cid: cid, rkey: rkey, deleted: false);
308 } on DioException catch (e) {
309 if (kDebugMode) {
310 debugPrint('❌ Vote failed: ${e.message}');
311 debugPrint(' Status: ${e.response?.statusCode}');
312 debugPrint(' Data: ${e.response?.data}');
313 }
314
315 if (e.response?.statusCode == 401) {
316 throw AuthenticationException(
317 'Authentication failed. Please sign in again.',
318 originalError: e,
319 );
320 }
321
322 throw ApiException(
323 'Failed to create vote: ${e.message}',
324 statusCode: e.response?.statusCode,
325 originalError: e,
326 );
327 } on Exception catch (e) {
328 throw ApiException('Failed to create vote: $e');
329 }
330 }
331
332 /// Delete vote via backend
333 Future<VoteResponse> _deleteVote({
334 required CovesSession session,
335 required String rkey,
336 }) async {
337 try {
338 // Note: Authorization header is added by the interceptor
339 await _dio.post<void>(
340 '/xrpc/social.coves.feed.vote.delete',
341 data: {
342 'rkey': rkey,
343 },
344 );
345
346 if (kDebugMode) {
347 debugPrint('✅ Vote deleted');
348 }
349
350 return const VoteResponse(deleted: true);
351 } on DioException catch (e) {
352 if (kDebugMode) {
353 debugPrint('❌ Delete vote failed: ${e.message}');
354 }
355
356 throw ApiException(
357 'Failed to delete vote: ${e.message}',
358 statusCode: e.response?.statusCode,
359 originalError: e,
360 );
361 }
362 }
363}
364
365/// Vote Response
366///
367/// Response from createVote operation.
368class VoteResponse {
369 const VoteResponse({this.uri, this.cid, this.rkey, required this.deleted});
370
371 /// AT-URI of the created vote record
372 final String? uri;
373
374 /// Content ID of the vote record
375 final String? cid;
376
377 /// Record key (rkey) of the vote - last segment of URI
378 final String? rkey;
379
380 /// Whether the vote was deleted (toggled off)
381 final bool deleted;
382}
383
384/// Existing Vote
385///
386/// Represents a vote that already exists on the PDS.
387class ExistingVote {
388 const ExistingVote({required this.direction, required this.rkey});
389
390 /// Vote direction ("up" or "down")
391 final String direction;
392
393 /// Record key for deletion
394 final String rkey;
395}
396
397/// Vote Info
398///
399/// Information about a user's vote on a post, returned from getUserVotes().
400class VoteInfo {
401 const VoteInfo({
402 required this.direction,
403 required this.voteUri,
404 required this.rkey,
405 });
406
407 /// Vote direction ("up" or "down")
408 final String direction;
409
410 /// AT-URI of the vote record
411 final String voteUri;
412
413 /// Record key (rkey) - last segment of URI
414 final String rkey;
415}