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