···
1
+
import 'dart:convert';
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
2
-
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'api_exceptions.dart';
···
/// - com.atproto.repo.deleteRecord (delete vote)
/// - com.atproto.repo.listRecords (find existing votes)
25
-
/// **DPoP Authentication TODO**:
26
+
/// **DPoP Authentication**:
/// 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.
31
-
/// Required for production:
28
+
/// Uses OAuthSession.fetchHandler which automatically handles:
/// - Authorization: DPoP <access_token>
/// - DPoP: <proof> (signed JWT proving key possession)
31
+
/// - Automatic token refresh on expiry
32
+
/// - Nonce management for replay protection
Future<OAuthSession?> Function()? sessionGetter,
···
String? Function()? pdsUrlGetter,
}) : _sessionGetter = sessionGetter,
41
-
_pdsUrlGetter = pdsUrlGetter {
44
-
connectTimeout: const Duration(seconds: 30),
45
-
receiveTimeout: const Duration(seconds: 30),
46
-
headers: {'Content-Type': 'application/json'},
40
+
_pdsUrlGetter = pdsUrlGetter;
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;
65
-
debugPrint('⚠️ DPoP authentication not yet implemented');
69
-
handler.next(options);
71
-
onError: (error, handler) {
73
-
debugPrint('❌ PDS API Error: ${error.message}');
74
-
debugPrint(' Status: ${error.response?.statusCode}');
75
-
debugPrint(' Data: ${error.response?.data}');
77
-
handler.next(error);
83
-
late final Dio _dio;
final Future<OAuthSession?> Function()? _sessionGetter;
final String? Function()? _didGetter;
final String? Function()? _pdsUrlGetter;
···
// Step 1: Check for existing vote
final existingVote = await _findExistingVote(
···
return const VoteResponse(deleted: true);
···
···
// Step 2: Create new vote
final response = await _createVote(
···
186
-
} on DioException catch (e) {
187
-
throw ApiException.fromDioError(e);
throw ApiException('Failed to create vote: $e');
···
/// Returns ExistingVote with direction and rkey if found, null otherwise.
Future<ExistingVote?> _findExistingVote({
200
-
required String pdsUrl,
204
-
// Query listRecords to find votes
205
-
final response = await _dio.get<Map<String, dynamic>>(
206
-
'$pdsUrl/xrpc/com.atproto.repo.listRecords',
209
-
'collection': voteCollection,
211
-
'reverse': true, // Most recent first
155
+
final session = await _sessionGetter?.call();
156
+
if (session == null) {
160
+
// Query listRecords to find votes using session's fetchHandler
161
+
final response = await session.fetchHandler(
162
+
'/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=100&reverse=true',
215
-
if (response.data == null) {
166
+
if (response.statusCode != 200) {
168
+
debugPrint('⚠️ Failed to list votes: ${response.statusCode}');
219
-
final records = response.data!['records'] as List<dynamic>?;
173
+
final data = jsonDecode(response.body) as Map<String, dynamic>;
174
+
final records = data['records'] as List<dynamic>?;
if (records == null || records.isEmpty) {
···
253
-
} on DioException catch (e) {
255
-
debugPrint('⚠️ Failed to list votes: ${e.message}');
210
+
debugPrint('⚠️ Failed to list votes: $e');
// Return null on error - assume no existing vote
···
/// Calls com.atproto.repo.createRecord with the vote record.
Future<VoteResponse> _createVote({
267
-
required String pdsUrl,
required String direction,
226
+
final session = await _sessionGetter?.call();
227
+
if (session == null) {
228
+
throw ApiException('User not authenticated - no session available');
// Build the vote record according to the lexicon
r'$type': voteCollection,
···
'createdAt': DateTime.now().toUtc().toIso8601String(),
283
-
final response = await _dio.post<Map<String, dynamic>>(
284
-
'$pdsUrl/xrpc/com.atproto.repo.createRecord',
287
-
'collection': voteCollection,
242
+
final requestBody = jsonEncode({
244
+
'collection': voteCollection,
248
+
// Use session's fetchHandler for DPoP-authenticated request
249
+
final response = await session.fetchHandler(
250
+
'/xrpc/com.atproto.repo.createRecord',
252
+
headers: {'Content-Type': 'application/json'},
292
-
if (response.data == null) {
293
-
throw ApiException('Empty response from PDS');
256
+
if (response.statusCode != 200) {
257
+
throw ApiException(
258
+
'Failed to create vote: ${response.statusCode} - ${response.body}',
259
+
statusCode: response.statusCode,
296
-
final uri = response.data!['uri'] as String?;
297
-
final cid = response.data!['cid'] as String?;
263
+
final data = jsonDecode(response.body) as Map<String, dynamic>;
264
+
final uri = data['uri'] as String?;
265
+
final cid = data['cid'] as String?;
if (uri == null || cid == null) {
throw ApiException('Invalid response from PDS - missing uri or cid');
···
/// Calls com.atproto.repo.deleteRecord to remove the vote.
Future<void> _deleteVote({
319
-
required String pdsUrl,
322
-
await _dio.post<Map<String, dynamic>>(
323
-
'$pdsUrl/xrpc/com.atproto.repo.deleteRecord',
326
-
'collection': voteCollection,
289
+
final session = await _sessionGetter?.call();
290
+
if (session == null) {
291
+
throw ApiException('User not authenticated - no session available');
294
+
final requestBody = jsonEncode({
296
+
'collection': voteCollection,
300
+
// Use session's fetchHandler for DPoP-authenticated request
301
+
final response = await session.fetchHandler(
302
+
'/xrpc/com.atproto.repo.deleteRecord',
304
+
headers: {'Content-Type': 'application/json'},
308
+
if (response.statusCode != 200) {
309
+
throw ApiException(
310
+
'Failed to delete vote: ${response.statusCode} - ${response.body}',
311
+
statusCode: response.statusCode,