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}