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 = dio ?? 39 Dio( 40 BaseOptions( 41 baseUrl: EnvironmentConfig.current.apiUrl, 42 connectTimeout: const Duration(seconds: 30), 43 receiveTimeout: const Duration(seconds: 30), 44 headers: {'Content-Type': 'application/json'}, 45 ), 46 ); 47 48 // Add 401 retry interceptor (same pattern as CovesApiService) 49 _dio.interceptors.add( 50 InterceptorsWrapper( 51 onRequest: (options, handler) async { 52 // Fetch fresh token before each request 53 final session = await _sessionGetter?.call(); 54 if (session != null) { 55 options.headers['Authorization'] = 'Bearer ${session.token}'; 56 if (kDebugMode) { 57 debugPrint('🔐 VoteService: Adding fresh Authorization header'); 58 } 59 } else { 60 if (kDebugMode) { 61 debugPrint( 62 '⚠️ VoteService: Session getter returned null - ' 63 'making unauthenticated request', 64 ); 65 } 66 } 67 return handler.next(options); 68 }, 69 onError: (error, handler) async { 70 // Handle 401 errors with automatic token refresh 71 if (error.response?.statusCode == 401 && _tokenRefresher != null) { 72 if (kDebugMode) { 73 debugPrint('🔄 VoteService: 401 detected, attempting token refresh...'); 74 } 75 76 // Check if we already retried this request (prevent infinite loop) 77 if (error.requestOptions.extra['retried'] == true) { 78 if (kDebugMode) { 79 debugPrint( 80 '⚠️ VoteService: Request already retried after token refresh, ' 81 'signing out user', 82 ); 83 } 84 // Already retried once, don't retry again 85 if (_signOutHandler != null) { 86 await _signOutHandler(); 87 } 88 return handler.next(error); 89 } 90 91 try { 92 // Attempt to refresh the token 93 final refreshSucceeded = await _tokenRefresher(); 94 95 if (refreshSucceeded) { 96 if (kDebugMode) { 97 debugPrint('✅ VoteService: Token refresh successful, retrying request'); 98 } 99 100 // Get the new session 101 final newSession = await _sessionGetter?.call(); 102 103 if (newSession != null) { 104 // Mark this request as retried to prevent infinite loops 105 error.requestOptions.extra['retried'] = true; 106 107 // Update the Authorization header with the new token 108 error.requestOptions.headers['Authorization'] = 109 'Bearer ${newSession.token}'; 110 111 // Retry the original request with the new token 112 try { 113 final response = await _dio.fetch(error.requestOptions); 114 return handler.resolve(response); 115 } on DioException catch (retryError) { 116 // If retry failed with 401 and already retried, we already 117 // signed out in the retry limit check above, so just pass 118 // the error through without signing out again 119 if (retryError.response?.statusCode == 401 && 120 retryError.requestOptions.extra['retried'] == true) { 121 return handler.next(retryError); 122 } 123 // For other errors during retry, rethrow to outer catch 124 rethrow; 125 } 126 } 127 } 128 129 // Refresh failed, sign out the user 130 if (kDebugMode) { 131 debugPrint('❌ VoteService: Token refresh failed, signing out user'); 132 } 133 if (_signOutHandler != null) { 134 await _signOutHandler(); 135 } 136 } catch (e) { 137 if (kDebugMode) { 138 debugPrint('❌ VoteService: Error during token refresh: $e'); 139 } 140 // Only sign out if we haven't already (avoid double sign-out) 141 // Check if this is a DioException from a retried request 142 final isRetriedRequest = e is DioException && 143 e.response?.statusCode == 401 && 144 e.requestOptions.extra['retried'] == true; 145 146 if (!isRetriedRequest && _signOutHandler != null) { 147 await _signOutHandler(); 148 } 149 } 150 } 151 152 // Log the error for debugging 153 if (kDebugMode) { 154 debugPrint('❌ VoteService API Error: ${error.message}'); 155 if (error.response != null) { 156 debugPrint(' Status: ${error.response?.statusCode}'); 157 debugPrint(' Data: ${error.response?.data}'); 158 } 159 } 160 return handler.next(error); 161 }, 162 ), 163 ); 164 } 165 166 final Future<CovesSession?> Function()? _sessionGetter; 167 final String? Function()? _didGetter; 168 final Future<bool> Function()? _tokenRefresher; 169 final Future<void> Function()? _signOutHandler; 170 late final Dio _dio; 171 172 /// Collection name for vote records 173 static const String voteCollection = 'social.coves.feed.vote'; 174 175 /// Get all votes for the current user 176 /// 177 /// TODO: This needs a backend endpoint to list user's votes. 178 /// For now, returns empty map - votes will be fetched with feed data. 179 /// 180 /// Returns: 181 /// - `Map<String, VoteInfo>` where key is the post URI 182 /// - Empty map if not authenticated or no votes found 183 Future<Map<String, VoteInfo>> getUserVotes() async { 184 try { 185 final userDid = _didGetter?.call(); 186 if (userDid == null || userDid.isEmpty) { 187 return {}; 188 } 189 190 final session = await _sessionGetter?.call(); 191 if (session == null) { 192 return {}; 193 } 194 195 // TODO: Implement backend endpoint for listing user votes 196 // For now, vote state should come from feed responses 197 if (kDebugMode) { 198 debugPrint( 199 '⚠️ getUserVotes: Backend endpoint not yet implemented. ' 200 'Vote state should come from feed responses.', 201 ); 202 } 203 204 return {}; 205 } on Exception catch (e) { 206 if (kDebugMode) { 207 debugPrint('⚠️ Failed to load user votes: $e'); 208 } 209 return {}; 210 } 211 } 212 213 /// Create or toggle vote 214 /// 215 /// Sends vote request to the Coves backend, which proxies to the user's PDS. 216 /// 217 /// Parameters: 218 /// - [postUri]: AT-URI of the post 219 /// - [postCid]: Content ID of the post (for strong reference) 220 /// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote 221 /// - [existingVoteRkey]: Optional rkey from cached state 222 /// - [existingVoteDirection]: Optional direction from cached state 223 /// 224 /// Returns: 225 /// - VoteResponse with uri/cid/rkey if created 226 /// - VoteResponse with deleted=true if toggled off 227 /// 228 /// Throws: 229 /// - ApiException for API errors 230 Future<VoteResponse> createVote({ 231 required String postUri, 232 required String postCid, 233 String direction = 'up', 234 String? existingVoteRkey, 235 String? existingVoteDirection, 236 }) async { 237 try { 238 final userDid = _didGetter?.call(); 239 final session = await _sessionGetter?.call(); 240 241 if (userDid == null || userDid.isEmpty) { 242 throw ApiException('User not authenticated - no DID available'); 243 } 244 245 if (session == null) { 246 throw ApiException('User not authenticated - no session available'); 247 } 248 249 if (kDebugMode) { 250 debugPrint('🗳️ Creating vote via backend'); 251 debugPrint(' Post: $postUri'); 252 debugPrint(' Direction: $direction'); 253 } 254 255 // Determine if this is a toggle (delete) or create 256 final isToggleOff = 257 existingVoteRkey != null && existingVoteDirection == direction; 258 259 if (isToggleOff) { 260 // Delete existing vote 261 return _deleteVote( 262 session: session, 263 rkey: existingVoteRkey, 264 ); 265 } 266 267 // If switching direction, delete old vote first 268 if (existingVoteRkey != null && existingVoteDirection != null) { 269 if (kDebugMode) { 270 debugPrint(' Switching vote direction - deleting old vote first'); 271 } 272 await _deleteVote(session: session, rkey: existingVoteRkey); 273 } 274 275 // Create new vote via backend 276 // Note: Authorization header is added by the interceptor 277 final response = await _dio.post<Map<String, dynamic>>( 278 '/xrpc/social.coves.feed.vote.create', 279 data: { 280 'subject': { 281 'uri': postUri, 282 'cid': postCid, 283 }, 284 'direction': direction, 285 }, 286 ); 287 288 final data = response.data; 289 if (data == null) { 290 throw ApiException('Invalid response from server - no data'); 291 } 292 293 final uri = data['uri'] as String?; 294 final cid = data['cid'] as String?; 295 296 if (uri == null || cid == null) { 297 throw ApiException('Invalid response from server - missing uri or cid'); 298 } 299 300 // Extract rkey from URI 301 final rkey = uri.split('/').last; 302 303 if (kDebugMode) { 304 debugPrint('✅ Vote created: $uri'); 305 } 306 307 return VoteResponse(uri: uri, cid: cid, rkey: rkey, deleted: false); 308 } on DioException catch (e) { 309 if (kDebugMode) { 310 debugPrint('❌ Vote failed: ${e.message}'); 311 debugPrint(' Status: ${e.response?.statusCode}'); 312 debugPrint(' Data: ${e.response?.data}'); 313 } 314 315 if (e.response?.statusCode == 401) { 316 throw AuthenticationException( 317 'Authentication failed. Please sign in again.', 318 originalError: e, 319 ); 320 } 321 322 throw ApiException( 323 'Failed to create vote: ${e.message}', 324 statusCode: e.response?.statusCode, 325 originalError: e, 326 ); 327 } on Exception catch (e) { 328 throw ApiException('Failed to create vote: $e'); 329 } 330 } 331 332 /// Delete vote via backend 333 Future<VoteResponse> _deleteVote({ 334 required CovesSession session, 335 required String rkey, 336 }) async { 337 try { 338 // Note: Authorization header is added by the interceptor 339 await _dio.post<void>( 340 '/xrpc/social.coves.feed.vote.delete', 341 data: { 342 'rkey': rkey, 343 }, 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}