1import 'package:dio/dio.dart'; 2import 'package:flutter/foundation.dart'; 3 4import '../config/environment_config.dart'; 5import '../models/coves_session.dart'; 6import 'api_exceptions.dart'; 7 8/// Vote Service 9/// 10/// Handles vote/like interactions through the Coves backend. 11/// 12/// **Architecture with Backend OAuth**: 13/// With sealed tokens, the client cannot write directly to the user's PDS 14/// (no DPoP keys available). Instead, votes go through the Coves backend: 15/// 16/// Mobile Client → Coves Backend (sealed token) → User's PDS (DPoP) 17/// 18/// The backend: 19/// 1. Unseals the token to get the actual access/refresh tokens 20/// 2. Uses stored DPoP keys to sign requests 21/// 3. Writes to the user's PDS on their behalf 22/// 23/// TODO: Backend vote endpoints need to be implemented: 24/// - POST /xrpc/social.coves.feed.vote.create 25/// - POST /xrpc/social.coves.feed.vote.delete 26/// - GET /xrpc/social.coves.feed.vote.list (or included in feed response) 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 /// Get all votes for the current user 184 /// 185 /// TODO: This needs a backend endpoint to list user's votes. 186 /// For now, returns empty map - votes will be fetched with feed data. 187 /// 188 /// Returns: 189 /// - `Map<String, VoteInfo>` where key is the post URI 190 /// - Empty map if not authenticated or no votes found 191 Future<Map<String, VoteInfo>> getUserVotes() async { 192 try { 193 final userDid = _didGetter?.call(); 194 if (userDid == null || userDid.isEmpty) { 195 return {}; 196 } 197 198 final session = await _sessionGetter?.call(); 199 if (session == null) { 200 return {}; 201 } 202 203 // TODO: Implement backend endpoint for listing user votes 204 // For now, vote state should come from feed responses 205 if (kDebugMode) { 206 debugPrint( 207 '⚠️ getUserVotes: Backend endpoint not yet implemented. ' 208 'Vote state should come from feed responses.', 209 ); 210 } 211 212 return {}; 213 } on Exception catch (e) { 214 if (kDebugMode) { 215 debugPrint('⚠️ Failed to load user votes: $e'); 216 } 217 return {}; 218 } 219 } 220 221 /// Create or toggle vote 222 /// 223 /// Sends vote request to the Coves backend, which proxies to the user's PDS. 224 /// 225 /// Parameters: 226 /// - [postUri]: AT-URI of the post 227 /// - [postCid]: Content ID of the post (for strong reference) 228 /// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote 229 /// - [existingVoteRkey]: Optional rkey from cached state 230 /// - [existingVoteDirection]: Optional direction from cached state 231 /// 232 /// Returns: 233 /// - VoteResponse with uri/cid/rkey if created 234 /// - VoteResponse with deleted=true if toggled off 235 /// 236 /// Throws: 237 /// - ApiException for API errors 238 Future<VoteResponse> createVote({ 239 required String postUri, 240 required String postCid, 241 String direction = 'up', 242 String? existingVoteRkey, 243 String? existingVoteDirection, 244 }) async { 245 try { 246 final userDid = _didGetter?.call(); 247 final session = await _sessionGetter?.call(); 248 249 if (userDid == null || userDid.isEmpty) { 250 throw ApiException('User not authenticated - no DID available'); 251 } 252 253 if (session == null) { 254 throw ApiException('User not authenticated - no session available'); 255 } 256 257 if (kDebugMode) { 258 debugPrint('🗳️ Creating vote via backend'); 259 debugPrint(' Post: $postUri'); 260 debugPrint(' Direction: $direction'); 261 } 262 263 // Determine if this is a toggle (delete) or create 264 final isToggleOff = 265 existingVoteRkey != null && existingVoteDirection == direction; 266 267 if (isToggleOff) { 268 // Delete existing vote 269 return _deleteVote(session: session, rkey: existingVoteRkey); 270 } 271 272 // If switching direction, delete old vote first 273 if (existingVoteRkey != null && existingVoteDirection != null) { 274 if (kDebugMode) { 275 debugPrint(' Switching vote direction - deleting old vote first'); 276 } 277 await _deleteVote(session: session, rkey: existingVoteRkey); 278 } 279 280 // Create new vote via backend 281 // Note: Authorization header is added by the interceptor 282 final response = await _dio.post<Map<String, dynamic>>( 283 '/xrpc/social.coves.feed.vote.create', 284 data: { 285 'subject': {'uri': postUri, 'cid': postCid}, 286 'direction': direction, 287 }, 288 ); 289 290 final data = response.data; 291 if (data == null) { 292 throw ApiException('Invalid response from server - no data'); 293 } 294 295 final uri = data['uri'] as String?; 296 final cid = data['cid'] as String?; 297 298 if (uri == null || cid == null) { 299 throw ApiException('Invalid response from server - missing uri or cid'); 300 } 301 302 // Extract rkey from URI 303 final rkey = uri.split('/').last; 304 305 if (kDebugMode) { 306 debugPrint('✅ Vote created: $uri'); 307 } 308 309 return VoteResponse(uri: uri, cid: cid, rkey: rkey, deleted: false); 310 } on DioException catch (e) { 311 if (kDebugMode) { 312 debugPrint('❌ Vote failed: ${e.message}'); 313 debugPrint(' Status: ${e.response?.statusCode}'); 314 debugPrint(' Data: ${e.response?.data}'); 315 } 316 317 if (e.response?.statusCode == 401) { 318 throw AuthenticationException( 319 'Authentication failed. Please sign in again.', 320 originalError: e, 321 ); 322 } 323 324 throw ApiException( 325 'Failed to create vote: ${e.message}', 326 statusCode: e.response?.statusCode, 327 originalError: e, 328 ); 329 } on Exception catch (e) { 330 throw ApiException('Failed to create vote: $e'); 331 } 332 } 333 334 /// Delete vote via backend 335 Future<VoteResponse> _deleteVote({ 336 required CovesSession session, 337 required String rkey, 338 }) async { 339 try { 340 // Note: Authorization header is added by the interceptor 341 await _dio.post<void>( 342 '/xrpc/social.coves.feed.vote.delete', 343 data: {'rkey': rkey}, 344 ); 345 346 if (kDebugMode) { 347 debugPrint('✅ Vote deleted'); 348 } 349 350 return const VoteResponse(deleted: true); 351 } on DioException catch (e) { 352 if (kDebugMode) { 353 debugPrint('❌ Delete vote failed: ${e.message}'); 354 } 355 356 throw ApiException( 357 'Failed to delete vote: ${e.message}', 358 statusCode: e.response?.statusCode, 359 originalError: e, 360 ); 361 } 362 } 363} 364 365/// Vote Response 366/// 367/// Response from createVote operation. 368class VoteResponse { 369 const VoteResponse({this.uri, this.cid, this.rkey, required this.deleted}); 370 371 /// AT-URI of the created vote record 372 final String? uri; 373 374 /// Content ID of the vote record 375 final String? cid; 376 377 /// Record key (rkey) of the vote - last segment of URI 378 final String? rkey; 379 380 /// Whether the vote was deleted (toggled off) 381 final bool deleted; 382} 383 384/// Existing Vote 385/// 386/// Represents a vote that already exists on the PDS. 387class ExistingVote { 388 const ExistingVote({required this.direction, required this.rkey}); 389 390 /// Vote direction ("up" or "down") 391 final String direction; 392 393 /// Record key for deletion 394 final String rkey; 395} 396 397/// Vote Info 398/// 399/// Information about a user's vote on a post, returned from getUserVotes(). 400class VoteInfo { 401 const VoteInfo({ 402 required this.direction, 403 required this.voteUri, 404 required this.rkey, 405 }); 406 407 /// Vote direction ("up" or "down") 408 final String direction; 409 410 /// AT-URI of the vote record 411 final String voteUri; 412 413 /// Record key (rkey) - last segment of URI 414 final String rkey; 415}