1import 'package:dio/dio.dart'; 2import 'package:flutter/foundation.dart'; 3 4import '../config/oauth_config.dart'; 5import '../models/post.dart'; 6import 'api_exceptions.dart'; 7 8/// Coves API Service 9/// 10/// Handles authenticated requests to the Coves backend. 11/// Uses dio for HTTP requests with automatic token management. 12/// 13/// IMPORTANT: Accepts a tokenGetter function to fetch fresh access tokens 14/// before each authenticated request. This is critical because atProto OAuth 15/// rotates tokens automatically (~1 hour expiry), and caching tokens would 16/// cause 401 errors after the first token expires. 17class CovesApiService { 18 CovesApiService({Future<String?> Function()? tokenGetter}) 19 : _tokenGetter = tokenGetter { 20 _dio = Dio( 21 BaseOptions( 22 baseUrl: OAuthConfig.apiUrl, 23 connectTimeout: const Duration(seconds: 30), 24 receiveTimeout: const Duration(seconds: 30), 25 headers: {'Content-Type': 'application/json'}, 26 ), 27 ); 28 29 // Add auth interceptor FIRST to add bearer token 30 _dio.interceptors.add( 31 InterceptorsWrapper( 32 onRequest: (options, handler) async { 33 // Fetch fresh token before each request (critical for atProto OAuth) 34 if (_tokenGetter != null) { 35 final token = await _tokenGetter(); 36 if (token != null) { 37 options.headers['Authorization'] = 'Bearer $token'; 38 if (kDebugMode) { 39 debugPrint('🔐 Adding fresh Authorization header'); 40 } 41 } else { 42 if (kDebugMode) { 43 debugPrint( 44 '⚠️ Token getter returned null - ' 45 'making unauthenticated request', 46 ); 47 } 48 } 49 } else { 50 if (kDebugMode) { 51 debugPrint( 52 '⚠️ No token getter provided - ' 53 'making unauthenticated request', 54 ); 55 } 56 } 57 return handler.next(options); 58 }, 59 onError: (error, handler) { 60 if (kDebugMode) { 61 debugPrint('❌ API Error: ${error.message}'); 62 if (error.response != null) { 63 debugPrint(' Status: ${error.response?.statusCode}'); 64 debugPrint(' Data: ${error.response?.data}'); 65 } 66 } 67 return handler.next(error); 68 }, 69 ), 70 ); 71 72 // Add logging interceptor AFTER auth (so it can see the 73 // Authorization header) 74 if (kDebugMode) { 75 _dio.interceptors.add( 76 LogInterceptor( 77 requestBody: true, 78 responseBody: true, 79 logPrint: (obj) => debugPrint(obj.toString()), 80 ), 81 ); 82 } 83 } 84 late final Dio _dio; 85 final Future<String?> Function()? _tokenGetter; 86 87 /// Get timeline feed (authenticated, personalized) 88 /// 89 /// Fetches posts from communities the user is subscribed to. 90 /// Requires authentication. 91 /// 92 /// Parameters: 93 /// - [sort]: 'hot', 'top', or 'new' (default: 'hot') 94 /// - [timeframe]: 'hour', 'day', 'week', 'month', 'year', 'all' 95 /// (default: 'day' for top sort) 96 /// - [limit]: Number of posts per page (default: 15, max: 50) 97 /// - [cursor]: Pagination cursor from previous response 98 Future<TimelineResponse> getTimeline({ 99 String sort = 'hot', 100 String? timeframe, 101 int limit = 15, 102 String? cursor, 103 }) async { 104 try { 105 if (kDebugMode) { 106 debugPrint('📡 Fetching timeline: sort=$sort, limit=$limit'); 107 } 108 109 final queryParams = <String, dynamic>{'sort': sort, 'limit': limit}; 110 111 if (timeframe != null) { 112 queryParams['timeframe'] = timeframe; 113 } 114 115 if (cursor != null) { 116 queryParams['cursor'] = cursor; 117 } 118 119 final response = await _dio.get( 120 '/xrpc/social.coves.feed.getTimeline', 121 queryParameters: queryParams, 122 ); 123 124 if (kDebugMode) { 125 debugPrint( 126 '✅ Timeline fetched: ' 127 '${response.data['feed']?.length ?? 0} posts', 128 ); 129 } 130 131 return TimelineResponse.fromJson(response.data as Map<String, dynamic>); 132 } on DioException catch (e) { 133 _handleDioException(e, 'timeline'); 134 } 135 } 136 137 /// Get discover feed (public, no auth required) 138 /// 139 /// Fetches posts from all communities for exploration. 140 /// Does not require authentication. 141 Future<TimelineResponse> getDiscover({ 142 String sort = 'hot', 143 String? timeframe, 144 int limit = 15, 145 String? cursor, 146 }) async { 147 try { 148 if (kDebugMode) { 149 debugPrint('📡 Fetching discover feed: sort=$sort, limit=$limit'); 150 } 151 152 final queryParams = <String, dynamic>{'sort': sort, 'limit': limit}; 153 154 if (timeframe != null) { 155 queryParams['timeframe'] = timeframe; 156 } 157 158 if (cursor != null) { 159 queryParams['cursor'] = cursor; 160 } 161 162 final response = await _dio.get( 163 '/xrpc/social.coves.feed.getDiscover', 164 queryParameters: queryParams, 165 ); 166 167 if (kDebugMode) { 168 debugPrint( 169 '✅ Discover feed fetched: ' 170 '${response.data['feed']?.length ?? 0} posts', 171 ); 172 } 173 174 return TimelineResponse.fromJson(response.data as Map<String, dynamic>); 175 } on DioException catch (e) { 176 _handleDioException(e, 'discover feed'); 177 } 178 } 179 180 /// Handle Dio exceptions with specific error types 181 /// 182 /// Converts generic DioException into specific typed exceptions 183 /// for better error handling throughout the app. 184 Never _handleDioException(DioException e, String operation) { 185 if (kDebugMode) { 186 debugPrint('❌ Failed to fetch $operation: ${e.message}'); 187 if (e.response != null) { 188 debugPrint(' Status: ${e.response?.statusCode}'); 189 debugPrint(' Data: ${e.response?.data}'); 190 } 191 } 192 193 // Handle specific HTTP status codes 194 if (e.response != null) { 195 final statusCode = e.response!.statusCode; 196 final message = 197 e.response!.data?['error'] ?? e.response!.data?['message']; 198 199 if (statusCode != null) { 200 if (statusCode == 401) { 201 throw AuthenticationException( 202 message?.toString() ?? 203 'Authentication failed. Token expired or invalid', 204 originalError: e, 205 ); 206 } else if (statusCode == 404) { 207 throw NotFoundException( 208 message?.toString() ?? 209 'Resource not found. PDS or content may not exist', 210 originalError: e, 211 ); 212 } else if (statusCode >= 500) { 213 throw ServerException( 214 message?.toString() ?? 'Server error. Please try again later', 215 statusCode: statusCode, 216 originalError: e, 217 ); 218 } else { 219 // Other HTTP errors 220 throw ApiException( 221 message?.toString() ?? 'Request failed: ${e.message}', 222 statusCode: statusCode, 223 originalError: e, 224 ); 225 } 226 } else { 227 // No status code in response 228 throw ApiException( 229 message?.toString() ?? 'Request failed: ${e.message}', 230 originalError: e, 231 ); 232 } 233 } 234 235 // Handle network-level errors (no response from server) 236 switch (e.type) { 237 case DioExceptionType.connectionTimeout: 238 case DioExceptionType.sendTimeout: 239 case DioExceptionType.receiveTimeout: 240 throw NetworkException( 241 'Connection timeout. Please check your internet connection', 242 originalError: e, 243 ); 244 case DioExceptionType.connectionError: 245 // Could be federation issue if it's a PDS connection failure 246 if (e.message?.contains('Failed host lookup') ?? false) { 247 throw FederationException( 248 'Failed to connect to PDS. Server may be unreachable', 249 originalError: e, 250 ); 251 } 252 throw NetworkException( 253 'Network error. Please check your internet connection', 254 originalError: e, 255 ); 256 case DioExceptionType.badResponse: 257 // Already handled above by response status code check 258 throw ApiException( 259 'Bad response from server: ${e.message}', 260 statusCode: e.response?.statusCode, 261 originalError: e, 262 ); 263 case DioExceptionType.cancel: 264 throw ApiException('Request cancelled', originalError: e); 265 default: 266 throw ApiException('Unknown error: ${e.message}', originalError: e); 267 } 268 } 269 270 /// Dispose resources 271 void dispose() { 272 _dio.close(); 273 } 274}