Main coves client
1import 'package:dio/dio.dart';
2import 'package:flutter/foundation.dart';
3
4import '../config/environment_config.dart';
5import '../models/comment.dart';
6import '../models/post.dart';
7import 'api_exceptions.dart';
8
9/// Coves API Service
10///
11/// Handles authenticated requests to the Coves backend.
12/// Uses dio for HTTP requests with automatic token management.
13///
14/// IMPORTANT: Accepts a tokenGetter function to fetch fresh access tokens
15/// before each authenticated request. This is critical because atProto OAuth
16/// rotates tokens automatically (~1 hour expiry), and caching tokens would
17/// cause 401 errors after the first token expires.
18///
19/// Features automatic token refresh on 401 responses:
20/// - When a 401 is received, attempts to refresh the token
21/// - Retries the original request with the new token
22/// - If refresh fails, signs out the user
23class CovesApiService {
24 CovesApiService({
25 Future<String?> Function()? tokenGetter,
26 Future<bool> Function()? tokenRefresher,
27 Future<void> Function()? signOutHandler,
28 Dio? dio,
29 }) : _tokenGetter = tokenGetter,
30 _tokenRefresher = tokenRefresher,
31 _signOutHandler = signOutHandler {
32 _dio =
33 dio ??
34 Dio(
35 BaseOptions(
36 baseUrl: EnvironmentConfig.current.apiUrl,
37 connectTimeout: const Duration(seconds: 30),
38 receiveTimeout: const Duration(seconds: 30),
39 headers: {'Content-Type': 'application/json'},
40 ),
41 );
42
43 // Add auth interceptor FIRST to add bearer token
44 _dio.interceptors.add(
45 InterceptorsWrapper(
46 onRequest: (options, handler) async {
47 // Fetch fresh token before each request (critical for atProto OAuth)
48 if (_tokenGetter != null) {
49 final token = await _tokenGetter();
50 if (token != null) {
51 options.headers['Authorization'] = 'Bearer $token';
52 if (kDebugMode) {
53 debugPrint('🔐 Adding fresh Authorization header');
54 }
55 } else {
56 if (kDebugMode) {
57 debugPrint(
58 '⚠️ Token getter returned null - '
59 'making unauthenticated request',
60 );
61 }
62 }
63 } else {
64 if (kDebugMode) {
65 debugPrint(
66 '⚠️ No token getter provided - '
67 'making unauthenticated request',
68 );
69 }
70 }
71 return handler.next(options);
72 },
73 onError: (error, handler) async {
74 // Handle 401 errors with automatic token refresh
75 if (error.response?.statusCode == 401 && _tokenRefresher != null) {
76 if (kDebugMode) {
77 debugPrint('🔄 401 detected, attempting token refresh...');
78 }
79
80 // Don't retry the refresh endpoint itself (avoid infinite loop)
81 final isRefreshEndpoint =
82 error.requestOptions.path.contains('/oauth/refresh');
83 if (isRefreshEndpoint) {
84 if (kDebugMode) {
85 debugPrint(
86 '⚠️ Refresh endpoint returned 401, signing out user',
87 );
88 }
89 // Refresh endpoint failed, sign out the user
90 if (_signOutHandler != null) {
91 await _signOutHandler();
92 }
93 return handler.next(error);
94 }
95
96 // Check if we already retried this request (prevent infinite loop)
97 if (error.requestOptions.extra['retried'] == true) {
98 if (kDebugMode) {
99 debugPrint(
100 '⚠️ Request already retried after token refresh, '
101 'signing out user',
102 );
103 }
104 // Already retried once, don't retry again
105 if (_signOutHandler != null) {
106 await _signOutHandler();
107 }
108 return handler.next(error);
109 }
110
111 try {
112 // Attempt to refresh the token
113 final refreshSucceeded = await _tokenRefresher();
114
115 if (refreshSucceeded) {
116 if (kDebugMode) {
117 debugPrint('✅ Token refresh successful, retrying request');
118 }
119
120 // Get the new token
121 final newToken =
122 _tokenGetter != null ? await _tokenGetter() : null;
123
124 if (newToken != null) {
125 // Mark this request as retried to prevent infinite loops
126 error.requestOptions.extra['retried'] = true;
127
128 // Update the Authorization header with the new token
129 error.requestOptions.headers['Authorization'] =
130 'Bearer $newToken';
131
132 // Retry the original request with the new token
133 try {
134 final response = await _dio.fetch(error.requestOptions);
135 return handler.resolve(response);
136 } on DioException catch (retryError) {
137 // If retry failed with 401 and already retried, we already
138 // signed out in the retry limit check above, so just pass
139 // the error through without signing out again
140 if (retryError.response?.statusCode == 401 &&
141 retryError.requestOptions.extra['retried'] == true) {
142 return handler.next(retryError);
143 }
144 // For other errors during retry, rethrow to outer catch
145 rethrow;
146 }
147 }
148 }
149
150 // Refresh failed, sign out the user
151 if (kDebugMode) {
152 debugPrint('❌ Token refresh failed, signing out user');
153 }
154 if (_signOutHandler != null) {
155 await _signOutHandler();
156 }
157 } catch (e) {
158 if (kDebugMode) {
159 debugPrint('❌ Error during token refresh: $e');
160 }
161 // Only sign out if we haven't already (avoid double sign-out)
162 // Check if this is a DioException from a retried request
163 final isRetriedRequest = e is DioException &&
164 e.response?.statusCode == 401 &&
165 e.requestOptions.extra['retried'] == true;
166
167 if (!isRetriedRequest && _signOutHandler != null) {
168 await _signOutHandler();
169 }
170 }
171 }
172
173 // Log the error for debugging
174 if (kDebugMode) {
175 debugPrint('❌ API Error: ${error.message}');
176 if (error.response != null) {
177 debugPrint(' Status: ${error.response?.statusCode}');
178 debugPrint(' Data: ${error.response?.data}');
179 }
180 }
181 return handler.next(error);
182 },
183 ),
184 );
185
186 // Add logging interceptor AFTER auth (so it can see the
187 // Authorization header)
188 if (kDebugMode) {
189 _dio.interceptors.add(
190 LogInterceptor(
191 requestBody: true,
192 responseBody: true,
193 logPrint: (obj) => debugPrint(obj.toString()),
194 ),
195 );
196 }
197 }
198 late final Dio _dio;
199 final Future<String?> Function()? _tokenGetter;
200 final Future<bool> Function()? _tokenRefresher;
201 final Future<void> Function()? _signOutHandler;
202
203 /// Get timeline feed (authenticated, personalized)
204 ///
205 /// Fetches posts from communities the user is subscribed to.
206 /// Requires authentication.
207 ///
208 /// Parameters:
209 /// - [sort]: 'hot', 'top', or 'new' (default: 'hot')
210 /// - [timeframe]: 'hour', 'day', 'week', 'month', 'year', 'all'
211 /// (default: 'day' for top sort)
212 /// - [limit]: Number of posts per page (default: 15, max: 50)
213 /// - [cursor]: Pagination cursor from previous response
214 Future<TimelineResponse> getTimeline({
215 String sort = 'hot',
216 String? timeframe,
217 int limit = 15,
218 String? cursor,
219 }) async {
220 try {
221 if (kDebugMode) {
222 debugPrint('📡 Fetching timeline: sort=$sort, limit=$limit');
223 }
224
225 final queryParams = <String, dynamic>{'sort': sort, 'limit': limit};
226
227 if (timeframe != null) {
228 queryParams['timeframe'] = timeframe;
229 }
230
231 if (cursor != null) {
232 queryParams['cursor'] = cursor;
233 }
234
235 final response = await _dio.get(
236 '/xrpc/social.coves.feed.getTimeline',
237 queryParameters: queryParams,
238 );
239
240 if (kDebugMode) {
241 debugPrint(
242 '✅ Timeline fetched: '
243 '${response.data['feed']?.length ?? 0} posts',
244 );
245 }
246
247 return TimelineResponse.fromJson(response.data as Map<String, dynamic>);
248 } on DioException catch (e) {
249 _handleDioException(e, 'timeline');
250 }
251 }
252
253 /// Get discover feed (public, no auth required)
254 ///
255 /// Fetches posts from all communities for exploration.
256 /// Does not require authentication.
257 Future<TimelineResponse> getDiscover({
258 String sort = 'hot',
259 String? timeframe,
260 int limit = 15,
261 String? cursor,
262 }) async {
263 try {
264 if (kDebugMode) {
265 debugPrint('📡 Fetching discover feed: sort=$sort, limit=$limit');
266 }
267
268 final queryParams = <String, dynamic>{'sort': sort, 'limit': limit};
269
270 if (timeframe != null) {
271 queryParams['timeframe'] = timeframe;
272 }
273
274 if (cursor != null) {
275 queryParams['cursor'] = cursor;
276 }
277
278 final response = await _dio.get(
279 '/xrpc/social.coves.feed.getDiscover',
280 queryParameters: queryParams,
281 );
282
283 if (kDebugMode) {
284 debugPrint(
285 '✅ Discover feed fetched: '
286 '${response.data['feed']?.length ?? 0} posts',
287 );
288 }
289
290 return TimelineResponse.fromJson(response.data as Map<String, dynamic>);
291 } on DioException catch (e) {
292 _handleDioException(e, 'discover feed');
293 }
294 }
295
296 /// Get comments for a post (authenticated)
297 ///
298 /// Fetches threaded comments for a specific post.
299 /// Requires authentication.
300 ///
301 /// Parameters:
302 /// - [postUri]: Post URI (required)
303 /// - [sort]: 'hot', 'top', or 'new' (default: 'hot')
304 /// - [timeframe]: 'hour', 'day', 'week', 'month', 'year', 'all'
305 /// - [depth]: Maximum nesting depth for replies (default: 10)
306 /// - [limit]: Number of comments per page (default: 50, max: 100)
307 /// - [cursor]: Pagination cursor from previous response
308 Future<CommentsResponse> getComments({
309 required String postUri,
310 String sort = 'hot',
311 String? timeframe,
312 int depth = 10,
313 int limit = 50,
314 String? cursor,
315 }) async {
316 try {
317 if (kDebugMode) {
318 debugPrint('📡 Fetching comments: postUri=$postUri, sort=$sort');
319 }
320
321 final queryParams = <String, dynamic>{
322 'post': postUri,
323 'sort': sort,
324 'depth': depth,
325 'limit': limit,
326 };
327
328 if (timeframe != null) {
329 queryParams['timeframe'] = timeframe;
330 }
331
332 if (cursor != null) {
333 queryParams['cursor'] = cursor;
334 }
335
336 final response = await _dio.get(
337 '/xrpc/social.coves.community.comment.getComments',
338 queryParameters: queryParams,
339 );
340
341 if (kDebugMode) {
342 debugPrint(
343 '✅ Comments fetched: '
344 '${response.data['comments']?.length ?? 0} comments',
345 );
346 }
347
348 return CommentsResponse.fromJson(response.data as Map<String, dynamic>);
349 } on DioException catch (e) {
350 _handleDioException(e, 'comments');
351 } catch (e) {
352 if (kDebugMode) {
353 debugPrint('❌ Error parsing comments response: $e');
354 }
355 throw ApiException(
356 'Failed to parse server response',
357 originalError: e,
358 );
359 }
360 }
361
362 /// Handle Dio exceptions with specific error types
363 ///
364 /// Converts generic DioException into specific typed exceptions
365 /// for better error handling throughout the app.
366 Never _handleDioException(DioException e, String operation) {
367 if (kDebugMode) {
368 debugPrint('❌ Failed to fetch $operation: ${e.message}');
369 if (e.response != null) {
370 debugPrint(' Status: ${e.response?.statusCode}');
371 debugPrint(' Data: ${e.response?.data}');
372 }
373 }
374
375 // Handle specific HTTP status codes
376 if (e.response != null) {
377 final statusCode = e.response!.statusCode;
378 final message =
379 e.response!.data?['error'] ?? e.response!.data?['message'];
380
381 if (statusCode != null) {
382 if (statusCode == 401) {
383 throw AuthenticationException(
384 message?.toString() ??
385 'Authentication failed. Token expired or invalid',
386 originalError: e,
387 );
388 } else if (statusCode == 404) {
389 throw NotFoundException(
390 message?.toString() ??
391 'Resource not found. PDS or content may not exist',
392 originalError: e,
393 );
394 } else if (statusCode >= 500) {
395 throw ServerException(
396 message?.toString() ?? 'Server error. Please try again later',
397 statusCode: statusCode,
398 originalError: e,
399 );
400 } else {
401 // Other HTTP errors
402 throw ApiException(
403 message?.toString() ?? 'Request failed: ${e.message}',
404 statusCode: statusCode,
405 originalError: e,
406 );
407 }
408 } else {
409 // No status code in response
410 throw ApiException(
411 message?.toString() ?? 'Request failed: ${e.message}',
412 originalError: e,
413 );
414 }
415 }
416
417 // Handle network-level errors (no response from server)
418 switch (e.type) {
419 case DioExceptionType.connectionTimeout:
420 case DioExceptionType.sendTimeout:
421 case DioExceptionType.receiveTimeout:
422 throw NetworkException(
423 'Connection timeout. Please check your internet connection',
424 originalError: e,
425 );
426 case DioExceptionType.connectionError:
427 // Could be federation issue if it's a PDS connection failure
428 if (e.message?.contains('Failed host lookup') ?? false) {
429 throw FederationException(
430 'Failed to connect to PDS. Server may be unreachable',
431 originalError: e,
432 );
433 }
434 throw NetworkException(
435 'Network error. Please check your internet connection',
436 originalError: e,
437 );
438 case DioExceptionType.badResponse:
439 // Already handled above by response status code check
440 throw ApiException(
441 'Bad response from server: ${e.message}',
442 statusCode: e.response?.statusCode,
443 originalError: e,
444 );
445 case DioExceptionType.cancel:
446 throw ApiException('Request cancelled', originalError: e);
447 default:
448 throw ApiException('Unknown error: ${e.message}', originalError: e);
449 }
450 }
451
452 /// Dispose resources
453 void dispose() {
454 _dio.close();
455 }
456}