mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
1import 'package:atproto/com_atproto_moderation_defs.dart';
2import 'package:atproto_core/atproto_core.dart';
3import 'package:flutter/material.dart';
4import 'package:flutter_bloc/flutter_bloc.dart';
5import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart';
6import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart';
7import 'package:lazurite/core/theme/theme_extensions.dart';
8
9enum _ReportType { post, actor }
10
11class ReportDialog extends StatefulWidget {
12 const ReportDialog.post({super.key, required this.postUri, required this.cid, required this.authorHandle})
13 : actorDid = null,
14 _type = _ReportType.post;
15
16 const ReportDialog.actor({super.key, required String did, required this.authorHandle})
17 : postUri = null,
18 cid = null,
19 actorDid = did,
20 _type = _ReportType.actor;
21
22 final AtUri? postUri;
23 final String? cid;
24 final String? actorDid;
25 final String authorHandle;
26 final _ReportType _type;
27
28 @override
29 State<ReportDialog> createState() => _ReportDialogState();
30}
31
32class _ReportDialogState extends State<ReportDialog> {
33 final _reasonController = TextEditingController();
34 ReasonType? _selectedReason;
35 bool _isSubmitting = false;
36
37 static const _reasonOptions = [
38 (
39 type: ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonSpam),
40 label: 'Spam',
41 description: 'Spam or unsolicited content',
42 ),
43 (
44 type: ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonViolation),
45 label: 'Violation',
46 description: 'Violates community guidelines',
47 ),
48 (
49 type: ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonMisleading),
50 label: 'Misleading',
51 description: 'Misleading or deceptive content',
52 ),
53 (
54 type: ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonSexual),
55 label: 'Sexual Content',
56 description: 'Unwanted sexual content',
57 ),
58 (
59 type: ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonRude),
60 label: 'Harassment',
61 description: 'Harassment or rude behaviour',
62 ),
63 (
64 type: ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonOther),
65 label: 'Other',
66 description: 'Other reason (requires explanation)',
67 ),
68 ];
69
70 @override
71 void dispose() {
72 _reasonController.dispose();
73 super.dispose();
74 }
75
76 @override
77 Widget build(BuildContext context) {
78 final title = widget._type == _ReportType.post ? 'Report Post' : 'Report Account';
79 final target = widget.authorHandle;
80
81 return AlertDialog(
82 title: Text('$title by @$target'),
83 content: SizedBox(
84 width: double.maxFinite,
85 child: Column(
86 mainAxisSize: MainAxisSize.min,
87 crossAxisAlignment: CrossAxisAlignment.start,
88 children: [
89 Text('Reason', style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
90 const SizedBox(height: 8),
91 ..._reasonOptions.map((option) => _buildReasonOption(option)),
92 if (_requiresExplanation) ...[
93 const SizedBox(height: 16),
94 Text(
95 'Explanation (required)',
96 style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
97 ),
98 const SizedBox(height: 8),
99 TextField(
100 controller: _reasonController,
101 maxLines: 3,
102 maxLength: 2000,
103 decoration: const InputDecoration(
104 hintText: 'Please explain why you are reporting this...',
105 border: OutlineInputBorder(),
106 ),
107 ),
108 ],
109 ],
110 ),
111 ),
112 actions: [
113 TextButton(onPressed: _isSubmitting ? null : () => Navigator.pop(context), child: const Text('Cancel')),
114 FilledButton(
115 onPressed: _canSubmit ? _submit : null,
116 child: _isSubmitting
117 ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
118 : const Text('Submit Report'),
119 ),
120 ],
121 );
122 }
123
124 Widget _buildReasonOption(({ReasonType type, String label, String description}) option) {
125 final isSelected = _selectedReason == option.type;
126 final theme = Theme.of(context);
127
128 return InkWell(
129 onTap: _isSubmitting
130 ? null
131 : () {
132 HapticHelper.selectionClick();
133 setState(() {
134 _selectedReason = option.type;
135 });
136 },
137 child: Padding(
138 padding: const EdgeInsets.symmetric(vertical: 8),
139 child: Row(
140 crossAxisAlignment: CrossAxisAlignment.start,
141 children: [
142 Container(
143 width: 20,
144 height: 20,
145 decoration: BoxDecoration(
146 shape: BoxShape.circle,
147 border: Border.all(color: isSelected ? theme.colorScheme.primary : theme.colorScheme.outline, width: 2),
148 ),
149 child: isSelected
150 ? Center(
151 child: Container(
152 width: 10,
153 height: 10,
154 decoration: BoxDecoration(shape: BoxShape.circle, color: theme.colorScheme.primary),
155 ),
156 )
157 : null,
158 ),
159 const SizedBox(width: 12),
160 Expanded(
161 child: Column(
162 crossAxisAlignment: CrossAxisAlignment.start,
163 children: [
164 Text(option.label, style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500)),
165 Text(
166 option.description,
167 style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
168 ),
169 ],
170 ),
171 ),
172 ],
173 ),
174 ),
175 );
176 }
177
178 bool get _requiresExplanation {
179 if (_selectedReason == null) return false;
180 if (_selectedReason!.isNotKnownValue) return false;
181 return _selectedReason!.knownValue == KnownReasonType.comAtprotoModerationDefsReasonOther;
182 }
183
184 bool get _canSubmit {
185 if (_selectedReason == null) return false;
186 if (_requiresExplanation && _reasonController.text.trim().isEmpty) return false;
187 return !_isSubmitting;
188 }
189
190 Future<void> _submit() async {
191 if (!_canSubmit) return;
192
193 setState(() {
194 _isSubmitting = true;
195 });
196
197 final cubit = context.read<ProfileActionCubit>();
198 final reason = _reasonController.text.trim().isNotEmpty ? _reasonController.text.trim() : null;
199
200 String? reportId;
201
202 if (widget._type == _ReportType.post && widget.postUri != null && widget.cid != null) {
203 reportId = await cubit.reportPost(
204 postUri: widget.postUri!,
205 cid: widget.cid!,
206 reasonType: _selectedReason!,
207 reason: reason,
208 );
209 } else if (widget._type == _ReportType.actor) {
210 reportId = await cubit.reportActor(reasonType: _selectedReason!, reason: reason);
211 }
212
213 if (mounted) {
214 Navigator.pop(context);
215
216 if (reportId != null) {
217 _showSuccessDialog(reportId);
218 } else {
219 _showErrorDialog();
220 }
221 }
222 }
223
224 void _showSuccessDialog(String reportId) {
225 showDialog<void>(
226 context: context,
227 builder: (context) => AlertDialog(
228 title: const Row(
229 children: [
230 Icon(Icons.check_circle, color: Colors.green),
231 SizedBox(width: 8),
232 Text('Report Submitted'),
233 ],
234 ),
235 content: Text('Thank you. Your report (ID: $reportId) has been submitted.'),
236 actions: [FilledButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
237 ),
238 );
239 }
240
241 void _showErrorDialog() {
242 showDialog<void>(
243 context: context,
244 builder: (context) => AlertDialog(
245 title: const Row(
246 children: [
247 Icon(Icons.error_outline, color: Colors.red),
248 SizedBox(width: 8),
249 Text('Report Failed'),
250 ],
251 ),
252 content: const Text('Unable to submit your report. Please try again later.'),
253 actions: [FilledButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
254 ),
255 );
256 }
257}