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