at main 5.7 kB view raw
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'; 8import 'auth_interceptor.dart'; 9 10/// Vote Service 11/// 12/// Handles vote/like interactions through the Coves backend. 13/// 14/// **Architecture with Backend OAuth**: 15/// With sealed tokens, the client cannot write directly to the user's PDS 16/// (no DPoP keys available). Instead, votes go through the Coves backend: 17/// 18/// Mobile Client → Coves Backend (sealed token) → User's PDS (DPoP) 19/// 20/// The backend: 21/// 1. Unseals the token to get the actual access/refresh tokens 22/// 2. Uses stored DPoP keys to sign requests 23/// 3. Writes to the user's PDS on their behalf 24/// 4. Handles toggle logic (creating, deleting, or switching vote direction) 25/// 26/// **Backend Endpoints**: 27/// - POST /xrpc/social.coves.feed.vote.create - Creates, toggles, or switches 28/// votes 29class VoteService { 30 VoteService({ 31 Future<CovesSession?> Function()? sessionGetter, 32 String? Function()? didGetter, 33 Future<bool> Function()? tokenRefresher, 34 Future<void> Function()? signOutHandler, 35 Dio? dio, 36 }) : _sessionGetter = sessionGetter, 37 _didGetter = didGetter { 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 shared 401 retry interceptor 50 _dio.interceptors.add( 51 createAuthInterceptor( 52 sessionGetter: sessionGetter, 53 tokenRefresher: tokenRefresher, 54 signOutHandler: signOutHandler, 55 serviceName: 'VoteService', 56 dio: _dio, 57 ), 58 ); 59 } 60 61 final Future<CovesSession?> Function()? _sessionGetter; 62 final String? Function()? _didGetter; 63 late final Dio _dio; 64 65 /// Collection name for vote records 66 static const String voteCollection = 'social.coves.feed.vote'; 67 68 /// Create or toggle vote 69 /// 70 /// Sends vote request to the Coves backend, which handles toggle logic. 71 /// The backend will create a vote if none exists, or toggle it off if 72 /// voting the same direction again. 73 /// 74 /// Parameters: 75 /// - [postUri]: AT-URI of the post 76 /// - [postCid]: Content ID of the post (for strong reference) 77 /// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote 78 /// 79 /// Returns: 80 /// - VoteResponse with uri/cid/rkey if vote was created 81 /// - VoteResponse with deleted=true if vote was toggled off (empty uri/cid) 82 /// 83 /// Throws: 84 /// - ApiException for API errors 85 Future<VoteResponse> createVote({ 86 required String postUri, 87 required String postCid, 88 String direction = 'up', 89 }) async { 90 try { 91 final userDid = _didGetter?.call(); 92 final session = await _sessionGetter?.call(); 93 94 if (userDid == null || userDid.isEmpty) { 95 throw ApiException('User not authenticated - no DID available'); 96 } 97 98 if (session == null) { 99 throw ApiException('User not authenticated - no session available'); 100 } 101 102 if (kDebugMode) { 103 debugPrint('🗳️ Creating vote via backend'); 104 debugPrint(' Post: $postUri'); 105 debugPrint(' Direction: $direction'); 106 } 107 108 // Send vote request to backend 109 // Note: Authorization header is added by the interceptor 110 final response = await _dio.post<Map<String, dynamic>>( 111 '/xrpc/social.coves.feed.vote.create', 112 data: { 113 'subject': {'uri': postUri, 'cid': postCid}, 114 'direction': direction, 115 }, 116 ); 117 118 final data = response.data; 119 if (data == null) { 120 throw ApiException('Invalid response from server - no data'); 121 } 122 123 final uri = data['uri'] as String?; 124 final cid = data['cid'] as String?; 125 126 // If uri/cid are empty, the backend toggled off an existing vote 127 if (uri == null || uri.isEmpty || cid == null || cid.isEmpty) { 128 if (kDebugMode) { 129 debugPrint('✅ Vote toggled off (deleted)'); 130 } 131 return const VoteResponse(deleted: true); 132 } 133 134 // Extract rkey from URI using shared utility 135 final rkey = VoteState.extractRkeyFromUri(uri); 136 137 if (kDebugMode) { 138 debugPrint('✅ Vote created: $uri'); 139 } 140 141 return VoteResponse(uri: uri, cid: cid, rkey: rkey, deleted: false); 142 } on DioException catch (e) { 143 if (kDebugMode) { 144 debugPrint('❌ Vote failed: ${e.message}'); 145 debugPrint(' Status: ${e.response?.statusCode}'); 146 debugPrint(' Data: ${e.response?.data}'); 147 } 148 149 if (e.response?.statusCode == 401) { 150 throw AuthenticationException( 151 'Authentication failed. Please sign in again.', 152 originalError: e, 153 ); 154 } 155 156 throw ApiException( 157 'Failed to create vote: ${e.message}', 158 statusCode: e.response?.statusCode, 159 originalError: e, 160 ); 161 } on ApiException { 162 rethrow; 163 } on Exception catch (e) { 164 throw ApiException('Failed to create vote: $e'); 165 } 166 } 167} 168 169/// Vote Response 170/// 171/// Response from createVote operation. 172class VoteResponse { 173 const VoteResponse({this.uri, this.cid, this.rkey, required this.deleted}); 174 175 /// AT-URI of the created vote record 176 final String? uri; 177 178 /// Content ID of the vote record 179 final String? cid; 180 181 /// Record key (rkey) of the vote - last segment of URI 182 final String? rkey; 183 184 /// Whether the vote was deleted (toggled off) 185 final bool deleted; 186}