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 '../providers/vote_provider.dart' show VoteState;
7import 'api_exceptions.dart';
8
9/// Vote Service
10///
11/// Handles vote/like interactions through the Coves backend.
12///
13/// **Architecture with Backend OAuth**:
14/// With sealed tokens, the client cannot write directly to the user's PDS
15/// (no DPoP keys available). Instead, votes go through the Coves backend:
16///
17/// Mobile Client → Coves Backend (sealed token) → User's PDS (DPoP)
18///
19/// The backend:
20/// 1. Unseals the token to get the actual access/refresh tokens
21/// 2. Uses stored DPoP keys to sign requests
22/// 3. Writes to the user's PDS on their behalf
23/// 4. Handles toggle logic (creating, deleting, or switching vote direction)
24///
25/// **Backend Endpoints**:
26/// - POST /xrpc/social.coves.feed.vote.create - Creates, toggles, or switches votes
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 /// Create or toggle vote
184 ///
185 /// Sends vote request to the Coves backend, which handles toggle logic.
186 /// The backend will create a vote if none exists, or toggle it off if
187 /// voting the same direction again.
188 ///
189 /// Parameters:
190 /// - [postUri]: AT-URI of the post
191 /// - [postCid]: Content ID of the post (for strong reference)
192 /// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote
193 ///
194 /// Returns:
195 /// - VoteResponse with uri/cid/rkey if vote was created
196 /// - VoteResponse with deleted=true if vote was toggled off (empty uri/cid)
197 ///
198 /// Throws:
199 /// - ApiException for API errors
200 Future<VoteResponse> createVote({
201 required String postUri,
202 required String postCid,
203 String direction = 'up',
204 }) async {
205 try {
206 final userDid = _didGetter?.call();
207 final session = await _sessionGetter?.call();
208
209 if (userDid == null || userDid.isEmpty) {
210 throw ApiException('User not authenticated - no DID available');
211 }
212
213 if (session == null) {
214 throw ApiException('User not authenticated - no session available');
215 }
216
217 if (kDebugMode) {
218 debugPrint('🗳️ Creating vote via backend');
219 debugPrint(' Post: $postUri');
220 debugPrint(' Direction: $direction');
221 }
222
223 // Send vote request to backend
224 // Note: Authorization header is added by the interceptor
225 final response = await _dio.post<Map<String, dynamic>>(
226 '/xrpc/social.coves.feed.vote.create',
227 data: {
228 'subject': {'uri': postUri, 'cid': postCid},
229 'direction': direction,
230 },
231 );
232
233 final data = response.data;
234 if (data == null) {
235 throw ApiException('Invalid response from server - no data');
236 }
237
238 final uri = data['uri'] as String?;
239 final cid = data['cid'] as String?;
240
241 // If uri/cid are empty, the backend toggled off an existing vote
242 if (uri == null || uri.isEmpty || cid == null || cid.isEmpty) {
243 if (kDebugMode) {
244 debugPrint('✅ Vote toggled off (deleted)');
245 }
246 return const VoteResponse(deleted: true);
247 }
248
249 // Extract rkey from URI using shared utility
250 final rkey = VoteState.extractRkeyFromUri(uri);
251
252 if (kDebugMode) {
253 debugPrint('✅ Vote created: $uri');
254 }
255
256 return VoteResponse(uri: uri, cid: cid, rkey: rkey, deleted: false);
257 } on DioException catch (e) {
258 if (kDebugMode) {
259 debugPrint('❌ Vote failed: ${e.message}');
260 debugPrint(' Status: ${e.response?.statusCode}');
261 debugPrint(' Data: ${e.response?.data}');
262 }
263
264 if (e.response?.statusCode == 401) {
265 throw AuthenticationException(
266 'Authentication failed. Please sign in again.',
267 originalError: e,
268 );
269 }
270
271 throw ApiException(
272 'Failed to create vote: ${e.message}',
273 statusCode: e.response?.statusCode,
274 originalError: e,
275 );
276 } on Exception catch (e) {
277 throw ApiException('Failed to create vote: $e');
278 }
279 }
280}
281
282/// Vote Response
283///
284/// Response from createVote operation.
285class VoteResponse {
286 const VoteResponse({this.uri, this.cid, this.rkey, required this.deleted});
287
288 /// AT-URI of the created vote record
289 final String? uri;
290
291 /// Content ID of the vote record
292 final String? cid;
293
294 /// Record key (rkey) of the vote - last segment of URI
295 final String? rkey;
296
297 /// Whether the vote was deleted (toggled off)
298 final bool deleted;
299}