Main coves client
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}