Main coves client
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}