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