1import 'package:dio/dio.dart'; 2import 'package:flutter/foundation.dart'; 3 4import '../config/environment_config.dart'; 5import '../models/coves_session.dart'; 6import '../providers/vote_provider.dart' show VoteState; 7import 'api_exceptions.dart'; 8 9/// Vote Service 10/// 11/// Handles vote/like interactions through the Coves backend. 12/// 13/// **Architecture with Backend OAuth**: 14/// With sealed tokens, the client cannot write directly to the user's PDS 15/// (no DPoP keys available). Instead, votes go through the Coves backend: 16/// 17/// Mobile Client → Coves Backend (sealed token) → User's PDS (DPoP) 18/// 19/// The backend: 20/// 1. Unseals the token to get the actual access/refresh tokens 21/// 2. Uses stored DPoP keys to sign requests 22/// 3. Writes to the user's PDS on their behalf 23/// 4. Handles toggle logic (creating, deleting, or switching vote direction) 24/// 25/// **Backend Endpoints**: 26/// - POST /xrpc/social.coves.feed.vote.create - Creates, toggles, or switches votes 27class VoteService { 28 VoteService({ 29 Future<CovesSession?> Function()? sessionGetter, 30 String? Function()? didGetter, 31 Future<bool> Function()? tokenRefresher, 32 Future<void> Function()? signOutHandler, 33 Dio? dio, 34 }) : _sessionGetter = sessionGetter, 35 _didGetter = didGetter, 36 _tokenRefresher = tokenRefresher, 37 _signOutHandler = signOutHandler { 38 _dio = 39 dio ?? 40 Dio( 41 BaseOptions( 42 baseUrl: EnvironmentConfig.current.apiUrl, 43 connectTimeout: const Duration(seconds: 30), 44 receiveTimeout: const Duration(seconds: 30), 45 headers: {'Content-Type': 'application/json'}, 46 ), 47 ); 48 49 // Add 401 retry interceptor (same pattern as CovesApiService) 50 _dio.interceptors.add( 51 InterceptorsWrapper( 52 onRequest: (options, handler) async { 53 // Fetch fresh token before each request 54 final session = await _sessionGetter?.call(); 55 if (session != null) { 56 options.headers['Authorization'] = 'Bearer ${session.token}'; 57 if (kDebugMode) { 58 debugPrint('🔐 VoteService: Adding fresh Authorization header'); 59 } 60 } else { 61 if (kDebugMode) { 62 debugPrint( 63 '⚠️ VoteService: Session getter returned null - ' 64 'making unauthenticated request', 65 ); 66 } 67 } 68 return handler.next(options); 69 }, 70 onError: (error, handler) async { 71 // Handle 401 errors with automatic token refresh 72 if (error.response?.statusCode == 401 && _tokenRefresher != null) { 73 if (kDebugMode) { 74 debugPrint( 75 '🔄 VoteService: 401 detected, attempting token refresh...', 76 ); 77 } 78 79 // Check if we already retried this request (prevent infinite loop) 80 if (error.requestOptions.extra['retried'] == true) { 81 if (kDebugMode) { 82 debugPrint( 83 '⚠️ VoteService: Request already retried after token refresh, ' 84 'signing out user', 85 ); 86 } 87 // Already retried once, don't retry again 88 if (_signOutHandler != null) { 89 await _signOutHandler(); 90 } 91 return handler.next(error); 92 } 93 94 try { 95 // Attempt to refresh the token 96 final refreshSucceeded = await _tokenRefresher(); 97 98 if (refreshSucceeded) { 99 if (kDebugMode) { 100 debugPrint( 101 '✅ VoteService: Token refresh successful, retrying request', 102 ); 103 } 104 105 // Get the new session 106 final newSession = await _sessionGetter?.call(); 107 108 if (newSession != null) { 109 // Mark this request as retried to prevent infinite loops 110 error.requestOptions.extra['retried'] = true; 111 112 // Update the Authorization header with the new token 113 error.requestOptions.headers['Authorization'] = 114 'Bearer ${newSession.token}'; 115 116 // Retry the original request with the new token 117 try { 118 final response = await _dio.fetch(error.requestOptions); 119 return handler.resolve(response); 120 } on DioException catch (retryError) { 121 // If retry failed with 401 and already retried, we already 122 // signed out in the retry limit check above, so just pass 123 // the error through without signing out again 124 if (retryError.response?.statusCode == 401 && 125 retryError.requestOptions.extra['retried'] == true) { 126 return handler.next(retryError); 127 } 128 // For other errors during retry, rethrow to outer catch 129 rethrow; 130 } 131 } 132 } 133 134 // Refresh failed, sign out the user 135 if (kDebugMode) { 136 debugPrint( 137 '❌ VoteService: Token refresh failed, signing out user', 138 ); 139 } 140 if (_signOutHandler != null) { 141 await _signOutHandler(); 142 } 143 } catch (e) { 144 if (kDebugMode) { 145 debugPrint('❌ VoteService: Error during token refresh: $e'); 146 } 147 // Only sign out if we haven't already (avoid double sign-out) 148 // Check if this is a DioException from a retried request 149 final isRetriedRequest = 150 e is DioException && 151 e.response?.statusCode == 401 && 152 e.requestOptions.extra['retried'] == true; 153 154 if (!isRetriedRequest && _signOutHandler != null) { 155 await _signOutHandler(); 156 } 157 } 158 } 159 160 // Log the error for debugging 161 if (kDebugMode) { 162 debugPrint('❌ VoteService API Error: ${error.message}'); 163 if (error.response != null) { 164 debugPrint(' Status: ${error.response?.statusCode}'); 165 debugPrint(' Data: ${error.response?.data}'); 166 } 167 } 168 return handler.next(error); 169 }, 170 ), 171 ); 172 } 173 174 final Future<CovesSession?> Function()? _sessionGetter; 175 final String? Function()? _didGetter; 176 final Future<bool> Function()? _tokenRefresher; 177 final Future<void> Function()? _signOutHandler; 178 late final Dio _dio; 179 180 /// Collection name for vote records 181 static const String voteCollection = 'social.coves.feed.vote'; 182 183 /// Create or toggle vote 184 /// 185 /// Sends vote request to the Coves backend, which handles toggle logic. 186 /// The backend will create a vote if none exists, or toggle it off if 187 /// voting the same direction again. 188 /// 189 /// Parameters: 190 /// - [postUri]: AT-URI of the post 191 /// - [postCid]: Content ID of the post (for strong reference) 192 /// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote 193 /// 194 /// Returns: 195 /// - VoteResponse with uri/cid/rkey if vote was created 196 /// - VoteResponse with deleted=true if vote was toggled off (empty uri/cid) 197 /// 198 /// Throws: 199 /// - ApiException for API errors 200 Future<VoteResponse> createVote({ 201 required String postUri, 202 required String postCid, 203 String direction = 'up', 204 }) async { 205 try { 206 final userDid = _didGetter?.call(); 207 final session = await _sessionGetter?.call(); 208 209 if (userDid == null || userDid.isEmpty) { 210 throw ApiException('User not authenticated - no DID available'); 211 } 212 213 if (session == null) { 214 throw ApiException('User not authenticated - no session available'); 215 } 216 217 if (kDebugMode) { 218 debugPrint('🗳️ Creating vote via backend'); 219 debugPrint(' Post: $postUri'); 220 debugPrint(' Direction: $direction'); 221 } 222 223 // Send vote request to backend 224 // Note: Authorization header is added by the interceptor 225 final response = await _dio.post<Map<String, dynamic>>( 226 '/xrpc/social.coves.feed.vote.create', 227 data: { 228 'subject': {'uri': postUri, 'cid': postCid}, 229 'direction': direction, 230 }, 231 ); 232 233 final data = response.data; 234 if (data == null) { 235 throw ApiException('Invalid response from server - no data'); 236 } 237 238 final uri = data['uri'] as String?; 239 final cid = data['cid'] as String?; 240 241 // If uri/cid are empty, the backend toggled off an existing vote 242 if (uri == null || uri.isEmpty || cid == null || cid.isEmpty) { 243 if (kDebugMode) { 244 debugPrint('✅ Vote toggled off (deleted)'); 245 } 246 return const VoteResponse(deleted: true); 247 } 248 249 // Extract rkey from URI using shared utility 250 final rkey = VoteState.extractRkeyFromUri(uri); 251 252 if (kDebugMode) { 253 debugPrint('✅ Vote created: $uri'); 254 } 255 256 return VoteResponse(uri: uri, cid: cid, rkey: rkey, deleted: false); 257 } on DioException catch (e) { 258 if (kDebugMode) { 259 debugPrint('❌ Vote failed: ${e.message}'); 260 debugPrint(' Status: ${e.response?.statusCode}'); 261 debugPrint(' Data: ${e.response?.data}'); 262 } 263 264 if (e.response?.statusCode == 401) { 265 throw AuthenticationException( 266 'Authentication failed. Please sign in again.', 267 originalError: e, 268 ); 269 } 270 271 throw ApiException( 272 'Failed to create vote: ${e.message}', 273 statusCode: e.response?.statusCode, 274 originalError: e, 275 ); 276 } on Exception catch (e) { 277 throw ApiException('Failed to create vote: $e'); 278 } 279 } 280} 281 282/// Vote Response 283/// 284/// Response from createVote operation. 285class VoteResponse { 286 const VoteResponse({this.uri, this.cid, this.rkey, required this.deleted}); 287 288 /// AT-URI of the created vote record 289 final String? uri; 290 291 /// Content ID of the vote record 292 final String? cid; 293 294 /// Record key (rkey) of the vote - last segment of URI 295 final String? rkey; 296 297 /// Whether the vote was deleted (toggled off) 298 final bool deleted; 299}