···
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
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)
-
/// **DPoP Authentication TODO**:
/// atProto PDSs require DPoP (Demonstrating Proof of Possession) authentication.
-
/// The current implementation uses a placeholder that will not work with real PDSs.
-
/// This needs to be implemented using OAuthSession's DPoP capabilities once
-
/// available in the atproto_oauth_flutter package.
-
/// Required for production:
/// - Authorization: DPoP <access_token>
/// - DPoP: <proof> (signed JWT proving key possession)
Future<OAuthSession?> Function()? sessionGetter,
···
String? Function()? pdsUrlGetter,
}) : _sessionGetter = sessionGetter,
-
_pdsUrlGetter = pdsUrlGetter {
-
connectTimeout: const Duration(seconds: 30),
-
receiveTimeout: const Duration(seconds: 30),
-
headers: {'Content-Type': 'application/json'},
-
// TODO: Add DPoP auth interceptor
-
// atProto PDSs require DPoP authentication, not Bearer tokens
-
// This needs implementation using OAuthSession's DPoP support
-
onRequest: (options, handler) async {
-
// PLACEHOLDER: This does not implement DPoP authentication
-
// and will fail on real PDSs with "Malformed token" errors
-
if (_sessionGetter != null) {
-
final session = await _sessionGetter();
-
// TODO: Generate DPoP proof and set headers:
-
// options.headers['Authorization'] = 'DPoP ${session.accessToken}';
-
// options.headers['DPoP'] = dpopProof;
-
debugPrint('⚠️ DPoP authentication not yet implemented');
-
onError: (error, handler) {
-
debugPrint('❌ PDS API Error: ${error.message}');
-
debugPrint(' Status: ${error.response?.statusCode}');
-
debugPrint(' Data: ${error.response?.data}');
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(
···
-
} on DioException catch (e) {
-
throw ApiException.fromDioError(e);
throw ApiException('Failed to create vote: $e');
···
/// Returns ExistingVote with direction and rkey if found, null otherwise.
Future<ExistingVote?> _findExistingVote({
-
required String pdsUrl,
-
// Query listRecords to find votes
-
final response = await _dio.get<Map<String, dynamic>>(
-
'$pdsUrl/xrpc/com.atproto.repo.listRecords',
-
'collection': voteCollection,
-
'reverse': true, // Most recent first
-
if (response.data == null) {
-
final records = response.data!['records'] as List<dynamic>?;
if (records == null || records.isEmpty) {
···
-
} on DioException catch (e) {
-
debugPrint('⚠️ Failed to list votes: ${e.message}');
// Return null on error - assume no existing vote
···
/// Calls com.atproto.repo.createRecord with the vote record.
Future<VoteResponse> _createVote({
-
required String pdsUrl,
required String direction,
// Build the vote record according to the lexicon
r'$type': voteCollection,
···
'createdAt': DateTime.now().toUtc().toIso8601String(),
-
final response = await _dio.post<Map<String, dynamic>>(
-
'$pdsUrl/xrpc/com.atproto.repo.createRecord',
-
'collection': voteCollection,
-
if (response.data == null) {
-
throw ApiException('Empty response from PDS');
-
final uri = response.data!['uri'] as String?;
-
final cid = response.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({
-
required String pdsUrl,
-
await _dio.post<Map<String, dynamic>>(
-
'$pdsUrl/xrpc/com.atproto.repo.deleteRecord',
-
'collection': voteCollection,
···
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
import 'package:flutter/foundation.dart';
import 'api_exceptions.dart';
···
/// - com.atproto.repo.deleteRecord (delete vote)
/// - com.atproto.repo.listRecords (find existing votes)
+
/// **DPoP Authentication**:
/// atProto PDSs require DPoP (Demonstrating Proof of Possession) authentication.
+
/// Uses OAuthSession.fetchHandler which automatically handles:
/// - Authorization: DPoP <access_token>
/// - DPoP: <proof> (signed JWT proving key possession)
+
/// - Automatic token refresh on expiry
+
/// - Nonce management for replay protection
Future<OAuthSession?> Function()? sessionGetter,
···
String? Function()? pdsUrlGetter,
}) : _sessionGetter = sessionGetter,
+
_pdsUrlGetter = pdsUrlGetter;
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(
···
throw ApiException('Failed to create vote: $e');
···
/// Returns ExistingVote with direction and rkey if found, null otherwise.
Future<ExistingVote?> _findExistingVote({
+
final session = await _sessionGetter?.call();
+
// Query listRecords to find votes using session's fetchHandler
+
final response = await session.fetchHandler(
+
'/xrpc/com.atproto.repo.listRecords?repo=$userDid&collection=$voteCollection&limit=100&reverse=true',
+
if (response.statusCode != 200) {
+
debugPrint('⚠️ Failed to list votes: ${response.statusCode}');
+
final data = jsonDecode(response.body) as Map<String, dynamic>;
+
final records = data['records'] as List<dynamic>?;
if (records == null || records.isEmpty) {
···
+
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({
required String direction,
+
final session = await _sessionGetter?.call();
+
throw ApiException('User not authenticated - no session available');
// Build the vote record according to the lexicon
r'$type': voteCollection,
···
'createdAt': DateTime.now().toUtc().toIso8601String(),
+
final requestBody = jsonEncode({
+
'collection': voteCollection,
+
// Use session's fetchHandler for DPoP-authenticated request
+
final response = await session.fetchHandler(
+
'/xrpc/com.atproto.repo.createRecord',
+
headers: {'Content-Type': 'application/json'},
+
if (response.statusCode != 200) {
+
'Failed to create vote: ${response.statusCode} - ${response.body}',
+
statusCode: response.statusCode,
+
final data = jsonDecode(response.body) as Map<String, dynamic>;
+
final uri = data['uri'] as String?;
+
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({
+
final session = await _sessionGetter?.call();
+
throw ApiException('User not authenticated - no session available');
+
final requestBody = jsonEncode({
+
'collection': voteCollection,
+
// Use session's fetchHandler for DPoP-authenticated request
+
final response = await session.fetchHandler(
+
'/xrpc/com.atproto.repo.deleteRecord',
+
headers: {'Content-Type': 'application/json'},
+
if (response.statusCode != 200) {
+
'Failed to delete vote: ${response.statusCode} - ${response.body}',
+
statusCode: response.statusCode,