Main coves client

feat(report): add content reporting for posts and comments

Implements user-facing content moderation by allowing authenticated users
to report posts and comments. Reports are categorized by reason (spam,
harassment, doxing, illegal content, child safety, other) with an optional
explanation field.

Changes:
- Add ReportDialog widget with type-safe ReportReason enum
- Add submitReport() API method with input validation
- Add "Report comment" option to comment overflow menu
- Add "Report post" option to post overflow menu
- Show sign-in prompt when unauthenticated users try to report

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

+550 -26
+85
lib/services/coves_api_service.dart
··· 1269 1269 } 1270 1270 } 1271 1271 1272 + /// Submit a report for content moderation 1273 + /// 1274 + /// Reports a post or comment to administrators for review. 1275 + /// Requires authentication. 1276 + /// 1277 + /// Parameters: 1278 + /// - [targetUri]: AT-URI of the content being reported (post or comment) 1279 + /// - [reason]: Category of the report. Must be one of: 1280 + /// 'spam', 'harassment', 'doxing', 'illegal', 'csam', 'other' 1281 + /// - [explanation]: Optional description (max 1000 characters) 1282 + /// 1283 + /// Returns the report ID on success. 1284 + /// 1285 + /// Throws: 1286 + /// - [AuthenticationException] if not authenticated 1287 + /// - [ApiException] if validation fails or server error 1288 + Future<int> submitReport({ 1289 + required String targetUri, 1290 + required String reason, 1291 + String? explanation, 1292 + }) async { 1293 + // Validate inputs before making API call 1294 + const validReasons = { 1295 + 'spam', 1296 + 'harassment', 1297 + 'doxing', 1298 + 'illegal', 1299 + 'csam', 1300 + 'other', 1301 + }; 1302 + 1303 + if (targetUri.isEmpty || !targetUri.startsWith('at://')) { 1304 + throw ApiException('Invalid target URI'); 1305 + } 1306 + 1307 + if (!validReasons.contains(reason)) { 1308 + throw ApiException('Invalid report reason: $reason'); 1309 + } 1310 + 1311 + if (explanation != null && explanation.length > 1000) { 1312 + throw ApiException('Explanation exceeds maximum length of 1000 characters'); 1313 + } 1314 + 1315 + try { 1316 + if (kDebugMode) { 1317 + debugPrint('🚨 Submitting report for: $targetUri (reason: $reason)'); 1318 + } 1319 + 1320 + final requestBody = <String, dynamic>{ 1321 + 'targetUri': targetUri, 1322 + 'reason': reason, 1323 + }; 1324 + 1325 + if (explanation != null && explanation.isNotEmpty) { 1326 + requestBody['explanation'] = explanation; 1327 + } 1328 + 1329 + final response = await _dio.post( 1330 + '/xrpc/social.coves.admin.submitReport', 1331 + data: requestBody, 1332 + ); 1333 + 1334 + if (kDebugMode) { 1335 + debugPrint('✅ Report submitted successfully'); 1336 + } 1337 + 1338 + final data = response.data as Map<String, dynamic>; 1339 + final reportId = data['reportId'] as int?; 1340 + if (reportId == null) { 1341 + throw ApiException('Server returned invalid report response'); 1342 + } 1343 + return reportId; 1344 + } on DioException catch (e) { 1345 + throw _handleDioException(e, 'submit report'); 1346 + } catch (e) { 1347 + if (e is ApiException) { 1348 + rethrow; 1349 + } 1350 + if (kDebugMode) { 1351 + debugPrint('❌ Error submitting report: $e'); 1352 + } 1353 + throw ApiException('Failed to submit report', originalError: e); 1354 + } 1355 + } 1356 + 1272 1357 /// Dispose resources 1273 1358 void dispose() { 1274 1359 _dio.close();
+65 -21
lib/widgets/comment_card.dart
··· 13 13 import '../services/api_exceptions.dart'; 14 14 import '../utils/date_time_utils.dart'; 15 15 import 'icons/animated_heart_icon.dart'; 16 + import 'report_dialog.dart'; 16 17 import 'rich_text_renderer.dart'; 17 18 import 'sign_in_dialog.dart'; 18 19 import 'tappable_author.dart'; ··· 322 323 ); 323 324 } 324 325 325 - /// Handles menu action selection (delete) 326 + /// Handles menu action selection (report or delete). 327 + /// 328 + /// Menu is only visible to authenticated users, so no auth check needed here. 326 329 Future<void> _handleMenuAction(BuildContext context, String action) async { 327 - if (action == 'delete') { 330 + if (action == 'report') { 331 + if (!context.mounted) return; 332 + final messenger = ScaffoldMessenger.of(context); 333 + 334 + // Show report dialog 335 + final reported = await ReportDialog.show( 336 + context, 337 + targetUri: comment.uri, 338 + contentType: 'comment', 339 + ); 340 + 341 + if (reported == true && context.mounted) { 342 + messenger.showSnackBar( 343 + const SnackBar( 344 + content: Text('Report submitted. Thank you for helping keep our community safe.'), 345 + behavior: SnackBarBehavior.floating, 346 + ), 347 + ); 348 + } 349 + } else if (action == 'delete') { 328 350 // Prevent multiple taps - set flag immediately before dialog 329 351 if (_isDeleting) return; 330 352 setState(() => _isDeleting = true); ··· 449 471 } 450 472 } 451 473 452 - /// Builds the three-dots menu for comment actions (only shown for author) 474 + /// Builds the three-dots menu for comment actions. 475 + /// 476 + /// Shows either a report option (for non-authors) or a delete option 477 + /// (for the comment author). Only visible when authenticated. 453 478 Widget _buildCommentMenu(BuildContext context) { 454 479 return Consumer<AuthProvider>( 455 480 builder: (context, authProvider, child) { 456 - if (!authProvider.isAuthenticated || 457 - authProvider.did != comment.author.did) { 481 + // Only show menu for authenticated users 482 + if (!authProvider.isAuthenticated) { 458 483 return const SizedBox.shrink(); 459 484 } 485 + 486 + final isCommentAuthor = authProvider.did == comment.author.did; 460 487 461 488 return PopupMenuButton<String>( 462 489 icon: Icon( ··· 473 500 ), 474 501 onSelected: (action) => _handleMenuAction(context, action), 475 502 itemBuilder: (context) => [ 476 - const PopupMenuItem<String>( 477 - value: 'delete', 478 - child: Row( 479 - children: [ 480 - Icon( 481 - Icons.delete_outline, 482 - size: 20, 483 - color: Colors.red, 484 - ), 485 - SizedBox(width: 12), 486 - Text( 487 - 'Delete comment', 488 - style: TextStyle(color: Colors.red), 489 - ), 490 - ], 503 + // Report option (for non-authors) 504 + if (!isCommentAuthor) 505 + const PopupMenuItem<String>( 506 + value: 'report', 507 + child: Row( 508 + children: [ 509 + Icon( 510 + Icons.flag_outlined, 511 + size: 20, 512 + ), 513 + SizedBox(width: 12), 514 + Text('Report comment'), 515 + ], 516 + ), 491 517 ), 492 - ), 518 + // Delete option (only for comment author) 519 + if (isCommentAuthor) 520 + const PopupMenuItem<String>( 521 + value: 'delete', 522 + child: Row( 523 + children: [ 524 + Icon( 525 + Icons.delete_outline, 526 + size: 20, 527 + color: Colors.red, 528 + ), 529 + SizedBox(width: 12), 530 + Text( 531 + 'Delete comment', 532 + style: TextStyle(color: Colors.red), 533 + ), 534 + ], 535 + ), 536 + ), 493 537 ], 494 538 ); 495 539 },
+62 -5
lib/widgets/post_card_actions.dart
··· 13 13 import '../services/coves_api_service.dart'; 14 14 import '../utils/date_time_utils.dart'; 15 15 import 'icons/animated_heart_icon.dart'; 16 + import 'report_dialog.dart'; 16 17 import 'share_button.dart'; 17 18 import 'sign_in_dialog.dart'; 18 19 ··· 48 49 final communityName = post.post.community.name; 49 50 50 51 if (action == 'subscribe') { 51 - // Check authentication 52 + // Check authentication - subscribe requires sign-in 52 53 final authProvider = context.read<AuthProvider>(); 53 54 if (!authProvider.isAuthenticated) { 54 55 if (!context.mounted) return; ··· 56 57 context, 57 58 message: 'You need to sign in to subscribe to communities.', 58 59 ); 59 - if ((shouldSignIn ?? false) && context.mounted) { 60 - if (kDebugMode) { 61 - debugPrint('Navigate to sign-in screen'); 62 - } 60 + if (shouldSignIn != true && context.mounted) { 61 + ScaffoldMessenger.of(context).showSnackBar( 62 + const SnackBar( 63 + content: Text('Sign in required to subscribe'), 64 + behavior: SnackBarBehavior.floating, 65 + ), 66 + ); 63 67 } 64 68 return; 65 69 } ··· 105 109 ), 106 110 ); 107 111 } 112 + } 113 + } else if (action == 'report') { 114 + // Check authentication - report requires sign-in 115 + final authProvider = context.read<AuthProvider>(); 116 + if (!authProvider.isAuthenticated) { 117 + if (!context.mounted) return; 118 + final shouldSignIn = await SignInDialog.show( 119 + context, 120 + message: 'You need to sign in to report content.', 121 + ); 122 + if (shouldSignIn != true && context.mounted) { 123 + ScaffoldMessenger.of(context).showSnackBar( 124 + const SnackBar( 125 + content: Text('Sign in required to report content'), 126 + behavior: SnackBarBehavior.floating, 127 + ), 128 + ); 129 + } 130 + return; 131 + } 132 + 133 + if (!context.mounted) return; 134 + final messenger = ScaffoldMessenger.of(context); 135 + 136 + // Show report dialog 137 + final reported = await ReportDialog.show( 138 + context, 139 + targetUri: post.post.uri, 140 + contentType: 'post', 141 + ); 142 + 143 + if (reported == true && context.mounted) { 144 + messenger.showSnackBar( 145 + const SnackBar( 146 + content: Text('Report submitted. Thank you for helping keep our community safe.'), 147 + behavior: SnackBarBehavior.floating, 148 + ), 149 + ); 108 150 } 109 151 } else if (action == 'delete') { 110 152 // Prevent multiple taps - set flag immediately before dialog ··· 313 355 ], 314 356 ), 315 357 ), 358 + // Report option (for all authenticated users, except own posts) 359 + if (!isPostAuthor) 360 + const PopupMenuItem<String>( 361 + value: 'report', 362 + child: Row( 363 + children: [ 364 + Icon( 365 + Icons.flag_outlined, 366 + size: 20, 367 + ), 368 + SizedBox(width: 12), 369 + Text('Report post'), 370 + ], 371 + ), 372 + ), 316 373 // Delete option (only for post author) 317 374 if (isPostAuthor) 318 375 const PopupMenuItem<String>(
+338
lib/widgets/report_dialog.dart
··· 1 + import 'package:flutter/foundation.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter/services.dart'; 4 + import 'package:provider/provider.dart'; 5 + 6 + import '../constants/app_colors.dart'; 7 + import '../providers/auth_provider.dart'; 8 + import '../services/api_exceptions.dart'; 9 + import '../services/coves_api_service.dart'; 10 + 11 + /// Report reason categories matching backend enum. 12 + /// 13 + /// Uses enhanced enum to enforce a closed set of valid reasons at compile time. 14 + enum ReportReason { 15 + spam(label: 'Spam', description: 'Unsolicited advertising or repetitive content'), 16 + harassment(label: 'Harassment', description: 'Bullying, threats, or targeted attacks'), 17 + doxing(label: 'Doxing', description: 'Sharing private information without consent'), 18 + illegal(label: 'Illegal Content', description: 'Content that violates laws or regulations'), 19 + csam(label: 'Child Safety', description: 'Content exploiting or endangering minors'), 20 + other(label: 'Other', description: 'Other policy violations'); 21 + 22 + const ReportReason({required this.label, required this.description}); 23 + 24 + final String label; 25 + final String description; 26 + 27 + /// The API value for this reason (matches enum name) 28 + String get value => name; 29 + } 30 + 31 + /// Dialog for reporting posts or comments 32 + /// 33 + /// Shows a list of report reasons and an optional explanation field. 34 + /// Returns true if the report was submitted successfully. 35 + class ReportDialog extends StatefulWidget { 36 + const ReportDialog({ 37 + required this.targetUri, 38 + required this.contentType, 39 + super.key, 40 + }); 41 + 42 + /// AT-URI of the content being reported 43 + final String targetUri; 44 + 45 + /// Type of content ('post' or 'comment') for display purposes 46 + final String contentType; 47 + 48 + /// Show the report dialog 49 + /// 50 + /// Returns true if report was submitted, false if cancelled, null if dismissed 51 + static Future<bool?> show( 52 + BuildContext context, { 53 + required String targetUri, 54 + required String contentType, 55 + }) { 56 + return showDialog<bool>( 57 + context: context, 58 + builder: (context) => ReportDialog( 59 + targetUri: targetUri, 60 + contentType: contentType, 61 + ), 62 + ); 63 + } 64 + 65 + @override 66 + State<ReportDialog> createState() => _ReportDialogState(); 67 + } 68 + 69 + class _ReportDialogState extends State<ReportDialog> { 70 + ReportReason? _selectedReason; 71 + final _explanationController = TextEditingController(); 72 + bool _isSubmitting = false; 73 + String? _error; 74 + 75 + static const int _maxExplanationLength = 1000; 76 + 77 + @override 78 + void dispose() { 79 + _explanationController.dispose(); 80 + super.dispose(); 81 + } 82 + 83 + Future<void> _submitReport() async { 84 + if (_selectedReason == null) { 85 + setState(() => _error = 'Please select a reason'); 86 + return; 87 + } 88 + 89 + setState(() { 90 + _isSubmitting = true; 91 + _error = null; 92 + }); 93 + 94 + try { 95 + await HapticFeedback.lightImpact(); 96 + } on PlatformException { 97 + // Haptics not supported 98 + } 99 + 100 + final authProvider = context.read<AuthProvider>(); 101 + final apiService = CovesApiService( 102 + tokenGetter: authProvider.getAccessToken, 103 + tokenRefresher: authProvider.refreshToken, 104 + signOutHandler: authProvider.signOut, 105 + ); 106 + 107 + try { 108 + await apiService.submitReport( 109 + targetUri: widget.targetUri, 110 + reason: _selectedReason!.value, 111 + explanation: _explanationController.text.trim().isEmpty 112 + ? null 113 + : _explanationController.text.trim(), 114 + ); 115 + 116 + if (mounted) { 117 + Navigator.of(context).pop(true); 118 + } 119 + } on AuthenticationException catch (e) { 120 + if (kDebugMode) { 121 + debugPrint('Auth error submitting report: $e'); 122 + } 123 + if (mounted) { 124 + setState(() { 125 + _error = 'You must be signed in to report content'; 126 + _isSubmitting = false; 127 + }); 128 + } 129 + } on ApiException catch (e) { 130 + if (kDebugMode) { 131 + debugPrint('API error submitting report: $e'); 132 + } 133 + if (mounted) { 134 + setState(() { 135 + _error = e.message; 136 + _isSubmitting = false; 137 + }); 138 + } 139 + } finally { 140 + apiService.dispose(); 141 + } 142 + } 143 + 144 + @override 145 + Widget build(BuildContext context) { 146 + return AlertDialog( 147 + backgroundColor: AppColors.background, 148 + title: Text( 149 + 'Report ${widget.contentType}', 150 + style: const TextStyle( 151 + color: AppColors.textPrimary, 152 + fontSize: 18, 153 + fontWeight: FontWeight.bold, 154 + ), 155 + ), 156 + content: ConstrainedBox( 157 + constraints: BoxConstraints( 158 + maxHeight: MediaQuery.of(context).size.height * 0.6, 159 + maxWidth: double.maxFinite, 160 + ), 161 + child: SingleChildScrollView( 162 + child: Column( 163 + mainAxisSize: MainAxisSize.min, 164 + crossAxisAlignment: CrossAxisAlignment.start, 165 + children: [ 166 + const Text( 167 + 'Why are you reporting this?', 168 + style: TextStyle( 169 + color: AppColors.textSecondary, 170 + fontSize: 14, 171 + ), 172 + ), 173 + const SizedBox(height: 16), 174 + // Reason selection list 175 + ...ReportReason.values.map((reason) => _buildReasonTile(reason)), 176 + const SizedBox(height: 16), 177 + // Explanation field 178 + TextField( 179 + controller: _explanationController, 180 + maxLines: 3, 181 + maxLength: _maxExplanationLength, 182 + style: const TextStyle( 183 + color: AppColors.textPrimary, 184 + fontSize: 14, 185 + ), 186 + decoration: InputDecoration( 187 + hintText: 'Additional details (optional)', 188 + hintStyle: TextStyle( 189 + color: AppColors.textPrimary.withValues(alpha: 0.4), 190 + ), 191 + filled: true, 192 + fillColor: AppColors.backgroundSecondary, 193 + border: OutlineInputBorder( 194 + borderRadius: BorderRadius.circular(8), 195 + borderSide: BorderSide.none, 196 + ), 197 + focusedBorder: OutlineInputBorder( 198 + borderRadius: BorderRadius.circular(8), 199 + borderSide: const BorderSide(color: AppColors.primary), 200 + ), 201 + counterStyle: const TextStyle( 202 + color: AppColors.textSecondary, 203 + fontSize: 12, 204 + ), 205 + ), 206 + ), 207 + // Error message 208 + if (_error != null) ...[ 209 + const SizedBox(height: 8), 210 + Text( 211 + _error!, 212 + style: const TextStyle( 213 + color: AppColors.error, 214 + fontSize: 13, 215 + ), 216 + ), 217 + ], 218 + ], 219 + ), 220 + ), 221 + ), 222 + actions: [ 223 + TextButton( 224 + onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false), 225 + child: Text( 226 + 'Cancel', 227 + style: TextStyle( 228 + color: _isSubmitting 229 + ? AppColors.textSecondary.withValues(alpha: 0.5) 230 + : AppColors.textSecondary, 231 + ), 232 + ), 233 + ), 234 + ElevatedButton( 235 + onPressed: _isSubmitting || _selectedReason == null ? null : _submitReport, 236 + style: ElevatedButton.styleFrom( 237 + backgroundColor: AppColors.error, 238 + foregroundColor: AppColors.textPrimary, 239 + disabledBackgroundColor: AppColors.error.withValues(alpha: 0.3), 240 + disabledForegroundColor: AppColors.textPrimary.withValues(alpha: 0.5), 241 + ), 242 + child: _isSubmitting 243 + ? const SizedBox( 244 + width: 16, 245 + height: 16, 246 + child: CircularProgressIndicator( 247 + strokeWidth: 2, 248 + color: AppColors.textPrimary, 249 + ), 250 + ) 251 + : const Text('Submit Report'), 252 + ), 253 + ], 254 + ); 255 + } 256 + 257 + Widget _buildReasonTile(ReportReason reason) { 258 + final isSelected = _selectedReason == reason; 259 + 260 + return GestureDetector( 261 + onTap: _isSubmitting 262 + ? null 263 + : () { 264 + setState(() { 265 + _selectedReason = reason; 266 + _error = null; 267 + }); 268 + }, 269 + child: Container( 270 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), 271 + margin: const EdgeInsets.only(bottom: 4), 272 + decoration: BoxDecoration( 273 + color: isSelected 274 + ? AppColors.error.withValues(alpha: 0.15) 275 + : Colors.transparent, 276 + borderRadius: BorderRadius.circular(8), 277 + border: Border.all( 278 + color: isSelected ? AppColors.error : AppColors.border, 279 + width: isSelected ? 1.5 : 1, 280 + ), 281 + ), 282 + child: Row( 283 + children: [ 284 + // Radio indicator 285 + Container( 286 + width: 20, 287 + height: 20, 288 + decoration: BoxDecoration( 289 + shape: BoxShape.circle, 290 + border: Border.all( 291 + color: isSelected ? AppColors.error : AppColors.textSecondary, 292 + width: 2, 293 + ), 294 + ), 295 + child: isSelected 296 + ? Center( 297 + child: Container( 298 + width: 10, 299 + height: 10, 300 + decoration: const BoxDecoration( 301 + shape: BoxShape.circle, 302 + color: AppColors.error, 303 + ), 304 + ), 305 + ) 306 + : null, 307 + ), 308 + const SizedBox(width: 12), 309 + // Label and description 310 + Expanded( 311 + child: Column( 312 + crossAxisAlignment: CrossAxisAlignment.start, 313 + children: [ 314 + Text( 315 + reason.label, 316 + style: TextStyle( 317 + color: isSelected ? AppColors.textPrimary : AppColors.textSecondary, 318 + fontSize: 14, 319 + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, 320 + ), 321 + ), 322 + const SizedBox(height: 2), 323 + Text( 324 + reason.description, 325 + style: TextStyle( 326 + color: AppColors.textPrimary.withValues(alpha: 0.5), 327 + fontSize: 12, 328 + ), 329 + ), 330 + ], 331 + ), 332 + ), 333 + ], 334 + ), 335 + ), 336 + ); 337 + } 338 + }