refactor(votes): simplify vote service and provider for backend toggle

Vote Service:
- Remove deleteVote method - backend handles toggle logic
- Remove getUserVotes - vote state now comes from feed viewer data
- Remove unused ExistingVote and VoteInfo classes
- Handle empty uri/cid response as successful toggle-off
- Use shared extractRkeyFromUri utility

Vote Provider:
- Remove existingVoteRkey/Direction params from createVote call
- Remove loadInitialVotes - replaced by setInitialVoteState per-post
- Add extractRkeyFromUri static utility to VoteState
- Clear score adjustments in setInitialVoteState to prevent double-counting

This aligns with the backend's vote cache approach where viewer state
is populated from PDS on each request rather than relying on the
eventually-consistent AppView index.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+38 -185
lib
+20 -51
lib/providers/vote_provider.dart
···
import 'package:flutter/foundation.dart';
import '../services/api_exceptions.dart';
-
import '../services/vote_service.dart' show VoteService, VoteInfo;
+
import '../services/vote_service.dart' show VoteService;
import 'auth_provider.dart';
/// Vote Provider
···
_pendingRequests[postUri] = true;
try {
-
// Make API call - pass existing vote info to avoid O(n) PDS lookup
+
// Make API call
final response = await _voteService.createVote(
postUri: postUri,
postCid: postCid,
direction: direction,
-
existingVoteRkey: currentState?.rkey,
-
existingVoteDirection: currentState?.direction,
);
// Update with server response
···
String? voteUri,
}) {
if (voteDirection != null) {
-
// Extract rkey from vote URI if available
-
// URI format: at://did:plc:xyz/social.coves.feed.vote/3kby...
-
String? rkey;
-
if (voteUri != null) {
-
final parts = voteUri.split('/');
-
if (parts.isNotEmpty) {
-
rkey = parts.last;
-
}
-
}
-
_votes[postUri] = VoteState(
direction: voteDirection,
uri: voteUri,
-
rkey: rkey,
+
rkey: VoteState.extractRkeyFromUri(voteUri),
deleted: false,
);
} else {
_votes.remove(postUri);
}
-
// Don't notify listeners - this is just initial state
-
}
-
/// Load initial vote states from a map of votes
-
///
-
/// This is used to bulk-load vote state after querying the user's PDS.
-
/// Typically called after loading feed posts to fill in which posts
-
/// the user has voted on.
-
///
-
/// IMPORTANT: This clears score adjustments since the server score
-
/// already reflects the loaded votes. If we kept stale adjustments,
-
/// we'd double-count votes (server score + our adjustment).
-
///
-
/// Parameters:
-
/// - [votes]: Map of post URI -> vote info from VoteService.getUserVotes()
-
void loadInitialVotes(Map<String, VoteInfo> votes) {
-
for (final entry in votes.entries) {
-
final postUri = entry.key;
-
final voteInfo = entry.value;
-
-
_votes[postUri] = VoteState(
-
direction: voteInfo.direction,
-
uri: voteInfo.voteUri,
-
rkey: voteInfo.rkey,
-
deleted: false,
-
);
+
// IMPORTANT: Clear any stale score adjustment for this post.
+
// When we receive fresh data from the server (via feed/comments refresh),
+
// the server's score already reflects the actual vote state. Any local
+
// delta from a previous optimistic update is now stale and would cause
+
// double-counting (e.g., server score already includes +1, plus our +1).
+
_scoreAdjustments.remove(postUri);
-
// Clear any stale score adjustments for this post
-
// The server score already includes this vote
-
_scoreAdjustments.remove(postUri);
-
}
-
-
if (kDebugMode) {
-
debugPrint('📊 Initialized ${votes.length} vote states');
-
}
-
-
// Notify once after loading all votes
-
notifyListeners();
+
// Don't notify listeners - this is just initial state
}
/// Clear all vote state (e.g., on sign out)
···
/// Whether the vote has been deleted
final bool deleted;
+
+
/// Extract rkey (record key) from an AT-URI
+
///
+
/// AT-URI format: at://did:plc:xyz/social.coves.feed.vote/3kby...
+
/// Returns the last segment (rkey) or null if URI is null/invalid.
+
static String? extractRkeyFromUri(String? uri) {
+
if (uri == null) return null;
+
final parts = uri.split('/');
+
return parts.isNotEmpty ? parts.last : null;
+
}
}
+18 -134
lib/services/vote_service.dart
···
import '../config/environment_config.dart';
import '../models/coves_session.dart';
+
import '../providers/vote_provider.dart' show VoteState;
import 'api_exceptions.dart';
/// Vote Service
···
/// 1. Unseals the token to get the actual access/refresh tokens
/// 2. Uses stored DPoP keys to sign requests
/// 3. Writes to the user's PDS on their behalf
+
/// 4. Handles toggle logic (creating, deleting, or switching vote direction)
///
-
/// TODO: Backend vote endpoints need to be implemented:
-
/// - POST /xrpc/social.coves.feed.vote.create
-
/// - POST /xrpc/social.coves.feed.vote.delete
-
/// - GET /xrpc/social.coves.feed.vote.list (or included in feed response)
+
/// **Backend Endpoints**:
+
/// - POST /xrpc/social.coves.feed.vote.create - Creates, toggles, or switches votes
class VoteService {
VoteService({
Future<CovesSession?> Function()? sessionGetter,
···
/// Collection name for vote records
static const String voteCollection = 'social.coves.feed.vote';
-
/// Get all votes for the current user
-
///
-
/// TODO: This needs a backend endpoint to list user's votes.
-
/// For now, returns empty map - votes will be fetched with feed data.
-
///
-
/// Returns:
-
/// - `Map<String, VoteInfo>` where key is the post URI
-
/// - Empty map if not authenticated or no votes found
-
Future<Map<String, VoteInfo>> getUserVotes() async {
-
try {
-
final userDid = _didGetter?.call();
-
if (userDid == null || userDid.isEmpty) {
-
return {};
-
}
-
-
final session = await _sessionGetter?.call();
-
if (session == null) {
-
return {};
-
}
-
-
// TODO: Implement backend endpoint for listing user votes
-
// For now, vote state should come from feed responses
-
if (kDebugMode) {
-
debugPrint(
-
'⚠️ getUserVotes: Backend endpoint not yet implemented. '
-
'Vote state should come from feed responses.',
-
);
-
}
-
-
return {};
-
} on Exception catch (e) {
-
if (kDebugMode) {
-
debugPrint('⚠️ Failed to load user votes: $e');
-
}
-
return {};
-
}
-
}
-
/// Create or toggle vote
///
-
/// Sends vote request to the Coves backend, which proxies to the user's PDS.
+
/// Sends vote request to the Coves backend, which handles toggle logic.
+
/// The backend will create a vote if none exists, or toggle it off if
+
/// voting the same direction again.
///
/// Parameters:
/// - [postUri]: AT-URI of the post
/// - [postCid]: Content ID of the post (for strong reference)
/// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote
-
/// - [existingVoteRkey]: Optional rkey from cached state
-
/// - [existingVoteDirection]: Optional direction from cached state
///
/// Returns:
-
/// - VoteResponse with uri/cid/rkey if created
-
/// - VoteResponse with deleted=true if toggled off
+
/// - VoteResponse with uri/cid/rkey if vote was created
+
/// - VoteResponse with deleted=true if vote was toggled off (empty uri/cid)
///
/// Throws:
/// - ApiException for API errors
···
required String postUri,
required String postCid,
String direction = 'up',
-
String? existingVoteRkey,
-
String? existingVoteDirection,
}) async {
try {
final userDid = _didGetter?.call();
···
debugPrint(' Direction: $direction');
}
-
// Determine if this is a toggle (delete) or create
-
final isToggleOff =
-
existingVoteRkey != null && existingVoteDirection == direction;
-
-
if (isToggleOff) {
-
// Delete existing vote
-
return _deleteVote(session: session, rkey: existingVoteRkey);
-
}
-
-
// If switching direction, delete old vote first
-
if (existingVoteRkey != null && existingVoteDirection != null) {
-
if (kDebugMode) {
-
debugPrint(' Switching vote direction - deleting old vote first');
-
}
-
await _deleteVote(session: session, rkey: existingVoteRkey);
-
}
-
-
// Create new vote via backend
+
// Send vote request to backend
// Note: Authorization header is added by the interceptor
final response = await _dio.post<Map<String, dynamic>>(
'/xrpc/social.coves.feed.vote.create',
···
final uri = data['uri'] as String?;
final cid = data['cid'] as String?;
-
if (uri == null || cid == null) {
-
throw ApiException('Invalid response from server - missing uri or cid');
+
// If uri/cid are empty, the backend toggled off an existing vote
+
if (uri == null || uri.isEmpty || cid == null || cid.isEmpty) {
+
if (kDebugMode) {
+
debugPrint('✅ Vote toggled off (deleted)');
+
}
+
return const VoteResponse(deleted: true);
}
-
// Extract rkey from URI
-
final rkey = uri.split('/').last;
+
// Extract rkey from URI using shared utility
+
final rkey = VoteState.extractRkeyFromUri(uri);
if (kDebugMode) {
debugPrint('✅ Vote created: $uri');
···
throw ApiException('Failed to create vote: $e');
}
}
-
-
/// Delete vote via backend
-
Future<VoteResponse> _deleteVote({
-
required CovesSession session,
-
required String rkey,
-
}) async {
-
try {
-
// Note: Authorization header is added by the interceptor
-
await _dio.post<void>(
-
'/xrpc/social.coves.feed.vote.delete',
-
data: {'rkey': rkey},
-
);
-
-
if (kDebugMode) {
-
debugPrint('✅ Vote deleted');
-
}
-
-
return const VoteResponse(deleted: true);
-
} on DioException catch (e) {
-
if (kDebugMode) {
-
debugPrint('❌ Delete vote failed: ${e.message}');
-
}
-
-
throw ApiException(
-
'Failed to delete vote: ${e.message}',
-
statusCode: e.response?.statusCode,
-
originalError: e,
-
);
-
}
-
}
}
/// Vote Response
···
/// Whether the vote was deleted (toggled off)
final bool deleted;
}
-
-
/// Existing Vote
-
///
-
/// Represents a vote that already exists on the PDS.
-
class ExistingVote {
-
const ExistingVote({required this.direction, required this.rkey});
-
-
/// Vote direction ("up" or "down")
-
final String direction;
-
-
/// Record key for deletion
-
final String rkey;
-
}
-
-
/// Vote Info
-
///
-
/// Information about a user's vote on a post, returned from getUserVotes().
-
class VoteInfo {
-
const VoteInfo({
-
required this.direction,
-
required this.voteUri,
-
required this.rkey,
-
});
-
-
/// Vote direction ("up" or "down")
-
final String direction;
-
-
/// AT-URI of the vote record
-
final String voteUri;
-
-
/// Record key (rkey) - last segment of URI
-
final String rkey;
-
}