at main 18 kB view raw
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}