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