feat(comments): add CommentService for backend comment creation

- Add CommentService with createComment() method that calls backend API
- Backend handles PDS writes via OAuth/DPoP (sealed token architecture)
- Add ValidationException to api_exceptions for client-side validation
- Add characters package for proper Unicode grapheme cluster counting

The comment creation flow:
Mobile → Coves Backend (sealed token) → User's PDS (DPoP)

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

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

+6
lib/services/api_exceptions.dart
···
FederationException(super.message, {super.originalError})
: super(statusCode: null);
}
+
+
/// Validation error
+
/// Client-side validation failure (empty content, exceeds limits, etc.)
+
class ValidationException extends ApiException {
+
ValidationException(super.message) : super(statusCode: null);
+
}
+170
lib/services/comment_service.dart
···
+
import 'package:dio/dio.dart';
+
import 'package:flutter/foundation.dart';
+
+
import '../config/environment_config.dart';
+
import '../models/coves_session.dart';
+
import 'api_exceptions.dart';
+
import 'auth_interceptor.dart';
+
+
/// Comment Service
+
///
+
/// Handles comment creation through the Coves backend.
+
///
+
/// **Architecture with Backend OAuth**:
+
/// With sealed tokens, the client cannot write directly to the user's PDS
+
/// (no DPoP keys available). Instead, comments go through the Coves backend:
+
///
+
/// Mobile Client → Coves Backend (sealed token) → User's PDS (DPoP)
+
///
+
/// The backend:
+
/// 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
+
///
+
/// **Backend Endpoint**:
+
/// - POST /xrpc/social.coves.community.comment.create
+
class CommentService {
+
CommentService({
+
Future<CovesSession?> Function()? sessionGetter,
+
Future<bool> Function()? tokenRefresher,
+
Future<void> Function()? signOutHandler,
+
Dio? dio,
+
}) : _sessionGetter = sessionGetter {
+
_dio =
+
dio ??
+
Dio(
+
BaseOptions(
+
baseUrl: EnvironmentConfig.current.apiUrl,
+
connectTimeout: const Duration(seconds: 30),
+
receiveTimeout: const Duration(seconds: 30),
+
headers: {'Content-Type': 'application/json'},
+
),
+
);
+
+
// Add shared 401 retry interceptor
+
_dio.interceptors.add(
+
createAuthInterceptor(
+
sessionGetter: sessionGetter,
+
tokenRefresher: tokenRefresher,
+
signOutHandler: signOutHandler,
+
serviceName: 'CommentService',
+
dio: _dio,
+
),
+
);
+
}
+
+
final Future<CovesSession?> Function()? _sessionGetter;
+
late final Dio _dio;
+
+
/// Create a comment
+
///
+
/// Sends comment request to the Coves backend, which writes to the
+
/// user's PDS.
+
///
+
/// Parameters:
+
/// - [rootUri]: AT-URI of the root post (always the original post)
+
/// - [rootCid]: CID of the root post
+
/// - [parentUri]: AT-URI of the parent (post or comment)
+
/// - [parentCid]: CID of the parent
+
/// - [content]: Comment text content
+
///
+
/// Returns:
+
/// - CreateCommentResponse with uri and cid of the created comment
+
///
+
/// Throws:
+
/// - ApiException for API errors
+
/// - AuthenticationException for auth failures
+
Future<CreateCommentResponse> createComment({
+
required String rootUri,
+
required String rootCid,
+
required String parentUri,
+
required String parentCid,
+
required String content,
+
}) async {
+
try {
+
final session = await _sessionGetter?.call();
+
+
if (session == null) {
+
throw AuthenticationException(
+
'User not authenticated - no session available',
+
);
+
}
+
+
if (kDebugMode) {
+
debugPrint('💬 Creating comment via backend');
+
debugPrint(' Root: $rootUri');
+
debugPrint(' Parent: $parentUri');
+
debugPrint(' Content length: ${content.length}');
+
}
+
+
// Send comment request to backend
+
// Note: Authorization header is added by the interceptor
+
final response = await _dio.post<Map<String, dynamic>>(
+
'/xrpc/social.coves.community.comment.create',
+
data: {
+
'reply': {
+
'root': {'uri': rootUri, 'cid': rootCid},
+
'parent': {'uri': parentUri, 'cid': parentCid},
+
},
+
'content': content,
+
},
+
);
+
+
final data = response.data;
+
if (data == null) {
+
throw ApiException('Invalid response from server - no data');
+
}
+
+
final uri = data['uri'] as String?;
+
final cid = data['cid'] as String?;
+
+
if (uri == null || uri.isEmpty || cid == null || cid.isEmpty) {
+
throw ApiException(
+
'Invalid response from server - missing uri or cid',
+
);
+
}
+
+
if (kDebugMode) {
+
debugPrint('✅ Comment created: $uri');
+
}
+
+
return CreateCommentResponse(uri: uri, cid: cid);
+
} on DioException catch (e) {
+
if (kDebugMode) {
+
debugPrint('❌ Comment creation failed: ${e.message}');
+
debugPrint(' Status: ${e.response?.statusCode}');
+
debugPrint(' Data: ${e.response?.data}');
+
}
+
+
if (e.response?.statusCode == 401) {
+
throw AuthenticationException(
+
'Authentication failed. Please sign in again.',
+
originalError: e,
+
);
+
}
+
+
throw ApiException(
+
'Failed to create comment: ${e.message}',
+
statusCode: e.response?.statusCode,
+
originalError: e,
+
);
+
} on AuthenticationException {
+
rethrow;
+
} on ApiException {
+
rethrow;
+
} on Exception catch (e) {
+
throw ApiException('Failed to create comment: $e');
+
}
+
}
+
}
+
+
/// Response from comment creation
+
class CreateCommentResponse {
+
const CreateCommentResponse({required this.uri, required this.cid});
+
+
/// AT-URI of the created comment record
+
final String uri;
+
+
/// CID of the created comment record
+
final String cid;
+
}
+1 -1
pubspec.lock
···
source: hosted
version: "1.3.1"
characters:
-
dependency: transitive
+
dependency: "direct main"
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
+1
pubspec.yaml
···
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
+
characters: ^1.4.0 # Unicode grapheme cluster support for emoji counting
flutter_secure_storage: ^9.2.2
shared_preferences: ^2.3.3
go_router: ^16.3.0