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 =
39 dio ??
40 Dio(
41 BaseOptions(
42 baseUrl: EnvironmentConfig.current.apiUrl,
43 connectTimeout: const Duration(seconds: 30),
44 receiveTimeout: const Duration(seconds: 30),
45 headers: {'Content-Type': 'application/json'},
46 ),
47 );
48
49 // Add 401 retry interceptor (same pattern as CovesApiService)
50 _dio.interceptors.add(
51 InterceptorsWrapper(
52 onRequest: (options, handler) async {
53 // Fetch fresh token before each request
54 final session = await _sessionGetter?.call();
55 if (session != null) {
56 options.headers['Authorization'] = 'Bearer ${session.token}';
57 if (kDebugMode) {
58 debugPrint('🔐 VoteService: Adding fresh Authorization header');
59 }
60 } else {
61 if (kDebugMode) {
62 debugPrint(
63 '⚠️ VoteService: Session getter returned null - '
64 'making unauthenticated request',
65 );
66 }
67 }
68 return handler.next(options);
69 },
70 onError: (error, handler) async {
71 // Handle 401 errors with automatic token refresh
72 if (error.response?.statusCode == 401 && _tokenRefresher != null) {
73 if (kDebugMode) {
74 debugPrint(
75 '🔄 VoteService: 401 detected, attempting token refresh...',
76 );
77 }
78
79 // Check if we already retried this request (prevent infinite loop)
80 if (error.requestOptions.extra['retried'] == true) {
81 if (kDebugMode) {
82 debugPrint(
83 '⚠️ VoteService: Request already retried after token refresh, '
84 'signing out user',
85 );
86 }
87 // Already retried once, don't retry again
88 if (_signOutHandler != null) {
89 await _signOutHandler();
90 }
91 return handler.next(error);
92 }
93
94 try {
95 // Attempt to refresh the token
96 final refreshSucceeded = await _tokenRefresher();
97
98 if (refreshSucceeded) {
99 if (kDebugMode) {
100 debugPrint(
101 '✅ VoteService: Token refresh successful, retrying request',
102 );
103 }
104
105 // Get the new session
106 final newSession = await _sessionGetter?.call();
107
108 if (newSession != null) {
109 // Mark this request as retried to prevent infinite loops
110 error.requestOptions.extra['retried'] = true;
111
112 // Update the Authorization header with the new token
113 error.requestOptions.headers['Authorization'] =
114 'Bearer ${newSession.token}';
115
116 // Retry the original request with the new token
117 try {
118 final response = await _dio.fetch(error.requestOptions);
119 return handler.resolve(response);
120 } on DioException catch (retryError) {
121 // If retry failed with 401 and already retried, we already
122 // signed out in the retry limit check above, so just pass
123 // the error through without signing out again
124 if (retryError.response?.statusCode == 401 &&
125 retryError.requestOptions.extra['retried'] == true) {
126 return handler.next(retryError);
127 }
128 // For other errors during retry, rethrow to outer catch
129 rethrow;
130 }
131 }
132 }
133
134 // Refresh failed, sign out the user
135 if (kDebugMode) {
136 debugPrint(
137 '❌ VoteService: Token refresh failed, signing out user',
138 );
139 }
140 if (_signOutHandler != null) {
141 await _signOutHandler();
142 }
143 } catch (e) {
144 if (kDebugMode) {
145 debugPrint('❌ VoteService: Error during token refresh: $e');
146 }
147 // Only sign out if we haven't already (avoid double sign-out)
148 // Check if this is a DioException from a retried request
149 final isRetriedRequest =
150 e is DioException &&
151 e.response?.statusCode == 401 &&
152 e.requestOptions.extra['retried'] == true;
153
154 if (!isRetriedRequest && _signOutHandler != null) {
155 await _signOutHandler();
156 }
157 }
158 }
159
160 // Log the error for debugging
161 if (kDebugMode) {
162 debugPrint('❌ VoteService API Error: ${error.message}');
163 if (error.response != null) {
164 debugPrint(' Status: ${error.response?.statusCode}');
165 debugPrint(' Data: ${error.response?.data}');
166 }
167 }
168 return handler.next(error);
169 },
170 ),
171 );
172 }
173
174 final Future<CovesSession?> Function()? _sessionGetter;
175 final String? Function()? _didGetter;
176 final Future<bool> Function()? _tokenRefresher;
177 final Future<void> Function()? _signOutHandler;
178 late final Dio _dio;
179
180 /// Collection name for vote records
181 static const String voteCollection = 'social.coves.feed.vote';
182
183 /// Get all votes for the current user
184 ///
185 /// TODO: This needs a backend endpoint to list user's votes.
186 /// For now, returns empty map - votes will be fetched with feed data.
187 ///
188 /// Returns:
189 /// - `Map<String, VoteInfo>` where key is the post URI
190 /// - Empty map if not authenticated or no votes found
191 Future<Map<String, VoteInfo>> getUserVotes() async {
192 try {
193 final userDid = _didGetter?.call();
194 if (userDid == null || userDid.isEmpty) {
195 return {};
196 }
197
198 final session = await _sessionGetter?.call();
199 if (session == null) {
200 return {};
201 }
202
203 // TODO: Implement backend endpoint for listing user votes
204 // For now, vote state should come from feed responses
205 if (kDebugMode) {
206 debugPrint(
207 '⚠️ getUserVotes: Backend endpoint not yet implemented. '
208 'Vote state should come from feed responses.',
209 );
210 }
211
212 return {};
213 } on Exception catch (e) {
214 if (kDebugMode) {
215 debugPrint('⚠️ Failed to load user votes: $e');
216 }
217 return {};
218 }
219 }
220
221 /// Create or toggle vote
222 ///
223 /// Sends vote request to the Coves backend, which proxies to the user's PDS.
224 ///
225 /// Parameters:
226 /// - [postUri]: AT-URI of the post
227 /// - [postCid]: Content ID of the post (for strong reference)
228 /// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote
229 /// - [existingVoteRkey]: Optional rkey from cached state
230 /// - [existingVoteDirection]: Optional direction from cached state
231 ///
232 /// Returns:
233 /// - VoteResponse with uri/cid/rkey if created
234 /// - VoteResponse with deleted=true if toggled off
235 ///
236 /// Throws:
237 /// - ApiException for API errors
238 Future<VoteResponse> createVote({
239 required String postUri,
240 required String postCid,
241 String direction = 'up',
242 String? existingVoteRkey,
243 String? existingVoteDirection,
244 }) async {
245 try {
246 final userDid = _didGetter?.call();
247 final session = await _sessionGetter?.call();
248
249 if (userDid == null || userDid.isEmpty) {
250 throw ApiException('User not authenticated - no DID available');
251 }
252
253 if (session == null) {
254 throw ApiException('User not authenticated - no session available');
255 }
256
257 if (kDebugMode) {
258 debugPrint('🗳️ Creating vote via backend');
259 debugPrint(' Post: $postUri');
260 debugPrint(' Direction: $direction');
261 }
262
263 // Determine if this is a toggle (delete) or create
264 final isToggleOff =
265 existingVoteRkey != null && existingVoteDirection == direction;
266
267 if (isToggleOff) {
268 // Delete existing vote
269 return _deleteVote(session: session, rkey: existingVoteRkey);
270 }
271
272 // If switching direction, delete old vote first
273 if (existingVoteRkey != null && existingVoteDirection != null) {
274 if (kDebugMode) {
275 debugPrint(' Switching vote direction - deleting old vote first');
276 }
277 await _deleteVote(session: session, rkey: existingVoteRkey);
278 }
279
280 // Create new vote via backend
281 // Note: Authorization header is added by the interceptor
282 final response = await _dio.post<Map<String, dynamic>>(
283 '/xrpc/social.coves.feed.vote.create',
284 data: {
285 'subject': {'uri': postUri, 'cid': postCid},
286 'direction': direction,
287 },
288 );
289
290 final data = response.data;
291 if (data == null) {
292 throw ApiException('Invalid response from server - no data');
293 }
294
295 final uri = data['uri'] as String?;
296 final cid = data['cid'] as String?;
297
298 if (uri == null || cid == null) {
299 throw ApiException('Invalid response from server - missing uri or cid');
300 }
301
302 // Extract rkey from URI
303 final rkey = uri.split('/').last;
304
305 if (kDebugMode) {
306 debugPrint('✅ Vote created: $uri');
307 }
308
309 return VoteResponse(uri: uri, cid: cid, rkey: rkey, deleted: false);
310 } on DioException catch (e) {
311 if (kDebugMode) {
312 debugPrint('❌ Vote failed: ${e.message}');
313 debugPrint(' Status: ${e.response?.statusCode}');
314 debugPrint(' Data: ${e.response?.data}');
315 }
316
317 if (e.response?.statusCode == 401) {
318 throw AuthenticationException(
319 'Authentication failed. Please sign in again.',
320 originalError: e,
321 );
322 }
323
324 throw ApiException(
325 'Failed to create vote: ${e.message}',
326 statusCode: e.response?.statusCode,
327 originalError: e,
328 );
329 } on Exception catch (e) {
330 throw ApiException('Failed to create vote: $e');
331 }
332 }
333
334 /// Delete vote via backend
335 Future<VoteResponse> _deleteVote({
336 required CovesSession session,
337 required String rkey,
338 }) async {
339 try {
340 // Note: Authorization header is added by the interceptor
341 await _dio.post<void>(
342 '/xrpc/social.coves.feed.vote.delete',
343 data: {'rkey': rkey},
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}