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({
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}