Grain flutter app
1import 'package:flutter/cupertino.dart';
2import 'package:flutter/material.dart';
3import 'package:flutter/services.dart';
4import 'package:grain/api.dart';
5import 'package:grain/app_icons.dart';
6import 'package:grain/widgets/app_image.dart';
7import 'package:grain/widgets/gallery_preview.dart';
8import 'package:grain/widgets/faceted_text_field.dart';
9
10Future<void> showAddCommentSheet(
11 BuildContext context, {
12 String initialText = '',
13 required Future<void> Function(String text) onSubmit,
14 VoidCallback? onCancel,
15 dynamic gallery, // Pass gallery object
16 dynamic replyTo, // Pass comment/user object if replying to a comment
17}) async {
18 final theme = Theme.of(context);
19 final controller = TextEditingController(text: initialText);
20 await showCupertinoSheet(
21 context: context,
22 useNestedNavigation: false,
23 pageBuilder: (context) => Material(
24 type: MaterialType.transparency,
25 child: _AddCommentSheet(
26 controller: controller,
27 onSubmit: onSubmit,
28 onCancel: onCancel,
29 gallery: gallery,
30 replyTo: replyTo,
31 ),
32 ),
33 );
34 SystemChrome.setSystemUIOverlayStyle(
35 theme.brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark,
36 );
37}
38
39class _AddCommentSheet extends StatefulWidget {
40 final TextEditingController controller;
41 final Future<void> Function(String text) onSubmit;
42 final VoidCallback? onCancel;
43 final dynamic gallery;
44 final dynamic replyTo;
45 const _AddCommentSheet({
46 required this.controller,
47 required this.onSubmit,
48 this.onCancel,
49 this.gallery,
50 this.replyTo,
51 });
52
53 @override
54 State<_AddCommentSheet> createState() => _AddCommentSheetState();
55}
56
57class _AddCommentSheetState extends State<_AddCommentSheet> {
58 bool _posting = false;
59 String _currentText = '';
60 final FocusNode _focusNode = FocusNode();
61
62 @override
63 void initState() {
64 super.initState();
65 _currentText = widget.controller.text;
66 widget.controller.addListener(_onTextChanged);
67 // Request focus after build
68 WidgetsBinding.instance.addPostFrameCallback((_) {
69 _focusNode.requestFocus();
70 });
71 }
72
73 void _onTextChanged() {
74 if (_currentText != widget.controller.text) {
75 setState(() {
76 _currentText = widget.controller.text;
77 });
78 }
79 }
80
81 @override
82 void dispose() {
83 widget.controller.removeListener(_onTextChanged);
84 widget.controller.dispose();
85 super.dispose();
86 }
87
88 @override
89 Widget build(BuildContext context) {
90 final theme = Theme.of(context);
91 final gallery = widget.gallery;
92 final replyTo = widget.replyTo;
93 final creator = replyTo != null
94 ? (replyTo is Map && replyTo['author'] != null ? replyTo['author'] : null)
95 : gallery?.creator;
96 final focusPhoto = replyTo != null
97 ? (replyTo is Map && replyTo['focus'] != null ? replyTo['focus'] : null)
98 : null;
99 return CupertinoPageScaffold(
100 backgroundColor: theme.colorScheme.surface,
101 navigationBar: CupertinoNavigationBar(
102 backgroundColor: theme.colorScheme.surface,
103 border: Border(bottom: BorderSide(color: theme.dividerColor, width: 1)),
104 middle: Text(
105 replyTo != null ? 'Reply' : 'Add comment',
106 style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
107 ),
108 leading: CupertinoButton(
109 padding: EdgeInsets.zero,
110 onPressed: _posting ? null : widget.onCancel ?? () => Navigator.of(context).maybePop(),
111 child: Text(
112 'Cancel',
113 style: TextStyle(color: theme.colorScheme.primary, fontWeight: FontWeight.w600),
114 ),
115 ),
116 trailing: CupertinoButton(
117 padding: EdgeInsets.zero,
118 onPressed: _posting || _currentText.trim().isEmpty
119 ? null
120 : () async {
121 setState(() => _posting = true);
122 await widget.onSubmit(_currentText.trim());
123 if (mounted) Navigator.of(context).pop();
124 setState(() => _posting = false);
125 },
126 child: Row(
127 mainAxisSize: MainAxisSize.min,
128 children: [
129 Text(
130 'Post',
131 style: TextStyle(
132 color: _posting || _currentText.trim().isEmpty
133 ? theme.disabledColor
134 : theme.colorScheme.primary,
135 fontWeight: FontWeight.w600,
136 ),
137 ),
138 if (_posting) ...[
139 const SizedBox(width: 8),
140 SizedBox(
141 width: 16,
142 height: 16,
143 child: CircularProgressIndicator(
144 strokeWidth: 2,
145 valueColor: AlwaysStoppedAnimation<Color>(theme.colorScheme.primary),
146 semanticsLabel: 'Posting',
147 ),
148 ),
149 ],
150 ],
151 ),
152 ),
153 ),
154 child: SafeArea(
155 bottom: false,
156 child: Padding(
157 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
158 child: Column(
159 children: [
160 if ((gallery != null && creator != null && replyTo == null) ||
161 (replyTo != null && creator != null)) ...[
162 Row(
163 crossAxisAlignment: CrossAxisAlignment.start,
164 children: [
165 if ((creator is Map &&
166 creator['avatar'] != null &&
167 creator['avatar'].isNotEmpty) ||
168 (creator is! Map && creator.avatar != null && creator.avatar.isNotEmpty))
169 ClipOval(
170 child: AppImage(
171 url: creator is Map ? creator['avatar'] : creator.avatar,
172 width: 40,
173 height: 40,
174 fit: BoxFit.cover,
175 ),
176 )
177 else
178 CircleAvatar(
179 radius: 20,
180 backgroundColor: theme.colorScheme.surfaceContainerHighest,
181 child: Icon(AppIcons.person, size: 20, color: theme.iconTheme.color),
182 ),
183 Expanded(
184 child: Padding(
185 padding: const EdgeInsets.only(left: 20.0),
186 child: Column(
187 crossAxisAlignment: CrossAxisAlignment.start,
188 children: [
189 Column(
190 crossAxisAlignment: CrossAxisAlignment.start,
191 children: [
192 Text(
193 creator is Map
194 ? (creator['displayName'] ?? '')
195 : (creator.displayName ?? ''),
196 style: theme.textTheme.bodyMedium?.copyWith(
197 fontWeight: FontWeight.bold,
198 ),
199 ),
200 if ((creator is Map
201 ? (creator['handle'] ?? '')
202 : (creator.handle ?? ''))
203 .isNotEmpty) ...[
204 const SizedBox(height: 1),
205 Text(
206 '@${creator is Map ? creator['handle'] : creator.handle}',
207 style: theme.textTheme.bodySmall?.copyWith(
208 color: theme.hintColor,
209 ),
210 ),
211 ],
212 ],
213 ),
214 const SizedBox(height: 8),
215 if (replyTo != null &&
216 replyTo is Map &&
217 replyTo['text'] != null &&
218 (replyTo['text'] as String).isNotEmpty) ...[
219 Padding(
220 padding: const EdgeInsets.only(bottom: 8.0),
221 child: Text(
222 replyTo['text'],
223 style: theme.textTheme.bodySmall?.copyWith(
224 color: theme.hintColor,
225 ),
226 maxLines: 3,
227 overflow: TextOverflow.ellipsis,
228 ),
229 ),
230 ],
231 if (replyTo != null && focusPhoto != null) ...[
232 SizedBox(
233 height: 64,
234 child: AspectRatio(
235 aspectRatio:
236 (focusPhoto.aspectRatio != null &&
237 focusPhoto.aspectRatio.width > 0 &&
238 focusPhoto.aspectRatio.height > 0)
239 ? focusPhoto.aspectRatio.width / focusPhoto.aspectRatio.height
240 : 1.0,
241 child: AppImage(
242 url: focusPhoto.thumb?.isNotEmpty == true
243 ? focusPhoto.thumb
244 : focusPhoto.fullsize,
245 fit: BoxFit.cover,
246 borderRadius: BorderRadius.circular(8),
247 ),
248 ),
249 ),
250 ] else if (replyTo == null) ...[
251 Column(
252 crossAxisAlignment: CrossAxisAlignment.start,
253 children: [
254 Row(
255 children: [
256 Text(
257 gallery.title ?? '',
258 style: theme.textTheme.bodyMedium?.copyWith(
259 fontWeight: FontWeight.w600,
260 ),
261 maxLines: 1,
262 overflow: TextOverflow.ellipsis,
263 ),
264 ],
265 ),
266 const SizedBox(height: 4),
267 Row(
268 children: [
269 Align(
270 alignment: Alignment.centerLeft,
271 child: SizedBox(
272 height: 64,
273 child: GalleryPreview(gallery: gallery),
274 ),
275 ),
276 ],
277 ),
278 ],
279 ),
280 ],
281 ],
282 ),
283 ),
284 ),
285 ],
286 ),
287 const SizedBox(height: 16),
288 Divider(height: 1, thickness: 1, color: theme.dividerColor),
289 const SizedBox(height: 16),
290 ],
291 Expanded(
292 child: Row(
293 crossAxisAlignment: CrossAxisAlignment.start,
294 children: [
295 // Current user avatar
296 if (apiService.currentUser?.avatar != null &&
297 (apiService.currentUser?.avatar?.isNotEmpty ?? false))
298 Padding(
299 padding: const EdgeInsets.only(right: 8.0, top: 4.0),
300 child: ClipOval(
301 child: AppImage(
302 url: apiService.currentUser!.avatar!,
303 width: 40,
304 height: 40,
305 fit: BoxFit.cover,
306 ),
307 ),
308 )
309 else
310 Padding(
311 padding: const EdgeInsets.only(right: 8.0, top: 4.0),
312 child: CircleAvatar(
313 radius: 20,
314 backgroundColor: theme.colorScheme.surfaceContainerHighest,
315 child: Icon(AppIcons.person, size: 20, color: theme.iconTheme.color),
316 ),
317 ),
318 // Text input
319 Expanded(
320 child: Padding(
321 padding: const EdgeInsets.only(left: 10),
322 child: FacetedTextField(
323 controller: widget.controller,
324 maxLines: 6,
325 enabled: true,
326 keyboardType: TextInputType.multiline,
327 hintText: 'Add a comment',
328 // The FacetedTextField handles its own style and padding internally
329 ),
330 ),
331 ),
332 ],
333 ),
334 ),
335 const SizedBox(height: 24),
336 ],
337 ),
338 ),
339 ),
340 );
341 }
342}