1import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 2import 'package:dio/dio.dart'; 3import 'package:flutter/foundation.dart'; 4 5import 'api_exceptions.dart'; 6 7/// Vote Service 8/// 9/// Handles vote/like interactions by writing directly to the user's PDS. 10/// This follows the atProto architecture where clients write to PDSs and 11/// AppViews only index public data. 12/// 13/// **Correct Architecture**: 14/// Mobile Client → User's PDS (com.atproto.repo.createRecord) 15/// ↓ 16/// Jetstream 17/// ↓ 18/// Backend AppView (indexes vote events) 19/// 20/// Uses these XRPC endpoints: 21/// - com.atproto.repo.createRecord (create vote) 22/// - com.atproto.repo.deleteRecord (delete vote) 23/// - com.atproto.repo.listRecords (find existing votes) 24/// 25/// **DPoP Authentication TODO**: 26/// atProto PDSs require DPoP (Demonstrating Proof of Possession) authentication. 27/// The current implementation uses a placeholder that will not work with real PDSs. 28/// This needs to be implemented using OAuthSession's DPoP capabilities once 29/// available in the atproto_oauth_flutter package. 30/// 31/// Required for production: 32/// - Authorization: DPoP <access_token> 33/// - DPoP: <proof> (signed JWT proving key possession) 34class VoteService { 35 VoteService({ 36 Future<OAuthSession?> Function()? sessionGetter, 37 String? Function()? didGetter, 38 String? Function()? pdsUrlGetter, 39 }) : _sessionGetter = sessionGetter, 40 _didGetter = didGetter, 41 _pdsUrlGetter = pdsUrlGetter { 42 _dio = Dio( 43 BaseOptions( 44 connectTimeout: const Duration(seconds: 30), 45 receiveTimeout: const Duration(seconds: 30), 46 headers: {'Content-Type': 'application/json'}, 47 ), 48 ); 49 50 // TODO: Add DPoP auth interceptor 51 // atProto PDSs require DPoP authentication, not Bearer tokens 52 // This needs implementation using OAuthSession's DPoP support 53 _dio.interceptors.add( 54 InterceptorsWrapper( 55 onRequest: (options, handler) async { 56 // PLACEHOLDER: This does not implement DPoP authentication 57 // and will fail on real PDSs with "Malformed token" errors 58 if (_sessionGetter != null) { 59 final session = await _sessionGetter(); 60 if (session != null) { 61 // TODO: Generate DPoP proof and set headers: 62 // options.headers['Authorization'] = 'DPoP ${session.accessToken}'; 63 // options.headers['DPoP'] = dpopProof; 64 if (kDebugMode) { 65 debugPrint('⚠️ DPoP authentication not yet implemented'); 66 } 67 } 68 } 69 handler.next(options); 70 }, 71 onError: (error, handler) { 72 if (kDebugMode) { 73 debugPrint('❌ PDS API Error: ${error.message}'); 74 debugPrint(' Status: ${error.response?.statusCode}'); 75 debugPrint(' Data: ${error.response?.data}'); 76 } 77 handler.next(error); 78 }, 79 ), 80 ); 81 } 82 83 late final Dio _dio; 84 final Future<OAuthSession?> Function()? _sessionGetter; 85 final String? Function()? _didGetter; 86 final String? Function()? _pdsUrlGetter; 87 88 /// Collection name for vote records 89 static const String voteCollection = 'social.coves.interaction.vote'; 90 91 /// Create or toggle vote 92 /// 93 /// Implements smart toggle logic: 94 /// 1. Query PDS for existing vote on this post 95 /// 2. If exists with same direction → Delete (toggle off) 96 /// 3. If exists with different direction → Delete old + Create new 97 /// 4. If no existing vote → Create new 98 /// 99 /// Parameters: 100 /// - [postUri]: AT-URI of the post (e.g., 101 /// "at://did:plc:xyz/social.coves.post.record/abc123") 102 /// - [postCid]: Content ID of the post (for strong reference) 103 /// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote 104 /// 105 /// Returns: 106 /// - VoteResponse with uri/cid/rkey if created 107 /// - VoteResponse with deleted=true if toggled off 108 /// 109 /// Throws: 110 /// - ApiException for API errors 111 Future<VoteResponse> createVote({ 112 required String postUri, 113 required String postCid, 114 String direction = 'up', 115 }) async { 116 try { 117 // Get user's DID and PDS URL 118 final userDid = _didGetter?.call(); 119 final pdsUrl = _pdsUrlGetter?.call(); 120 121 if (userDid == null || userDid.isEmpty) { 122 throw ApiException('User not authenticated - no DID available'); 123 } 124 125 if (pdsUrl == null || pdsUrl.isEmpty) { 126 throw ApiException('PDS URL not available'); 127 } 128 129 if (kDebugMode) { 130 debugPrint('🗳️ Creating vote on PDS'); 131 debugPrint(' Post: $postUri'); 132 debugPrint(' Direction: $direction'); 133 debugPrint(' PDS: $pdsUrl'); 134 } 135 136 // Step 1: Check for existing vote 137 final existingVote = await _findExistingVote( 138 userDid: userDid, 139 pdsUrl: pdsUrl, 140 postUri: postUri, 141 ); 142 143 if (existingVote != null) { 144 if (kDebugMode) { 145 debugPrint(' Found existing vote: ${existingVote.direction}'); 146 } 147 148 // If same direction, toggle off (delete) 149 if (existingVote.direction == direction) { 150 if (kDebugMode) { 151 debugPrint(' Same direction - deleting vote'); 152 } 153 await _deleteVote( 154 userDid: userDid, 155 pdsUrl: pdsUrl, 156 rkey: existingVote.rkey, 157 ); 158 return const VoteResponse(deleted: true); 159 } 160 161 // Different direction - delete old vote first 162 if (kDebugMode) { 163 debugPrint(' Different direction - switching vote'); 164 } 165 await _deleteVote( 166 userDid: userDid, 167 pdsUrl: pdsUrl, 168 rkey: existingVote.rkey, 169 ); 170 } 171 172 // Step 2: Create new vote 173 final response = await _createVote( 174 userDid: userDid, 175 pdsUrl: pdsUrl, 176 postUri: postUri, 177 postCid: postCid, 178 direction: direction, 179 ); 180 181 if (kDebugMode) { 182 debugPrint('✅ Vote created: ${response.uri}'); 183 } 184 185 return response; 186 } on DioException catch (e) { 187 throw ApiException.fromDioError(e); 188 } catch (e) { 189 throw ApiException('Failed to create vote: $e'); 190 } 191 } 192 193 /// Find existing vote for a post 194 /// 195 /// Queries the user's PDS to check if they've already voted on this post. 196 /// 197 /// Returns ExistingVote with direction and rkey if found, null otherwise. 198 Future<ExistingVote?> _findExistingVote({ 199 required String userDid, 200 required String pdsUrl, 201 required String postUri, 202 }) async { 203 try { 204 // Query listRecords to find votes 205 final response = await _dio.get<Map<String, dynamic>>( 206 '$pdsUrl/xrpc/com.atproto.repo.listRecords', 207 queryParameters: { 208 'repo': userDid, 209 'collection': voteCollection, 210 'limit': 100, 211 'reverse': true, // Most recent first 212 }, 213 ); 214 215 if (response.data == null) { 216 return null; 217 } 218 219 final records = response.data!['records'] as List<dynamic>?; 220 if (records == null || records.isEmpty) { 221 return null; 222 } 223 224 // Find vote for this specific post 225 for (final record in records) { 226 final recordMap = record as Map<String, dynamic>; 227 final value = recordMap['value'] as Map<String, dynamic>?; 228 229 if (value == null) { 230 continue; 231 } 232 233 final subject = value['subject'] as Map<String, dynamic>?; 234 if (subject == null) { 235 continue; 236 } 237 238 final subjectUri = subject['uri'] as String?; 239 if (subjectUri == postUri) { 240 // Found existing vote! 241 final direction = value['direction'] as String; 242 final uri = recordMap['uri'] as String; 243 244 // Extract rkey from URI 245 // Format: at://did:plc:xyz/social.coves.interaction.vote/3kby... 246 final rkey = uri.split('/').last; 247 248 return ExistingVote(direction: direction, rkey: rkey); 249 } 250 } 251 252 return null; 253 } on DioException catch (e) { 254 if (kDebugMode) { 255 debugPrint('⚠️ Failed to list votes: ${e.message}'); 256 } 257 // Return null on error - assume no existing vote 258 return null; 259 } 260 } 261 262 /// Create vote record on PDS 263 /// 264 /// Calls com.atproto.repo.createRecord with the vote record. 265 Future<VoteResponse> _createVote({ 266 required String userDid, 267 required String pdsUrl, 268 required String postUri, 269 required String postCid, 270 required String direction, 271 }) async { 272 // Build the vote record according to the lexicon 273 final record = { 274 r'$type': voteCollection, 275 'subject': { 276 'uri': postUri, 277 'cid': postCid, 278 }, 279 'direction': direction, 280 'createdAt': DateTime.now().toUtc().toIso8601String(), 281 }; 282 283 final response = await _dio.post<Map<String, dynamic>>( 284 '$pdsUrl/xrpc/com.atproto.repo.createRecord', 285 data: { 286 'repo': userDid, 287 'collection': voteCollection, 288 'record': record, 289 }, 290 ); 291 292 if (response.data == null) { 293 throw ApiException('Empty response from PDS'); 294 } 295 296 final uri = response.data!['uri'] as String?; 297 final cid = response.data!['cid'] as String?; 298 299 if (uri == null || cid == null) { 300 throw ApiException('Invalid response from PDS - missing uri or cid'); 301 } 302 303 // Extract rkey from URI 304 final rkey = uri.split('/').last; 305 306 return VoteResponse( 307 uri: uri, 308 cid: cid, 309 rkey: rkey, 310 deleted: false, 311 ); 312 } 313 314 /// Delete vote record from PDS 315 /// 316 /// Calls com.atproto.repo.deleteRecord to remove the vote. 317 Future<void> _deleteVote({ 318 required String userDid, 319 required String pdsUrl, 320 required String rkey, 321 }) async { 322 await _dio.post<Map<String, dynamic>>( 323 '$pdsUrl/xrpc/com.atproto.repo.deleteRecord', 324 data: { 325 'repo': userDid, 326 'collection': voteCollection, 327 'rkey': rkey, 328 }, 329 ); 330 } 331} 332 333/// Vote Response 334/// 335/// Response from createVote operation. 336class VoteResponse { 337 const VoteResponse({ 338 this.uri, 339 this.cid, 340 this.rkey, 341 required this.deleted, 342 }); 343 344 /// AT-URI of the created vote record 345 final String? uri; 346 347 /// Content ID of the vote record 348 final String? cid; 349 350 /// Record key (rkey) of the vote - last segment of URI 351 final String? rkey; 352 353 /// Whether the vote was deleted (toggled off) 354 final bool deleted; 355} 356 357/// Existing Vote 358/// 359/// Represents a vote that already exists on the PDS. 360class ExistingVote { 361 const ExistingVote({required this.direction, required this.rkey}); 362 363 /// Vote direction ("up" or "down") 364 final String direction; 365 366 /// Record key for deletion 367 final String rkey; 368}