[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

add report button to posts

+715 -202
+1
assets/icons/more_horiz.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="#fff" fill-rule="evenodd" d="M5 12c0-.69.56-1.25 1.25-1.25h.01A1.25 1.25 0 1 1 6.25 13.25h-.01A1.25 1.25 0 0 1 5 12m6 0c0-.69.56-1.25 1.25-1.25h.01A1.25 1.25 0 1 1 12.25 13.25h-.01A1.25 1.25 0 0 1 11 12m6 0c0-.69.56-1.25 1.25-1.25h.01A1.25 1.25 0 1 1 18.25 13.25h-.01A1.25 1.25 0 0 1 17 12" clip-rule="evenodd"/></svg>
+8
lib/src/core/design_system/components/atoms/buttons/long_button.dart
··· 5 5 import 'package:sparksocial/src/core/design_system/tokens/gradients.dart'; 6 6 import 'package:sparksocial/src/core/design_system/tokens/typography.dart'; 7 7 8 + /// A general-purpose button with gradient background that can display any text. 9 + /// 10 + /// This button features an accent gradient background with a glass stroke border. 11 + /// It's commonly used in authentication screens and forms, but can be used 12 + /// anywhere a prominent action button is needed. 8 13 class LongButton extends StatelessWidget { 14 + /// The text to display on the button 9 15 final String label; 16 + 17 + /// Callback when the button is pressed 10 18 final VoidCallback? onPressed; 11 19 12 20 const LongButton({
+70
lib/src/core/design_system/components/atoms/buttons/primary_button.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:sparksocial/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; 3 + import 'package:sparksocial/src/core/design_system/tokens/colors.dart'; 4 + import 'package:sparksocial/src/core/design_system/tokens/typography.dart'; 5 + 6 + /// A general-purpose primary button that can display any text. 7 + /// 8 + /// This button uses the primary brand color and is commonly used in 9 + /// authentication screens, but can be used anywhere a prominent primary action is needed. 10 + /// It supports an optional trailing widget (e.g., SVG icon) and customizable minimum size. 11 + class PrimaryButton extends StatelessWidget { 12 + /// The text to display on the button 13 + final String text; 14 + 15 + /// Optional widget to display after the text (e.g., SVG logo) 16 + final Widget? trailing; 17 + 18 + /// Callback when the button is pressed 19 + final VoidCallback? onPressed; 20 + 21 + /// Minimum width of the button (default: 320) 22 + final double? minWidth; 23 + 24 + /// Minimum height of the button (default: 60) 25 + final double? minHeight; 26 + 27 + const PrimaryButton({ 28 + required this.text, 29 + this.trailing, 30 + this.onPressed, 31 + this.minWidth, 32 + this.minHeight, 33 + super.key, 34 + }); 35 + 36 + @override 37 + Widget build(BuildContext context) { 38 + return InteractivePressable( 39 + onTap: onPressed, 40 + borderRadius: BorderRadius.circular(16), 41 + child: Container( 42 + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24), 43 + constraints: BoxConstraints( 44 + minWidth: minWidth ?? 320, 45 + minHeight: minHeight ?? 60, 46 + ), 47 + decoration: BoxDecoration( 48 + color: AppColors.primary500, 49 + borderRadius: BorderRadius.circular(16), 50 + ), 51 + child: Row( 52 + mainAxisSize: MainAxisSize.min, 53 + mainAxisAlignment: MainAxisAlignment.center, 54 + children: [ 55 + Text( 56 + text, 57 + style: AppTypography.textLargeMedium.copyWith( 58 + color: AppColors.greyWhite, 59 + ), 60 + ), 61 + if (trailing != null) ...[ 62 + const SizedBox(width: 8), 63 + trailing!, 64 + ], 65 + ], 66 + ), 67 + ), 68 + ); 69 + } 70 + }
+7
lib/src/core/design_system/components/atoms/icons.dart
··· 257 257 colorFilter: color != null ? ColorFilter.mode(color, BlendMode.srcIn) : null, 258 258 package: 'assets', 259 259 ); 260 + static Widget moreHoriz({double size = 24, Color? color}) => SvgPicture.asset( 261 + '$_path/more_horiz.svg', 262 + width: size, 263 + height: size, 264 + colorFilter: color != null ? ColorFilter.mode(color, BlendMode.srcIn) : null, 265 + package: 'assets', 266 + ); 260 267 static Widget music({double size = 24, Color? color}) => SvgPicture.asset( 261 268 '$_path/music.svg', 262 269 width: size,
+5
lib/src/core/design_system/components/organisms/side_action_bar.dart
··· 19 19 this.onCurate, 20 20 this.onShare, 21 21 this.onSoundTap, 22 + this.optionsButton, 22 23 this.likeCount, 23 24 this.commentCount, 24 25 this.curateCount, ··· 38 39 final VoidCallback? onCurate; // called after a feed selection (or when opening?) 39 40 final VoidCallback? onShare; 40 41 final VoidCallback? onSoundTap; 42 + final Widget? optionsButton; 41 43 42 44 final String? likeCount; 43 45 final String? commentCount; ··· 171 173 cover: widget.soundCover!, 172 174 onTap: widget.onSoundTap, 173 175 ), 176 + ], 177 + if (widget.optionsButton != null) ...[ 178 + widget.optionsButton!, 174 179 ], 175 180 ], 176 181 );
+8 -53
lib/src/core/ui/widgets/menu_action_button.dart
··· 1 - import 'package:auto_route/auto_route.dart'; 2 1 import 'package:flutter/material.dart'; 3 2 import 'package:get_it/get_it.dart'; 4 3 5 4 import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 6 5 import 'package:sparksocial/src/core/ui/foundation/colors.dart'; 6 + import 'package:sparksocial/src/core/ui/widgets/options_panel.dart'; 7 7 8 8 class MenuActionButton extends StatelessWidget { 9 9 const MenuActionButton({ ··· 11 11 this.onPressed, 12 12 this.onDeletePressed, 13 13 this.isCompact = false, 14 - this.backgroundColor, 15 14 this.isProfile = false, 16 15 this.isOnVideo = false, 17 - this.isOwnPost = false, 18 16 this.authorDid, 19 17 }); 20 18 final VoidCallback? onPressed; 21 19 final VoidCallback? onDeletePressed; 22 20 final bool isCompact; 23 - final Color? backgroundColor; 24 21 final bool isProfile; 25 22 final bool isOnVideo; 26 - final bool isOwnPost; 27 23 final String? authorDid; 28 24 29 25 void _showOptionsMenu(BuildContext context) { 30 - final theme = Theme.of(context); 31 - final isDark = theme.brightness == Brightness.dark; 32 - final textColor = isDark ? Colors.white : Colors.black; 33 - final menuBackgroundColor = isDark ? theme.colorScheme.surface : Colors.white; 34 - 35 26 // Check if current user is the author 36 27 final authRepository = GetIt.instance<AuthRepository>(); 37 28 final userDid = authRepository.session?.did; 38 29 final isCurrentUserAuthor = userDid == authorDid; 39 30 40 - showModalBottomSheet( 31 + OptionsPanel.show( 41 32 context: context, 42 - backgroundColor: menuBackgroundColor, 43 - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), 44 - builder: (context) { 45 - return SafeArea( 46 - child: Column( 47 - mainAxisSize: MainAxisSize.min, 48 - children: [ 49 - // Show delete option if the user is the author 50 - if (isCurrentUserAuthor) 51 - ListTile( 52 - leading: const Icon(Icons.delete_outline, color: Colors.red), 53 - title: const Text('Delete', style: TextStyle(color: Colors.red)), 54 - onTap: () async { 55 - await context.router.maybePop(); 56 - if (onDeletePressed != null) { 57 - onDeletePressed!(); 58 - } 59 - }, 60 - ), 61 - ListTile( 62 - leading: const Icon(Icons.report_outlined), 63 - title: Text(isProfile ? 'Report Profile' : 'Report', style: TextStyle(color: textColor)), 64 - onTap: () { 65 - context.router.maybePop(); 66 - if (onPressed != null) { 67 - onPressed!(); 68 - } 69 - }, 70 - ), 71 - ListTile( 72 - leading: const Icon(Icons.close), 73 - title: Text('Close', style: TextStyle(color: textColor)), 74 - onTap: () { 75 - context.router.maybePop(); 76 - }, 77 - ), 78 - ], 79 - ), 80 - ); 33 + onReport: () { 34 + if (onPressed != null) { 35 + onPressed!(); 36 + } 81 37 }, 38 + onDelete: isCurrentUserAuthor && onDeletePressed != null ? onDeletePressed : null, 39 + isProfile: isProfile, 82 40 ); 83 41 } 84 42 ··· 107 65 super.key, 108 66 this.onPressed, 109 67 this.onDeletePressed, 110 - this.backgroundColor, 111 68 this.isProfile = false, 112 69 this.isOnVideo = false, 113 70 this.authorDid, 114 71 }); 115 72 final VoidCallback? onPressed; 116 73 final VoidCallback? onDeletePressed; 117 - final Color? backgroundColor; 118 74 final bool isProfile; 119 75 final bool isOnVideo; 120 76 final String? authorDid; ··· 125 81 onPressed: onPressed, 126 82 onDeletePressed: onDeletePressed, 127 83 isCompact: true, 128 - backgroundColor: backgroundColor, 129 84 isProfile: isProfile, 130 85 isOnVideo: isOnVideo, 131 86 authorDid: authorDid,
+59
lib/src/core/ui/widgets/options_panel.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + class OptionsPanel { 4 + static void show({ 5 + required BuildContext context, 6 + required VoidCallback onReport, 7 + VoidCallback? onDelete, 8 + bool isProfile = false, 9 + }) { 10 + final theme = Theme.of(context); 11 + final isDark = theme.brightness == Brightness.dark; 12 + final textColor = isDark ? Colors.white : Colors.black; 13 + final menuBackgroundColor = isDark ? theme.colorScheme.surface : Colors.white; 14 + 15 + showModalBottomSheet( 16 + context: context, 17 + backgroundColor: menuBackgroundColor, 18 + shape: const RoundedRectangleBorder( 19 + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), 20 + ), 21 + builder: (context) { 22 + return SafeArea( 23 + child: Column( 24 + mainAxisSize: MainAxisSize.min, 25 + children: [ 26 + if (onDelete != null) 27 + ListTile( 28 + leading: const Icon(Icons.delete_outline, color: Colors.red), 29 + title: const Text('Delete', style: TextStyle(color: Colors.red)), 30 + onTap: () { 31 + Navigator.of(context).pop(); 32 + onDelete(); 33 + }, 34 + ), 35 + ListTile( 36 + leading: const Icon(Icons.report_outlined), 37 + title: Text( 38 + isProfile ? 'Report Profile' : 'Report', 39 + style: TextStyle(color: textColor), 40 + ), 41 + onTap: () { 42 + Navigator.of(context).pop(); 43 + onReport(); 44 + }, 45 + ), 46 + ListTile( 47 + leading: const Icon(Icons.close), 48 + title: Text('Close', style: TextStyle(color: textColor)), 49 + onTap: () { 50 + Navigator.of(context).pop(); 51 + }, 52 + ), 53 + ], 54 + ), 55 + ); 56 + }, 57 + ); 58 + } 59 + }
+461 -74
lib/src/core/ui/widgets/report_dialog.dart
··· 1 1 import 'package:atproto/com_atproto_moderation_createreport.dart'; 2 2 import 'package:atproto/com_atproto_moderation_defs.dart'; 3 - import 'package:atproto/com_atproto_services.dart'; 4 3 import 'package:atproto/core.dart'; 5 4 import 'package:auto_route/auto_route.dart'; 6 5 import 'package:bluesky/com_atproto_repo_strongref.dart'; 7 6 import 'package:flutter/material.dart'; 8 7 import 'package:flutter_riverpod/flutter_riverpod.dart'; 9 8 import 'package:get_it/get_it.dart'; 9 + import 'package:sparksocial/src/core/design_system/components/atoms/buttons/long_button.dart'; 10 10 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 11 11 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 12 12 import 'package:sparksocial/src/core/utils/logging/logger.dart'; 13 13 14 + enum ReportCategory { 15 + violence('Violence'), 16 + sexual('Sexual Content'), 17 + childSafety('Child Safety'), 18 + harassment('Harassment'), 19 + misleading('Misleading'), 20 + ruleViolations('Rule Violations'), 21 + selfHarm('Self-Harm'), 22 + other('Other'); 23 + 24 + const ReportCategory(this.displayName); 25 + final String displayName; 26 + } 27 + 28 + class ReportReason { 29 + final String value; 30 + final String displayName; 31 + final String? description; 32 + final KnownReasonType? knownType; 33 + 34 + const ReportReason({ 35 + required this.value, 36 + required this.displayName, 37 + this.description, 38 + this.knownType, 39 + }); 40 + } 41 + 14 42 class ReportDialog extends ConsumerStatefulWidget { 15 43 const ReportDialog({required this.postUri, required this.postCid, super.key, this.onSubmit}); 16 44 final String postUri; 17 45 final String postCid; 18 - final Function(UModerationCreateReportSubject subject, KnownReasonType reasonType, String? reason, ModerationService? service)? 19 - onSubmit; 46 + 47 + /// Callback for report submission. Uses [ReasonType] directly to support both known and unknown reason types. 48 + final Function(UModerationCreateReportSubject subject, ReasonType reasonType, String? reason)? onSubmit; 20 49 21 50 @override 22 51 ConsumerState<ReportDialog> createState() => _ReportDialogState(); ··· 24 53 25 54 class _ReportDialogState extends ConsumerState<ReportDialog> { 26 55 final SparkLogger _logger = GetIt.instance<LogService>().getLogger('ReportDialog'); 27 - KnownReasonType _selectedReason = KnownReasonType.comAtprotoModerationDefsReasonSpam; 56 + ReportCategory? _selectedCategory; 57 + ReportReason? _selectedReason; 28 58 final TextEditingController _additionalInfoController = TextEditingController(); 29 59 bool _isSubmitting = false; 30 60 String? _errorMessage; 31 61 32 - // Map of user-friendly names and descriptions for each reason type 33 - final Map<KnownReasonType, Map<String, String>> _reasonDescriptions = { 34 - KnownReasonType.comAtprotoModerationDefsReasonSpam: {'name': 'Spam', 'description': 'Unwanted or repetitive content'}, 35 - KnownReasonType.comAtprotoModerationDefsReasonViolation: { 36 - 'name': 'Terms Violation', 37 - 'description': 'Violates platform terms', 38 - }, 39 - KnownReasonType.comAtprotoModerationDefsReasonMisleading: { 40 - 'name': 'Misleading Info', 41 - 'description': 'False or deceptive content', 42 - }, 43 - KnownReasonType.comAtprotoModerationDefsReasonSexual: { 44 - 'name': 'Sexual Content', 45 - 'description': 'Inappropriate explicit material', 46 - }, 47 - KnownReasonType.comAtprotoModerationDefsReasonRude: {'name': 'Harassment', 'description': 'Abusive or threatening behavior'}, 48 - KnownReasonType.comAtprotoModerationDefsReasonOther: {'name': 'Other', 'description': 'Other issues not listed above'}, 62 + // Map categories to their reasons 63 + static final Map<ReportCategory, List<ReportReason>> _categoryReasons = { 64 + ReportCategory.violence: [ 65 + const ReportReason( 66 + value: 'tools.ozone.report.defs#reasonViolenceAnimal', 67 + displayName: 'Animal Abuse', 68 + description: 'Content depicting harm to animals', 69 + knownType: KnownReasonType.toolsOzoneReportDefsReasonViolenceAnimal, 70 + ), 71 + const ReportReason( 72 + value: 'tools.ozone.report.defs#reasonViolenceThreats', 73 + displayName: 'Threats', 74 + description: 'Threats of violence', 75 + knownType: KnownReasonType.toolsOzoneReportDefsReasonViolenceThreats, 76 + ), 77 + const ReportReason( 78 + value: 'tools.ozone.report.defs#reasonViolenceGraphicContent', 79 + displayName: 'Graphic Content', 80 + description: 'Graphic or violent imagery', 81 + knownType: KnownReasonType.toolsOzoneReportDefsReasonViolenceGraphicContent, 82 + ), 83 + const ReportReason( 84 + value: 'tools.ozone.report.defs#reasonViolenceGlorification', 85 + displayName: 'Glorification of Violence', 86 + description: 'Content that glorifies violence', 87 + knownType: KnownReasonType.toolsOzoneReportDefsReasonViolenceGlorification, 88 + ), 89 + const ReportReason( 90 + value: 'tools.ozone.report.defs#reasonViolenceExtremistContent', 91 + displayName: 'Extremist Content', 92 + description: 'Content promoting extremist ideologies', 93 + knownType: KnownReasonType.toolsOzoneReportDefsReasonViolenceExtremistContent, 94 + ), 95 + const ReportReason( 96 + value: 'tools.ozone.report.defs#reasonViolenceTrafficking', 97 + displayName: 'Trafficking', 98 + description: 'Content related to human trafficking', 99 + knownType: KnownReasonType.toolsOzoneReportDefsReasonViolenceTrafficking, 100 + ), 101 + const ReportReason( 102 + value: 'tools.ozone.report.defs#reasonViolenceOther', 103 + displayName: 'Other Violence', 104 + description: 'Other violent content', 105 + knownType: KnownReasonType.toolsOzoneReportDefsReasonViolenceOther, 106 + ), 107 + ], 108 + ReportCategory.sexual: [ 109 + const ReportReason( 110 + value: 'tools.ozone.report.defs#reasonSexualAbuseContent', 111 + displayName: 'Abuse Content', 112 + description: 'Sexual abuse content', 113 + knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualAbuseContent, 114 + ), 115 + const ReportReason( 116 + value: 'tools.ozone.report.defs#reasonSexualNCII', 117 + displayName: 'Non-Consensual Intimate Images', 118 + description: 'Sharing intimate images without consent', 119 + knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualNCII, 120 + ), 121 + const ReportReason( 122 + value: 'tools.ozone.report.defs#reasonSexualDeepfake', 123 + displayName: 'Deepfake', 124 + description: 'AI-generated sexual content', 125 + knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualDeepfake, 126 + ), 127 + const ReportReason( 128 + value: 'tools.ozone.report.defs#reasonSexualAnimal', 129 + displayName: 'Animal Sexual Content', 130 + description: 'Sexual content involving animals', 131 + knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualAnimal, 132 + ), 133 + const ReportReason( 134 + value: 'tools.ozone.report.defs#reasonSexualUnlabeled', 135 + displayName: 'Unlabeled Sexual Content', 136 + description: 'Sexual content without proper warnings', 137 + knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualUnlabeled, 138 + ), 139 + const ReportReason( 140 + value: 'tools.ozone.report.defs#reasonSexualOther', 141 + displayName: 'Other Sexual Content', 142 + description: 'Other sexual content violations', 143 + knownType: KnownReasonType.toolsOzoneReportDefsReasonSexualOther, 144 + ), 145 + ], 146 + ReportCategory.childSafety: [ 147 + const ReportReason( 148 + value: 'tools.ozone.report.defs#reasonChildSafetyCSAM', 149 + displayName: 'CSAM', 150 + description: 'Child sexual abuse material', 151 + knownType: KnownReasonType.toolsOzoneReportDefsReasonChildSafetyCSAM, 152 + ), 153 + const ReportReason( 154 + value: 'tools.ozone.report.defs#reasonChildSafetyGroom', 155 + displayName: 'Grooming', 156 + description: 'Grooming behavior targeting minors', 157 + knownType: KnownReasonType.toolsOzoneReportDefsReasonChildSafetyGroom, 158 + ), 159 + const ReportReason( 160 + value: 'tools.ozone.report.defs#reasonChildSafetyPrivacy', 161 + displayName: 'Privacy Violation', 162 + description: 'Sharing private information about minors', 163 + knownType: KnownReasonType.toolsOzoneReportDefsReasonChildSafetyPrivacy, 164 + ), 165 + const ReportReason( 166 + value: 'tools.ozone.report.defs#reasonChildSafetyHarassment', 167 + displayName: 'Harassment', 168 + description: 'Harassment targeting minors', 169 + knownType: KnownReasonType.toolsOzoneReportDefsReasonChildSafetyHarassment, 170 + ), 171 + const ReportReason( 172 + value: 'tools.ozone.report.defs#reasonChildSafetyOther', 173 + displayName: 'Other Child Safety', 174 + description: 'Other child safety concerns', 175 + knownType: KnownReasonType.toolsOzoneReportDefsReasonChildSafetyOther, 176 + ), 177 + ], 178 + ReportCategory.harassment: [ 179 + const ReportReason( 180 + value: 'tools.ozone.report.defs#reasonHarassmentTroll', 181 + displayName: 'Trolling', 182 + description: 'Trolling or disruptive behavior', 183 + knownType: KnownReasonType.toolsOzoneReportDefsReasonHarassmentTroll, 184 + ), 185 + const ReportReason( 186 + value: 'tools.ozone.report.defs#reasonHarassmentTargeted', 187 + displayName: 'Targeted Harassment', 188 + description: 'Targeted harassment or bullying', 189 + knownType: KnownReasonType.toolsOzoneReportDefsReasonHarassmentTargeted, 190 + ), 191 + const ReportReason( 192 + value: 'tools.ozone.report.defs#reasonHarassmentHateSpeech', 193 + displayName: 'Hate Speech', 194 + description: 'Hate speech or discriminatory content', 195 + knownType: KnownReasonType.toolsOzoneReportDefsReasonHarassmentHateSpeech, 196 + ), 197 + const ReportReason( 198 + value: 'tools.ozone.report.defs#reasonHarassmentDoxxing', 199 + displayName: 'Doxxing', 200 + description: 'Sharing private information without consent', 201 + knownType: KnownReasonType.toolsOzoneReportDefsReasonHarassmentDoxxing, 202 + ), 203 + const ReportReason( 204 + value: 'tools.ozone.report.defs#reasonHarassmentOther', 205 + displayName: 'Other Harassment', 206 + description: 'Other harassment violations', 207 + knownType: KnownReasonType.toolsOzoneReportDefsReasonHarassmentOther, 208 + ), 209 + ], 210 + ReportCategory.misleading: [ 211 + const ReportReason( 212 + value: 'tools.ozone.report.defs#reasonMisleadingBot', 213 + displayName: 'Bot Account', 214 + description: 'Automated or bot account', 215 + knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingBot, 216 + ), 217 + const ReportReason( 218 + value: 'tools.ozone.report.defs#reasonMisleadingImpersonation', 219 + displayName: 'Impersonation', 220 + description: 'Impersonating another person or entity', 221 + knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingImpersonation, 222 + ), 223 + const ReportReason( 224 + value: 'tools.ozone.report.defs#reasonMisleadingSpam', 225 + displayName: 'Spam', 226 + description: 'Spam or repetitive content', 227 + knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingSpam, 228 + ), 229 + const ReportReason( 230 + value: 'tools.ozone.report.defs#reasonMisleadingScam', 231 + displayName: 'Scam', 232 + description: 'Fraudulent or scam content', 233 + knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingScam, 234 + ), 235 + const ReportReason( 236 + value: 'tools.ozone.report.defs#reasonMisleadingElections', 237 + displayName: 'Election Misinformation', 238 + description: 'False information about elections', 239 + knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingElections, 240 + ), 241 + const ReportReason( 242 + value: 'tools.ozone.report.defs#reasonMisleadingOther', 243 + displayName: 'Other Misleading', 244 + description: 'Other misleading content', 245 + knownType: KnownReasonType.toolsOzoneReportDefsReasonMisleadingOther, 246 + ), 247 + ], 248 + ReportCategory.ruleViolations: [ 249 + const ReportReason( 250 + value: 'tools.ozone.report.defs#reasonRuleSiteSecurity', 251 + displayName: 'Site Security', 252 + description: 'Violation of site security rules', 253 + knownType: KnownReasonType.toolsOzoneReportDefsReasonRuleSiteSecurity, 254 + ), 255 + const ReportReason( 256 + value: 'tools.ozone.report.defs#reasonRuleProhibitedSales', 257 + displayName: 'Prohibited Sales', 258 + description: 'Prohibited goods or services', 259 + knownType: KnownReasonType.toolsOzoneReportDefsReasonRuleProhibitedSales, 260 + ), 261 + const ReportReason( 262 + value: 'tools.ozone.report.defs#reasonRuleBanEvasion', 263 + displayName: 'Ban Evasion', 264 + description: 'Attempting to evade a ban', 265 + knownType: KnownReasonType.toolsOzoneReportDefsReasonRuleBanEvasion, 266 + ), 267 + const ReportReason( 268 + value: 'tools.ozone.report.defs#reasonRuleOther', 269 + displayName: 'Other Rule Violation', 270 + description: 'Other rule violations', 271 + knownType: KnownReasonType.toolsOzoneReportDefsReasonRuleOther, 272 + ), 273 + ], 274 + ReportCategory.selfHarm: [ 275 + const ReportReason( 276 + value: 'tools.ozone.report.defs#reasonSelfHarmContent', 277 + displayName: 'Self-Harm Content', 278 + description: 'Content promoting self-harm', 279 + knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmContent, 280 + ), 281 + const ReportReason( 282 + value: 'tools.ozone.report.defs#reasonSelfHarmED', 283 + displayName: 'Eating Disorder', 284 + description: 'Content promoting eating disorders', 285 + knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmED, 286 + ), 287 + const ReportReason( 288 + value: 'tools.ozone.report.defs#reasonSelfHarmStunts', 289 + displayName: 'Dangerous Stunts', 290 + description: 'Content showing dangerous stunts', 291 + knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmStunts, 292 + ), 293 + const ReportReason( 294 + value: 'tools.ozone.report.defs#reasonSelfHarmSubstances', 295 + displayName: 'Substance Abuse', 296 + description: 'Content promoting substance abuse', 297 + knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmSubstances, 298 + ), 299 + const ReportReason( 300 + value: 'tools.ozone.report.defs#reasonSelfHarmOther', 301 + displayName: 'Other Self-Harm', 302 + description: 'Other self-harm related content', 303 + knownType: KnownReasonType.toolsOzoneReportDefsReasonSelfHarmOther, 304 + ), 305 + ], 306 + ReportCategory.other: [ 307 + const ReportReason( 308 + value: 'tools.ozone.report.defs#reasonOther', 309 + displayName: 'Other', 310 + description: 'Other issues not listed above', 311 + knownType: KnownReasonType.toolsOzoneReportDefsReasonOther, 312 + ), 313 + ], 49 314 }; 50 315 51 316 @override ··· 54 319 super.dispose(); 55 320 } 56 321 322 + void _selectCategory(ReportCategory category) { 323 + setState(() { 324 + _selectedCategory = category; 325 + _selectedReason = null; 326 + }); 327 + } 328 + 329 + void _selectReason(ReportReason reason) { 330 + setState(() { 331 + _selectedReason = reason; 332 + }); 333 + } 334 + 335 + void _goBack() { 336 + setState(() { 337 + _selectedCategory = null; 338 + _selectedReason = null; 339 + }); 340 + } 341 + 57 342 Future<void> _submitReport() async { 343 + if (_selectedReason == null) return; 344 + 58 345 final subject = UModerationCreateReportSubject.repoStrongRef( 59 346 data: RepoStrongRef(cid: widget.postCid, uri: AtUri.parse(widget.postUri)), 60 347 ); ··· 66 353 }); 67 354 68 355 try { 356 + // Build the ReasonType: use known type if available, otherwise use unknown with the raw value 357 + final reasonType = _selectedReason!.knownType != null 358 + ? ReasonType.knownValue(data: _selectedReason!.knownType!) 359 + : ReasonType.unknown(data: _selectedReason!.value); 360 + 69 361 if (widget.onSubmit != null) { 70 - // Use the callback if provided 71 - widget.onSubmit!(subject, _selectedReason, reason, null); 362 + // Use the callback if provided - now passing ReasonType directly 363 + widget.onSubmit!(subject, reasonType, reason); 72 364 if (mounted) { 73 365 context.router.maybePop(); 74 366 } 75 367 } else { 76 368 // Get the repository directly and create the report 77 369 final repoRepository = GetIt.instance<SprkRepository>().repo; 78 - _logger.d('Creating report with reason: ${_selectedReason.value}'); 370 + _logger.d('Creating report with reason: ${_selectedReason!.value}'); 79 371 80 372 final success = await repoRepository.createReport( 81 373 input: ModerationCreateReportInput( 82 374 subject: subject, 83 - reasonType: ReasonType.knownValue(data: _selectedReason), 375 + reasonType: reasonType, 84 376 reason: reason, 85 377 ), 86 378 ); ··· 109 401 Widget build(BuildContext context) { 110 402 final theme = Theme.of(context); 111 403 final textColor = theme.textTheme.bodyMedium?.color ?? (theme.brightness == Brightness.dark ? Colors.white : Colors.black); 404 + final isStep2 = _selectedCategory != null; 405 + final reasons = isStep2 ? (_categoryReasons[_selectedCategory!] ?? <ReportReason>[]) : <ReportReason>[]; 406 + 112 407 return AlertDialog( 113 408 backgroundColor: theme.colorScheme.surface, 114 - title: Text('Report', style: theme.textTheme.titleLarge?.copyWith(color: textColor)), 409 + title: Row( 410 + children: [ 411 + if (isStep2) 412 + IconButton( 413 + icon: const Icon(Icons.arrow_back), 414 + onPressed: _goBack, 415 + iconSize: 20, 416 + padding: EdgeInsets.zero, 417 + constraints: const BoxConstraints(), 418 + color: textColor, 419 + ), 420 + if (isStep2) const SizedBox(width: 8), 421 + Expanded( 422 + child: Text( 423 + isStep2 ? _selectedCategory!.displayName : 'Report', 424 + style: theme.textTheme.titleLarge?.copyWith( 425 + color: textColor, 426 + fontWeight: FontWeight.bold, 427 + ), 428 + ), 429 + ), 430 + IconButton( 431 + icon: const Icon(Icons.close), 432 + onPressed: _isSubmitting ? null : () => context.router.maybePop(), 433 + iconSize: 20, 434 + padding: EdgeInsets.zero, 435 + constraints: const BoxConstraints(), 436 + color: textColor, 437 + ), 438 + ], 439 + ), 115 440 contentPadding: const EdgeInsets.fromLTRB(12, 8, 12, 8), 116 441 content: SizedBox( 117 442 width: double.maxFinite, ··· 119 444 mainAxisSize: MainAxisSize.min, 120 445 crossAxisAlignment: CrossAxisAlignment.start, 121 446 children: [ 122 - for (final reason in KnownReasonType.values) 123 - _ReasonTile( 124 - reason: reason, 125 - selectedReason: _selectedReason, 126 - reasonDescription: _reasonDescriptions[reason] ?? {'name': reason.value, 'description': ''}, 127 - onChanged: (value) { 128 - if (value != null) { 129 - setState(() { 130 - _selectedReason = value; 131 - }); 132 - } 133 - }, 447 + if (!isStep2) 448 + // Step 1: Category selection 449 + ...ReportCategory.values.map( 450 + (category) => _CategoryTile( 451 + category: category, 452 + selectedCategory: _selectedCategory, 453 + onTap: () => _selectCategory(category), 454 + ), 455 + ) 456 + else 457 + // Step 2: Reason selection 458 + ...reasons.map( 459 + (reason) => _ReasonTile( 460 + reason: reason, 461 + selectedReason: _selectedReason, 462 + onChanged: (value) { 463 + if (value != null) { 464 + _selectReason(value); 465 + } 466 + }, 467 + ), 134 468 ), 135 469 136 - const SizedBox(height: 8), 137 - TextField( 138 - controller: _additionalInfoController, 139 - maxLines: 3, 140 - style: theme.textTheme.bodySmall?.copyWith(color: textColor), 141 - decoration: InputDecoration( 142 - hintText: 'Additional details (optional)', 143 - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), 144 - border: const OutlineInputBorder(), 145 - hintStyle: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), 146 - fillColor: theme.colorScheme.surface, 147 - filled: true, 470 + if (isStep2 && _selectedReason != null) ...[ 471 + const SizedBox(height: 8), 472 + TextField( 473 + controller: _additionalInfoController, 474 + maxLines: 3, 475 + style: theme.textTheme.bodySmall?.copyWith(color: textColor), 476 + decoration: InputDecoration( 477 + hintText: 'Additional details (optional)', 478 + contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), 479 + border: const OutlineInputBorder(), 480 + hintStyle: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), 481 + fillColor: theme.colorScheme.surface, 482 + filled: true, 483 + ), 148 484 ), 149 - ), 485 + ], 150 486 151 487 if (_errorMessage != null) 152 488 Padding( ··· 165 501 ), 166 502 ), 167 503 actions: [ 168 - TextButton( 169 - onPressed: _isSubmitting ? null : () => context.router.maybePop(), 170 - child: Text('Cancel', style: TextStyle(color: textColor)), 171 - ), 172 - ElevatedButton( 173 - onPressed: _isSubmitting ? null : _submitReport, 174 - style: ElevatedButton.styleFrom( 175 - backgroundColor: theme.colorScheme.primary, 176 - foregroundColor: theme.colorScheme.onPrimary, 504 + if (isStep2 && _selectedReason != null) 505 + _isSubmitting 506 + ? const SizedBox( 507 + width: 16, 508 + height: 16, 509 + child: CircularProgressIndicator(strokeWidth: 2), 510 + ) 511 + : LongButton( 512 + label: 'Submit', 513 + onPressed: _submitReport, 514 + ), 515 + ], 516 + ); 517 + } 518 + } 519 + 520 + class _CategoryTile extends StatelessWidget { 521 + const _CategoryTile({ 522 + required this.category, 523 + required this.selectedCategory, 524 + required this.onTap, 525 + }); 526 + final ReportCategory category; 527 + final ReportCategory? selectedCategory; 528 + final VoidCallback onTap; 529 + 530 + @override 531 + Widget build(BuildContext context) { 532 + final theme = Theme.of(context); 533 + final textColor = theme.textTheme.bodyMedium?.color ?? (theme.brightness == Brightness.dark ? Colors.white : Colors.black); 534 + final isSelected = selectedCategory == category; 535 + 536 + return InkWell( 537 + onTap: onTap, 538 + borderRadius: BorderRadius.circular(8), 539 + child: Container( 540 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), 541 + margin: const EdgeInsets.only(bottom: 4), 542 + decoration: BoxDecoration( 543 + color: isSelected ? theme.colorScheme.primary.withAlpha(25) : Colors.transparent, 544 + borderRadius: BorderRadius.circular(8), 545 + border: Border.all( 546 + color: isSelected ? theme.colorScheme.primary : Colors.transparent, 177 547 ), 178 - child: _isSubmitting 179 - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) 180 - : const Text('Submit'), 181 548 ), 182 - ], 549 + child: Row( 550 + children: [ 551 + Expanded( 552 + child: Text( 553 + category.displayName, 554 + style: theme.textTheme.bodyMedium?.copyWith( 555 + color: textColor, 556 + fontWeight: FontWeight.w500, 557 + fontSize: 14, 558 + ), 559 + ), 560 + ), 561 + Icon( 562 + Icons.chevron_right, 563 + color: textColor.withAlpha(179), 564 + size: 20, 565 + ), 566 + ], 567 + ), 568 + ), 183 569 ); 184 570 } 185 571 } ··· 188 574 const _ReasonTile({ 189 575 required this.reason, 190 576 required this.selectedReason, 191 - required this.reasonDescription, 192 577 required this.onChanged, 193 578 }); 194 - final KnownReasonType reason; 195 - final KnownReasonType selectedReason; 196 - final Map<String, String> reasonDescription; 197 - final ValueChanged<KnownReasonType?> onChanged; 579 + final ReportReason reason; 580 + final ReportReason? selectedReason; 581 + final ValueChanged<ReportReason?> onChanged; 198 582 199 583 @override 200 584 Widget build(BuildContext context) { 201 585 final theme = Theme.of(context); 202 586 final textColor = theme.textTheme.bodyMedium?.color ?? (theme.brightness == Brightness.dark ? Colors.white : Colors.black); 203 - final friendlyName = reasonDescription['name'] ?? reason.value; 204 - final description = reasonDescription['description'] ?? ''; 205 587 206 - return RadioListTile<KnownReasonType>( 588 + return RadioListTile<ReportReason>( 207 589 title: Text( 208 - friendlyName, 590 + reason.displayName, 209 591 style: theme.textTheme.bodyMedium?.copyWith(color: textColor, fontWeight: FontWeight.w500, fontSize: 13), 210 592 ), 211 - subtitle: Text(description, style: theme.textTheme.bodySmall?.copyWith(color: textColor.withAlpha(179), fontSize: 10)), 593 + subtitle: reason.description != null 594 + ? Text( 595 + reason.description!, 596 + style: theme.textTheme.bodySmall?.copyWith(color: textColor.withAlpha(179), fontSize: 10), 597 + ) 598 + : null, 212 599 value: reason, 213 600 // ignore: deprecated_member_use 214 601 groupValue: selectedReason,
+7 -36
lib/src/features/auth/ui/pages/auth_prompt_page.dart
··· 2 2 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_svg/flutter_svg.dart'; 5 + import 'package:sparksocial/src/core/design_system/components/atoms/buttons/primary_button.dart'; 5 6 import 'package:sparksocial/src/core/routing/app_router.dart'; 6 7 import 'package:sparksocial/src/core/ui/foundation/colors.dart'; 7 8 ··· 61 62 ), 62 63 ), 63 64 const SizedBox(height: 60), 64 - ElevatedButton( 65 - style: ElevatedButton.styleFrom( 66 - backgroundColor: AppColors.primary, 67 - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24), 68 - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), 69 - minimumSize: const Size(320, 60), 70 - ), 65 + PrimaryButton( 66 + text: 'Login with ', 67 + trailing: SvgPicture.asset('assets/images/ataccount.svg', height: 22, width: 100), 71 68 onPressed: () { 72 69 context.router.push(const LoginRoute()); 73 70 }, 74 - child: Row( 75 - mainAxisSize: MainAxisSize.min, 76 - mainAxisAlignment: MainAxisAlignment.center, 77 - children: [ 78 - const Text( 79 - 'Login with ', 80 - style: TextStyle(fontWeight: FontWeight.normal, fontSize: 20, color: AppColors.white), 81 - ), 82 - SvgPicture.asset('assets/images/ataccount.svg', height: 22, width: 100), 83 - ], 84 - ), 85 71 ), 86 72 const SizedBox(height: 16), 87 - ElevatedButton( 88 - style: ElevatedButton.styleFrom( 89 - backgroundColor: AppColors.primary, 90 - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24), 91 - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), 92 - minimumSize: const Size(320, 60), 93 - ), 73 + PrimaryButton( 74 + text: 'Create an ', 75 + trailing: SvgPicture.asset('assets/images/ataccount.svg', height: 22, width: 100), 94 76 onPressed: () { 95 77 context.router.push(const RegisterRoute()); 96 78 }, 97 - child: Row( 98 - mainAxisSize: MainAxisSize.min, 99 - mainAxisAlignment: MainAxisAlignment.center, 100 - children: [ 101 - const Text( 102 - 'Create an ', 103 - style: TextStyle(fontWeight: FontWeight.normal, fontSize: 20, color: AppColors.white), 104 - ), 105 - SvgPicture.asset('assets/images/ataccount.svg', height: 22, width: 100), 106 - ], 107 - ), 108 79 ), 109 80 if (onClose != null) ...[ 110 81 const SizedBox(height: 24),
+26 -10
lib/src/features/comments/ui/widgets/comment_item.dart
··· 1 1 import 'package:atproto/com_atproto_moderation_createreport.dart'; 2 - import 'package:atproto/com_atproto_moderation_defs.dart'; 3 2 import 'package:atproto_core/atproto_core.dart'; 4 3 import 'package:auto_route/auto_route.dart'; 5 4 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 6 5 import 'package:flutter/material.dart'; 7 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 8 7 import 'package:get_it/get_it.dart'; 8 + import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 9 9 import 'package:sparksocial/src/core/network/atproto/data/models/feed_models.dart'; 10 10 import 'package:sparksocial/src/core/network/atproto/data/repositories/sprk_repository.dart'; 11 11 import 'package:sparksocial/src/core/routing/app_router.dart'; 12 12 import 'package:sparksocial/src/core/ui/foundation/colors.dart'; 13 13 import 'package:sparksocial/src/core/ui/widgets/image_content.dart'; 14 - import 'package:sparksocial/src/core/ui/widgets/menu_action_button.dart'; 14 + import 'package:sparksocial/src/core/ui/widgets/options_panel.dart'; 15 15 import 'package:sparksocial/src/core/ui/widgets/report_dialog.dart'; 16 16 import 'package:sparksocial/src/core/ui/widgets/user_avatar.dart'; 17 17 import 'package:sparksocial/src/features/comments/providers/comment_provider.dart'; ··· 47 47 builder: (context) => ReportDialog( 48 48 postUri: commentState.thread.post.uri.toString(), 49 49 postCid: commentState.thread.post.cid, 50 - onSubmit: (subject, reasonType, reason, service) async { 50 + onSubmit: (subject, reasonType, reason) async { 51 51 try { 52 52 final result = await sprkRepository.repo.createReport( 53 - input: ModerationCreateReportInput(subject: subject, reasonType: reasonType as ReasonType, reason: reason), 53 + input: ModerationCreateReportInput(subject: subject, reasonType: reasonType, reason: reason), 54 54 ); 55 55 56 56 if (result) { ··· 150 150 ), 151 151 ), 152 152 ), 153 - MenuActionButton( 154 - onPressed: _handleReportComment, 155 - onDeletePressed: _handleDeleteComment, 156 - isCompact: true, 157 - backgroundColor: Theme.of(context).colorScheme.surface, 158 - authorDid: commentState.thread.post.author.did, 153 + Builder( 154 + builder: (context) { 155 + final authRepository = GetIt.instance<AuthRepository>(); 156 + final userDid = authRepository.session?.did; 157 + final isCurrentUserAuthor = userDid == commentState.thread.post.author.did; 158 + final theme = Theme.of(context); 159 + final isDark = theme.brightness == Brightness.dark; 160 + final iconColor = isDark ? AppColors.white : AppColors.black; 161 + 162 + return GestureDetector( 163 + onTap: () => OptionsPanel.show( 164 + context: context, 165 + onReport: _handleReportComment, 166 + onDelete: isCurrentUserAuthor ? _handleDeleteComment : null, 167 + ), 168 + child: SizedBox( 169 + width: 28, 170 + height: 28, 171 + child: Icon(Icons.more_horiz, color: iconColor, size: 16), 172 + ), 173 + ); 174 + }, 159 175 ), 160 176 ], 161 177 ),
+28
lib/src/features/feed/ui/widgets/action_buttons/side_action_bar.dart
··· 2 2 import 'package:auto_route/auto_route.dart'; 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 + import 'package:sparksocial/src/core/design_system/components/atoms/icons.dart'; 5 6 import 'package:sparksocial/src/core/design_system/components/organisms/side_action_bar.dart'; 6 7 import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 7 8 import 'package:sparksocial/src/core/routing/app_router.dart'; 9 + import 'package:sparksocial/src/core/ui/widgets/options_panel.dart'; 10 + import 'package:sparksocial/src/core/ui/widgets/report_dialog.dart'; 8 11 import 'package:sparksocial/src/features/feed/providers/feed_provider.dart'; 9 12 import 'package:sparksocial/src/features/feed/providers/like_post.dart'; 10 13 import 'package:sparksocial/src/features/feed/ui/widgets/action_buttons/share_panel.dart'; ··· 198 201 } 199 202 } 200 203 204 + void _handleReport() { 205 + final currentPost = _currentPost ?? widget.post; 206 + showDialog( 207 + context: context, 208 + builder: (context) => ReportDialog( 209 + postUri: currentPost.uri.toString(), 210 + postCid: currentPost.cid, 211 + ), 212 + ); 213 + } 214 + 201 215 // Future<void> _handleCurate() async { 202 216 // // For now, this is a placeholder for curate functionality 203 217 // // In the future, this could add the post to a custom feed or collection ··· 233 247 soundCover: currentPost.sound?.coverArt.toString(), 234 248 // isCurated: isCurated, // Curation disabled 235 249 // curateDestinations: curateDestinations, // Curation disabled 250 + optionsButton: GestureDetector( 251 + behavior: HitTestBehavior.opaque, 252 + onTap: () => OptionsPanel.show( 253 + context: context, 254 + onReport: _handleReport, 255 + ), 256 + child: SizedBox( 257 + width: 40, 258 + height: 40, 259 + child: Center( 260 + child: AppIcons.moreHoriz(size: 32, color: Colors.white), 261 + ), 262 + ), 263 + ), 236 264 ); 237 265 } 238 266 }
+2 -2
lib/src/features/profile/providers/profile_provider.dart
··· 180 180 } 181 181 } 182 182 183 - Future<bool> createReport({required String did, required KnownReasonType reasonType, String? reason}) async { 183 + Future<bool> createReport({required String did, required ReasonType reasonType, String? reason}) async { 184 184 if (!authRepository.isAuthenticated) { 185 185 logger.w('Cannot create report, user not authenticated'); 186 186 final currentData = state.asData?.value; ··· 194 194 logger.d('Creating report for DID: $did with reason: $reasonType'); 195 195 final subject = UModerationCreateReportSubject.repoRef(data: RepoRef(did: did)); 196 196 final result = await sprkRepository.repo.createReport( 197 - input: ModerationCreateReportInput(subject: subject, reasonType: reasonType as ReasonType, reason: reason), 197 + input: ModerationCreateReportInput(subject: subject, reasonType: reasonType, reason: reason), 198 198 ); 199 199 logger.i('Report created successfully for $did'); 200 200 return result;
+33 -27
lib/src/features/profile/ui/pages/profile_page.dart
··· 13 13 import 'package:sparksocial/src/core/network/atproto/atproto.dart'; 14 14 import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart' as actor_models; 15 15 import 'package:sparksocial/src/core/routing/app_router.dart'; 16 - import 'package:sparksocial/src/core/ui/widgets/menu_action_button.dart'; 16 + import 'package:sparksocial/src/core/ui/widgets/options_panel.dart'; 17 17 import 'package:sparksocial/src/core/ui/widgets/report_dialog.dart'; 18 18 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 19 19 import 'package:sparksocial/src/core/utils/logging/logger.dart'; ··· 206 206 else 207 207 Padding( 208 208 padding: const EdgeInsets.only(right: 8), 209 - child: MenuActionButton( 210 - onPressed: () => showDialog( 209 + child: GestureDetector( 210 + onTap: () => OptionsPanel.show( 211 211 context: context, 212 - useRootNavigator: false, 213 - builder: (dContext) => ReportDialog( 214 - postUri: 'at://${profile.did}/app.bsky.actor.profile/self', 215 - postCid: profile.did, 216 - onSubmit: (subject, reasonType, reason, service) async { 217 - try { 218 - final success = await notifier.createReport( 219 - did: profile.did, 220 - reasonType: reasonType, 221 - reason: reason, 222 - ); 223 - if (success && context.mounted) { 224 - ScaffoldMessenger.of(context).showSnackBar( 225 - const SnackBar(content: Text('Report submitted successfully')), 226 - ); 227 - } 228 - } catch (e) { 229 - if (context.mounted) { 230 - ScaffoldMessenger.of(context).showSnackBar( 231 - SnackBar(content: Text('Error submitting report: $e')), 212 + onReport: () => showDialog( 213 + context: context, 214 + useRootNavigator: false, 215 + builder: (dContext) => ReportDialog( 216 + postUri: 'at://${profile.did}/app.bsky.actor.profile/self', 217 + postCid: profile.did, 218 + onSubmit: (subject, reasonType, reason) async { 219 + try { 220 + final success = await notifier.createReport( 221 + did: profile.did, 222 + reasonType: reasonType, 223 + reason: reason, 232 224 ); 225 + if (success && context.mounted) { 226 + ScaffoldMessenger.of(context).showSnackBar( 227 + const SnackBar(content: Text('Report submitted successfully')), 228 + ); 229 + } 230 + } catch (e) { 231 + if (context.mounted) { 232 + ScaffoldMessenger.of(context).showSnackBar( 233 + SnackBar(content: Text('Error submitting report: $e')), 234 + ); 235 + } 233 236 } 234 - } 235 - }, 237 + }, 238 + ), 236 239 ), 240 + isProfile: true, 237 241 ), 238 - backgroundColor: colorScheme.onSurface.withAlpha(30), 239 - isProfile: true, 242 + child: Container( 243 + padding: const EdgeInsets.all(8), 244 + child: AppIcons.moreHoriz(color: colorScheme.onSurface), 245 + ), 240 246 ), 241 247 ), 242 248 ],