Main coves client
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}