at main 14 kB view raw
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}