1import 'dart:async'; 2 3import 'package:flutter/material.dart'; 4import 'package:flutter/services.dart'; 5 6import '../constants/app_colors.dart'; 7 8/// Comment Composer Widget 9/// 10/// Reusable widget for composing comments across the app. 11/// Used in post detail screens and potentially nested comment replies. 12/// 13/// Features: 14/// - Multi-line text input with auto-expanding height 15/// - @ mention button (coming soon) 16/// - Image upload button (coming soon) 17/// - Send button with validation 18/// - Proper keyboard handling 19/// 20/// Note: This widget is currently unused but has been created for future use 21/// in other parts of the app where inline comment composition is needed. 22class CommentComposer extends StatefulWidget { 23 const CommentComposer({ 24 required this.onSubmit, 25 this.placeholder = 'Say something...', 26 this.autofocus = false, 27 super.key, 28 }); 29 30 /// Callback when user submits a comment 31 final Future<void> Function(String content) onSubmit; 32 33 /// Placeholder text for the input field 34 final String placeholder; 35 36 /// Whether to autofocus the input field 37 final bool autofocus; 38 39 @override 40 State<CommentComposer> createState() => _CommentComposerState(); 41} 42 43class _CommentComposerState extends State<CommentComposer> { 44 final TextEditingController _textController = TextEditingController(); 45 final FocusNode _focusNode = FocusNode(); 46 bool _hasText = false; 47 bool _isSubmitting = false; 48 Timer? _bannerDismissTimer; 49 50 @override 51 void initState() { 52 super.initState(); 53 _textController.addListener(_onTextChanged); 54 if (widget.autofocus) { 55 // Focus after frame is built 56 WidgetsBinding.instance.addPostFrameCallback((_) { 57 if (mounted) { 58 _focusNode.requestFocus(); 59 } 60 }); 61 } 62 } 63 64 @override 65 void dispose() { 66 _bannerDismissTimer?.cancel(); 67 _textController.dispose(); 68 _focusNode.dispose(); 69 super.dispose(); 70 } 71 72 void _onTextChanged() { 73 final hasText = _textController.text.trim().isNotEmpty; 74 if (hasText != _hasText) { 75 setState(() { 76 _hasText = hasText; 77 }); 78 } 79 } 80 81 Future<void> _handleSubmit() async { 82 final content = _textController.text.trim(); 83 if (content.isEmpty) { 84 return; 85 } 86 87 // Add haptic feedback before submission 88 await HapticFeedback.lightImpact(); 89 90 // Set loading state 91 setState(() { 92 _isSubmitting = true; 93 }); 94 95 try { 96 await widget.onSubmit(content); 97 _textController.clear(); 98 // Keep focus for rapid commenting 99 } on Exception catch (e) { 100 // Show error if submission fails 101 if (mounted) { 102 ScaffoldMessenger.of(context).showSnackBar( 103 SnackBar( 104 content: Text('Failed to submit: $e'), 105 backgroundColor: AppColors.primary, 106 behavior: SnackBarBehavior.floating, 107 ), 108 ); 109 } 110 } finally { 111 // Always reset loading state 112 if (mounted) { 113 setState(() { 114 _isSubmitting = false; 115 }); 116 } 117 } 118 } 119 120 void _showComingSoonBanner(String feature) { 121 // Cancel any existing timer to prevent multiple banners 122 _bannerDismissTimer?.cancel(); 123 124 final messenger = ScaffoldMessenger.of(context); 125 messenger.showMaterialBanner( 126 MaterialBanner( 127 content: Text('$feature coming soon!'), 128 backgroundColor: AppColors.primary, 129 leading: const Icon(Icons.info_outline, color: AppColors.textPrimary), 130 actions: [ 131 TextButton( 132 onPressed: messenger.hideCurrentMaterialBanner, 133 child: const Text( 134 'Dismiss', 135 style: TextStyle(color: AppColors.textPrimary), 136 ), 137 ), 138 ], 139 ), 140 ); 141 142 // Auto-hide after 2 seconds with cancelable timer 143 _bannerDismissTimer = Timer(const Duration(seconds: 2), () { 144 if (mounted) { 145 messenger.hideCurrentMaterialBanner(); 146 } 147 }); 148 } 149 150 void _handleMentionTap() { 151 _showComingSoonBanner('Mention feature'); 152 } 153 154 void _handleImageTap() { 155 _showComingSoonBanner('Image upload'); 156 } 157 158 @override 159 Widget build(BuildContext context) { 160 // Calculate max height for text input: 50% of screen 161 final maxTextHeight = MediaQuery.of(context).size.height * 0.5; 162 163 return Container( 164 decoration: const BoxDecoration( 165 color: AppColors.backgroundSecondary, 166 border: Border(top: BorderSide(color: AppColors.border)), 167 ), 168 child: Padding( 169 padding: EdgeInsets.only( 170 left: 12, 171 right: 12, 172 top: 6, 173 bottom: 6 + MediaQuery.of(context).padding.bottom, 174 ), 175 child: Column( 176 mainAxisSize: MainAxisSize.min, 177 children: [ 178 // Text input (scrollable if too long) 179 ConstrainedBox( 180 constraints: BoxConstraints(maxHeight: maxTextHeight), 181 child: Theme( 182 data: Theme.of(context).copyWith( 183 scrollbarTheme: ScrollbarThemeData( 184 thumbColor: WidgetStateProperty.all( 185 AppColors.textSecondary.withValues(alpha: 0.3), 186 ), 187 ), 188 ), 189 child: Scrollbar( 190 thumbVisibility: false, 191 thickness: 3, 192 radius: const Radius.circular(2), 193 child: SingleChildScrollView( 194 child: Container( 195 decoration: BoxDecoration( 196 color: AppColors.background, 197 borderRadius: BorderRadius.circular(20), 198 ), 199 child: TextField( 200 controller: _textController, 201 focusNode: _focusNode, 202 maxLines: null, 203 keyboardType: TextInputType.multiline, 204 textCapitalization: TextCapitalization.sentences, 205 textInputAction: TextInputAction.newline, 206 style: const TextStyle( 207 color: AppColors.textPrimary, 208 fontSize: 14, 209 ), 210 decoration: InputDecoration( 211 hintText: widget.placeholder, 212 hintStyle: TextStyle( 213 color: AppColors.textSecondary.withValues( 214 alpha: 0.6, 215 ), 216 fontSize: 15, 217 ), 218 border: InputBorder.none, 219 contentPadding: const EdgeInsets.symmetric( 220 horizontal: 16, 221 vertical: 10, 222 ), 223 ), 224 ), 225 ), 226 ), 227 ), 228 ), 229 ), 230 const SizedBox(height: 8), 231 // Action buttons row with send button (always visible) 232 Row( 233 children: [ 234 // Mention button 235 Semantics( 236 button: true, 237 label: 'Mention user', 238 child: GestureDetector( 239 onTap: _handleMentionTap, 240 child: const Padding( 241 padding: EdgeInsets.all(8), 242 child: Icon( 243 Icons.alternate_email_rounded, 244 size: 24, 245 color: AppColors.textSecondary, 246 ), 247 ), 248 ), 249 ), 250 const SizedBox(width: 4), 251 // Image button 252 Semantics( 253 button: true, 254 label: 'Add image', 255 child: GestureDetector( 256 onTap: _handleImageTap, 257 child: const Padding( 258 padding: EdgeInsets.all(8), 259 child: Icon( 260 Icons.image_outlined, 261 size: 24, 262 color: AppColors.textSecondary, 263 ), 264 ), 265 ), 266 ), 267 const Spacer(), 268 // Send button (pill-shaped) 269 Semantics( 270 button: true, 271 label: 'Send comment', 272 child: GestureDetector( 273 onTap: (_hasText && !_isSubmitting) ? _handleSubmit : null, 274 child: Container( 275 height: 32, 276 padding: const EdgeInsets.symmetric(horizontal: 14), 277 decoration: BoxDecoration( 278 color: 279 (_hasText && !_isSubmitting) 280 ? AppColors.primary 281 : AppColors.textSecondary.withValues( 282 alpha: 0.3, 283 ), 284 borderRadius: BorderRadius.circular(20), 285 ), 286 child: Row( 287 mainAxisSize: MainAxisSize.min, 288 children: [ 289 if (_isSubmitting) 290 const SizedBox( 291 width: 14, 292 height: 14, 293 child: CircularProgressIndicator( 294 strokeWidth: 2, 295 valueColor: AlwaysStoppedAnimation<Color>( 296 AppColors.textPrimary, 297 ), 298 ), 299 ) 300 else 301 const Text( 302 'Send', 303 style: TextStyle( 304 color: AppColors.textPrimary, 305 fontSize: 13, 306 fontWeight: FontWeight.normal, 307 ), 308 ), 309 ], 310 ), 311 ), 312 ), 313 ), 314 ], 315 ), 316 ], 317 ), 318 ), 319 ); 320 } 321}