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