Main coves client

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>

+38 -185
+20 -51
lib/providers/vote_provider.dart
··· 1 1 import 'package:flutter/foundation.dart'; 2 2 3 3 import '../services/api_exceptions.dart'; 4 - import '../services/vote_service.dart' show VoteService, VoteInfo; 4 + import '../services/vote_service.dart' show VoteService; 5 5 import 'auth_provider.dart'; 6 6 7 7 /// Vote Provider ··· 164 164 _pendingRequests[postUri] = true; 165 165 166 166 try { 167 - // Make API call - pass existing vote info to avoid O(n) PDS lookup 167 + // Make API call 168 168 final response = await _voteService.createVote( 169 169 postUri: postUri, 170 170 postCid: postCid, 171 171 direction: direction, 172 - existingVoteRkey: currentState?.rkey, 173 - existingVoteDirection: currentState?.direction, 174 172 ); 175 173 176 174 // Update with server response ··· 231 229 String? voteUri, 232 230 }) { 233 231 if (voteDirection != null) { 234 - // Extract rkey from vote URI if available 235 - // URI format: at://did:plc:xyz/social.coves.feed.vote/3kby... 236 - String? rkey; 237 - if (voteUri != null) { 238 - final parts = voteUri.split('/'); 239 - if (parts.isNotEmpty) { 240 - rkey = parts.last; 241 - } 242 - } 243 - 244 232 _votes[postUri] = VoteState( 245 233 direction: voteDirection, 246 234 uri: voteUri, 247 - rkey: rkey, 235 + rkey: VoteState.extractRkeyFromUri(voteUri), 248 236 deleted: false, 249 237 ); 250 238 } else { 251 239 _votes.remove(postUri); 252 240 } 253 - // Don't notify listeners - this is just initial state 254 - } 255 241 256 - /// Load initial vote states from a map of votes 257 - /// 258 - /// This is used to bulk-load vote state after querying the user's PDS. 259 - /// Typically called after loading feed posts to fill in which posts 260 - /// the user has voted on. 261 - /// 262 - /// IMPORTANT: This clears score adjustments since the server score 263 - /// already reflects the loaded votes. If we kept stale adjustments, 264 - /// we'd double-count votes (server score + our adjustment). 265 - /// 266 - /// Parameters: 267 - /// - [votes]: Map of post URI -> vote info from VoteService.getUserVotes() 268 - void loadInitialVotes(Map<String, VoteInfo> votes) { 269 - for (final entry in votes.entries) { 270 - final postUri = entry.key; 271 - final voteInfo = entry.value; 272 - 273 - _votes[postUri] = VoteState( 274 - direction: voteInfo.direction, 275 - uri: voteInfo.voteUri, 276 - rkey: voteInfo.rkey, 277 - deleted: false, 278 - ); 242 + // IMPORTANT: Clear any stale score adjustment for this post. 243 + // When we receive fresh data from the server (via feed/comments refresh), 244 + // the server's score already reflects the actual vote state. Any local 245 + // delta from a previous optimistic update is now stale and would cause 246 + // double-counting (e.g., server score already includes +1, plus our +1). 247 + _scoreAdjustments.remove(postUri); 279 248 280 - // Clear any stale score adjustments for this post 281 - // The server score already includes this vote 282 - _scoreAdjustments.remove(postUri); 283 - } 284 - 285 - if (kDebugMode) { 286 - debugPrint('📊 Initialized ${votes.length} vote states'); 287 - } 288 - 289 - // Notify once after loading all votes 290 - notifyListeners(); 249 + // Don't notify listeners - this is just initial state 291 250 } 292 251 293 252 /// Clear all vote state (e.g., on sign out) ··· 323 282 324 283 /// Whether the vote has been deleted 325 284 final bool deleted; 285 + 286 + /// Extract rkey (record key) from an AT-URI 287 + /// 288 + /// AT-URI format: at://did:plc:xyz/social.coves.feed.vote/3kby... 289 + /// Returns the last segment (rkey) or null if URI is null/invalid. 290 + static String? extractRkeyFromUri(String? uri) { 291 + if (uri == null) return null; 292 + final parts = uri.split('/'); 293 + return parts.isNotEmpty ? parts.last : null; 294 + } 326 295 }
+18 -134
lib/services/vote_service.dart
··· 3 3 4 4 import '../config/environment_config.dart'; 5 5 import '../models/coves_session.dart'; 6 + import '../providers/vote_provider.dart' show VoteState; 6 7 import 'api_exceptions.dart'; 7 8 8 9 /// Vote Service ··· 19 20 /// 1. Unseals the token to get the actual access/refresh tokens 20 21 /// 2. Uses stored DPoP keys to sign requests 21 22 /// 3. Writes to the user's PDS on their behalf 23 + /// 4. Handles toggle logic (creating, deleting, or switching vote direction) 22 24 /// 23 - /// TODO: Backend vote endpoints need to be implemented: 24 - /// - POST /xrpc/social.coves.feed.vote.create 25 - /// - POST /xrpc/social.coves.feed.vote.delete 26 - /// - GET /xrpc/social.coves.feed.vote.list (or included in feed response) 25 + /// **Backend Endpoints**: 26 + /// - POST /xrpc/social.coves.feed.vote.create - Creates, toggles, or switches votes 27 27 class VoteService { 28 28 VoteService({ 29 29 Future<CovesSession?> Function()? sessionGetter, ··· 180 180 /// Collection name for vote records 181 181 static const String voteCollection = 'social.coves.feed.vote'; 182 182 183 - /// Get all votes for the current user 184 - /// 185 - /// TODO: This needs a backend endpoint to list user's votes. 186 - /// For now, returns empty map - votes will be fetched with feed data. 187 - /// 188 - /// Returns: 189 - /// - `Map<String, VoteInfo>` where key is the post URI 190 - /// - Empty map if not authenticated or no votes found 191 - Future<Map<String, VoteInfo>> getUserVotes() async { 192 - try { 193 - final userDid = _didGetter?.call(); 194 - if (userDid == null || userDid.isEmpty) { 195 - return {}; 196 - } 197 - 198 - final session = await _sessionGetter?.call(); 199 - if (session == null) { 200 - return {}; 201 - } 202 - 203 - // TODO: Implement backend endpoint for listing user votes 204 - // For now, vote state should come from feed responses 205 - if (kDebugMode) { 206 - debugPrint( 207 - '⚠️ getUserVotes: Backend endpoint not yet implemented. ' 208 - 'Vote state should come from feed responses.', 209 - ); 210 - } 211 - 212 - return {}; 213 - } on Exception catch (e) { 214 - if (kDebugMode) { 215 - debugPrint('⚠️ Failed to load user votes: $e'); 216 - } 217 - return {}; 218 - } 219 - } 220 - 221 183 /// Create or toggle vote 222 184 /// 223 - /// Sends vote request to the Coves backend, which proxies to the user's PDS. 185 + /// Sends vote request to the Coves backend, which handles toggle logic. 186 + /// The backend will create a vote if none exists, or toggle it off if 187 + /// voting the same direction again. 224 188 /// 225 189 /// Parameters: 226 190 /// - [postUri]: AT-URI of the post 227 191 /// - [postCid]: Content ID of the post (for strong reference) 228 192 /// - [direction]: Vote direction - "up" for like/upvote, "down" for downvote 229 - /// - [existingVoteRkey]: Optional rkey from cached state 230 - /// - [existingVoteDirection]: Optional direction from cached state 231 193 /// 232 194 /// Returns: 233 - /// - VoteResponse with uri/cid/rkey if created 234 - /// - VoteResponse with deleted=true if toggled off 195 + /// - VoteResponse with uri/cid/rkey if vote was created 196 + /// - VoteResponse with deleted=true if vote was toggled off (empty uri/cid) 235 197 /// 236 198 /// Throws: 237 199 /// - ApiException for API errors ··· 239 201 required String postUri, 240 202 required String postCid, 241 203 String direction = 'up', 242 - String? existingVoteRkey, 243 - String? existingVoteDirection, 244 204 }) async { 245 205 try { 246 206 final userDid = _didGetter?.call(); ··· 260 220 debugPrint(' Direction: $direction'); 261 221 } 262 222 263 - // Determine if this is a toggle (delete) or create 264 - final isToggleOff = 265 - existingVoteRkey != null && existingVoteDirection == direction; 266 - 267 - if (isToggleOff) { 268 - // Delete existing vote 269 - return _deleteVote(session: session, rkey: existingVoteRkey); 270 - } 271 - 272 - // If switching direction, delete old vote first 273 - if (existingVoteRkey != null && existingVoteDirection != null) { 274 - if (kDebugMode) { 275 - debugPrint(' Switching vote direction - deleting old vote first'); 276 - } 277 - await _deleteVote(session: session, rkey: existingVoteRkey); 278 - } 279 - 280 - // Create new vote via backend 223 + // Send vote request to backend 281 224 // Note: Authorization header is added by the interceptor 282 225 final response = await _dio.post<Map<String, dynamic>>( 283 226 '/xrpc/social.coves.feed.vote.create', ··· 295 238 final uri = data['uri'] as String?; 296 239 final cid = data['cid'] as String?; 297 240 298 - if (uri == null || cid == null) { 299 - throw ApiException('Invalid response from server - missing uri or cid'); 241 + // If uri/cid are empty, the backend toggled off an existing vote 242 + if (uri == null || uri.isEmpty || cid == null || cid.isEmpty) { 243 + if (kDebugMode) { 244 + debugPrint('✅ Vote toggled off (deleted)'); 245 + } 246 + return const VoteResponse(deleted: true); 300 247 } 301 248 302 - // Extract rkey from URI 303 - final rkey = uri.split('/').last; 249 + // Extract rkey from URI using shared utility 250 + final rkey = VoteState.extractRkeyFromUri(uri); 304 251 305 252 if (kDebugMode) { 306 253 debugPrint('✅ Vote created: $uri'); ··· 330 277 throw ApiException('Failed to create vote: $e'); 331 278 } 332 279 } 333 - 334 - /// Delete vote via backend 335 - Future<VoteResponse> _deleteVote({ 336 - required CovesSession session, 337 - required String rkey, 338 - }) async { 339 - try { 340 - // Note: Authorization header is added by the interceptor 341 - await _dio.post<void>( 342 - '/xrpc/social.coves.feed.vote.delete', 343 - data: {'rkey': rkey}, 344 - ); 345 - 346 - if (kDebugMode) { 347 - debugPrint('✅ Vote deleted'); 348 - } 349 - 350 - return const VoteResponse(deleted: true); 351 - } on DioException catch (e) { 352 - if (kDebugMode) { 353 - debugPrint('❌ Delete vote failed: ${e.message}'); 354 - } 355 - 356 - throw ApiException( 357 - 'Failed to delete vote: ${e.message}', 358 - statusCode: e.response?.statusCode, 359 - originalError: e, 360 - ); 361 - } 362 - } 363 280 } 364 281 365 282 /// Vote Response ··· 380 297 /// Whether the vote was deleted (toggled off) 381 298 final bool deleted; 382 299 } 383 - 384 - /// Existing Vote 385 - /// 386 - /// Represents a vote that already exists on the PDS. 387 - class ExistingVote { 388 - const ExistingVote({required this.direction, required this.rkey}); 389 - 390 - /// Vote direction ("up" or "down") 391 - final String direction; 392 - 393 - /// Record key for deletion 394 - final String rkey; 395 - } 396 - 397 - /// Vote Info 398 - /// 399 - /// Information about a user's vote on a post, returned from getUserVotes(). 400 - class VoteInfo { 401 - const VoteInfo({ 402 - required this.direction, 403 - required this.voteUri, 404 - required this.rkey, 405 - }); 406 - 407 - /// Vote direction ("up" or "down") 408 - final String direction; 409 - 410 - /// AT-URI of the vote record 411 - final String voteUri; 412 - 413 - /// Record key (rkey) - last segment of URI 414 - final String rkey; 415 - }