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}