Main coves client
1import 'dart:async';
2import 'dart:math' as math;
3
4import 'package:flutter/material.dart';
5import 'package:flutter/services.dart';
6import 'package:provider/provider.dart';
7
8import '../../constants/app_colors.dart';
9import '../../models/comment.dart';
10import '../../models/post.dart';
11import '../../providers/comments_provider.dart';
12import '../../widgets/comment_thread.dart';
13import '../../widgets/post_card.dart';
14
15/// Reply Screen
16///
17/// Full-screen reply interface inspired by Thunder's natural scrolling
18/// approach:
19/// - Scrollable content area (post/comment preview + text input)
20/// - Fixed bottom action bar with keyboard-aware margin
21/// - "Cancel" button in app bar (left)
22/// - "Reply" button in app bar (right, pill-shaped, enabled when text
23/// present)
24///
25/// Key Features:
26/// - Natural scrolling without fixed split ratios
27/// - Thunder-style keyboard handling with manual margin
28/// - Post/comment context visible while composing
29/// - Text selection and copy/paste enabled
30class ReplyScreen extends StatefulWidget {
31 const ReplyScreen({
32 this.post,
33 this.comment,
34 required this.onSubmit,
35 super.key,
36 }) : assert(
37 (post != null) != (comment != null),
38 'Must provide exactly one: post or comment',
39 );
40
41 /// Post being replied to (mutually exclusive with comment)
42 final FeedViewPost? post;
43
44 /// Comment being replied to (mutually exclusive with post)
45 final ThreadViewComment? comment;
46
47 /// Callback when user submits reply
48 final Future<void> Function(String content) onSubmit;
49
50 @override
51 State<ReplyScreen> createState() => _ReplyScreenState();
52}
53
54class _ReplyScreenState extends State<ReplyScreen> with WidgetsBindingObserver {
55 final TextEditingController _textController = TextEditingController();
56 final FocusNode _focusNode = FocusNode();
57 final ScrollController _scrollController = ScrollController();
58 bool _hasText = false;
59 bool _isKeyboardOpening = false;
60 bool _isSubmitting = false;
61 double _lastKeyboardHeight = 0;
62 Timer? _bannerDismissTimer;
63
64 @override
65 void initState() {
66 super.initState();
67 WidgetsBinding.instance.addObserver(this);
68 _textController.addListener(_onTextChanged);
69 _focusNode.addListener(_onFocusChanged);
70
71 // Autofocus with delay (Thunder approach - let screen render first)
72 Future.delayed(const Duration(milliseconds: 300), () {
73 if (mounted) {
74 _isKeyboardOpening = true;
75 _focusNode.requestFocus();
76 }
77 });
78 }
79
80 void _onFocusChanged() {
81 // When text field gains focus, scroll to bottom as keyboard opens
82 if (_focusNode.hasFocus) {
83 _isKeyboardOpening = true;
84 }
85 }
86
87 @override
88 void didChangeMetrics() {
89 super.didChangeMetrics();
90 final keyboardHeight = View.of(context).viewInsets.bottom;
91
92 // Detect keyboard closing and unfocus text field
93 if (_lastKeyboardHeight > 0 && keyboardHeight == 0) {
94 // Keyboard just closed - unfocus the text field
95 if (_focusNode.hasFocus) {
96 _focusNode.unfocus();
97 }
98 }
99
100 _lastKeyboardHeight = keyboardHeight;
101
102 // Scroll to bottom as keyboard opens
103 if (_isKeyboardOpening && _scrollController.hasClients) {
104 WidgetsBinding.instance.addPostFrameCallback((_) {
105 if (mounted && _scrollController.hasClients) {
106 _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
107
108 // Stop auto-scrolling after keyboard animation completes
109 if (keyboardHeight > 100) {
110 // Keyboard is substantially open, stop tracking after a delay
111 Future.delayed(const Duration(milliseconds: 500), () {
112 _isKeyboardOpening = false;
113 });
114 }
115 }
116 });
117 }
118 }
119
120 @override
121 void dispose() {
122 _bannerDismissTimer?.cancel();
123 WidgetsBinding.instance.removeObserver(this);
124 _textController.dispose();
125 _focusNode.dispose();
126 _scrollController.dispose();
127 super.dispose();
128 }
129
130 void _onTextChanged() {
131 final hasText = _textController.text.trim().isNotEmpty;
132 if (hasText != _hasText) {
133 setState(() {
134 _hasText = hasText;
135 });
136 }
137 }
138
139 Future<void> _handleSubmit() async {
140 final content = _textController.text.trim();
141 if (content.isEmpty) {
142 return;
143 }
144
145 // Add haptic feedback before submission
146 await HapticFeedback.lightImpact();
147
148 // Set loading state
149 setState(() {
150 _isSubmitting = true;
151 });
152
153 try {
154 await widget.onSubmit(content);
155 // Pop screen after successful submission
156 if (mounted) {
157 Navigator.of(context).pop();
158 }
159 } on Exception catch (e) {
160 // Show error if submission fails
161 if (mounted) {
162 ScaffoldMessenger.of(context).showSnackBar(
163 SnackBar(
164 content: Text('Failed to submit: $e'),
165 backgroundColor: AppColors.primary,
166 behavior: SnackBarBehavior.floating,
167 ),
168 );
169 // Reset loading state on error
170 setState(() {
171 _isSubmitting = false;
172 });
173 }
174 }
175 }
176
177 void _showComingSoonBanner(String feature) {
178 // Cancel any existing timer to prevent multiple banners
179 _bannerDismissTimer?.cancel();
180
181 final messenger = ScaffoldMessenger.of(context);
182 messenger.showMaterialBanner(
183 MaterialBanner(
184 content: Text('$feature coming soon!'),
185 backgroundColor: AppColors.primary,
186 leading: const Icon(Icons.info_outline, color: AppColors.textPrimary),
187 actions: [
188 TextButton(
189 onPressed: messenger.hideCurrentMaterialBanner,
190 child: const Text(
191 'Dismiss',
192 style: TextStyle(color: AppColors.textPrimary),
193 ),
194 ),
195 ],
196 ),
197 );
198
199 // Auto-hide after 2 seconds with cancelable timer
200 _bannerDismissTimer = Timer(const Duration(seconds: 2), () {
201 if (mounted) {
202 messenger.hideCurrentMaterialBanner();
203 }
204 });
205 }
206
207 void _handleMentionTap() {
208 _showComingSoonBanner('Mention feature');
209 }
210
211 void _handleImageTap() {
212 _showComingSoonBanner('Image upload');
213 }
214
215 void _handleCancel() {
216 Navigator.of(context).pop();
217 }
218
219 @override
220 Widget build(BuildContext context) {
221 return GestureDetector(
222 onTap: () {
223 // Dismiss keyboard when tapping outside
224 FocusManager.instance.primaryFocus?.unfocus();
225 },
226 child: Scaffold(
227 backgroundColor: AppColors.background,
228 resizeToAvoidBottomInset: false, // Thunder approach
229 appBar: AppBar(
230 backgroundColor: AppColors.background,
231 surfaceTintColor: Colors.transparent,
232 foregroundColor: AppColors.textPrimary,
233 elevation: 0,
234 automaticallyImplyLeading: false,
235 leading: TextButton(
236 onPressed: _handleCancel,
237 child: const Text(
238 'Cancel',
239 style: TextStyle(color: AppColors.textPrimary, fontSize: 16),
240 ),
241 ),
242 leadingWidth: 80,
243 ),
244 body: Column(
245 children: [
246 // Scrollable content area (Thunder style)
247 Expanded(
248 child: SingleChildScrollView(
249 controller: _scrollController,
250 padding: const EdgeInsets.only(bottom: 16),
251 child: Column(
252 children: [
253 // Post or comment preview
254 _buildContext(),
255
256 const SizedBox(height: 8),
257
258 // Divider between post and text input
259 Container(height: 1, color: AppColors.border),
260
261 // Text input - no background box, types directly into
262 // main area
263 Padding(
264 padding: const EdgeInsets.all(16),
265 child: TextField(
266 controller: _textController,
267 focusNode: _focusNode,
268 maxLines: null,
269 minLines: 8,
270 keyboardType: TextInputType.multiline,
271 textCapitalization: TextCapitalization.sentences,
272 textInputAction: TextInputAction.newline,
273 style: const TextStyle(
274 color: AppColors.textPrimary,
275 fontSize: 16,
276 height: 1.4,
277 ),
278 decoration: const InputDecoration(
279 hintText: 'Say something...',
280 hintStyle: TextStyle(
281 color: AppColors.textSecondary,
282 fontSize: 16,
283 ),
284 border: InputBorder.none,
285 contentPadding: EdgeInsets.zero,
286 ),
287 ),
288 ),
289 ],
290 ),
291 ),
292 ),
293
294 // Divider - simple straight line like posts and comments
295 Container(height: 1, color: AppColors.border),
296
297 _ReplyToolbar(
298 hasText: _hasText,
299 isSubmitting: _isSubmitting,
300 onImageTap: _handleImageTap,
301 onMentionTap: _handleMentionTap,
302 onSubmit: _handleSubmit,
303 ),
304 ],
305 ),
306 ),
307 );
308 }
309
310 /// Build context area (post or comment chain)
311 Widget _buildContext() {
312 // Wrap in RepaintBoundary to isolate from keyboard animation rebuilds
313 return RepaintBoundary(
314 child: _ContextPreview(post: widget.post, comment: widget.comment),
315 );
316 }
317}
318
319/// Isolated context preview that doesn't rebuild on keyboard changes
320class _ContextPreview extends StatelessWidget {
321 const _ContextPreview({this.post, this.comment});
322
323 final FeedViewPost? post;
324 final ThreadViewComment? comment;
325
326 @override
327 Widget build(BuildContext context) {
328 if (post != null) {
329 // Show full post card - Consumer only rebuilds THIS widget, not parents
330 return Consumer<CommentsProvider>(
331 builder: (context, commentsProvider, child) {
332 return PostCard(
333 post: post!,
334 currentTime: commentsProvider.currentTimeNotifier.value,
335 showCommentButton: false,
336 disableNavigation: true,
337 showActions: false,
338 showBorder: false,
339 );
340 },
341 );
342 } else if (comment != null) {
343 // Show comment thread/chain
344 return Consumer<CommentsProvider>(
345 builder: (context, commentsProvider, child) {
346 return CommentThread(
347 thread: comment!,
348 currentTime: commentsProvider.currentTimeNotifier.value,
349 maxDepth: 6,
350 );
351 },
352 );
353 }
354
355 return const SizedBox.shrink();
356 }
357}
358
359class _ReplyToolbar extends StatefulWidget {
360 const _ReplyToolbar({
361 required this.hasText,
362 required this.isSubmitting,
363 required this.onMentionTap,
364 required this.onImageTap,
365 required this.onSubmit,
366 });
367
368 final bool hasText;
369 final bool isSubmitting;
370 final VoidCallback onMentionTap;
371 final VoidCallback onImageTap;
372 final VoidCallback onSubmit;
373
374 @override
375 State<_ReplyToolbar> createState() => _ReplyToolbarState();
376}
377
378class _ReplyToolbarState extends State<_ReplyToolbar>
379 with WidgetsBindingObserver {
380 final ValueNotifier<double> _keyboardMarginNotifier = ValueNotifier(0);
381 final ValueNotifier<double> _safeAreaBottomNotifier = ValueNotifier(0);
382
383 @override
384 void initState() {
385 super.initState();
386 WidgetsBinding.instance.addObserver(this);
387 }
388
389 @override
390 void didChangeDependencies() {
391 super.didChangeDependencies();
392 _updateMargins();
393 }
394
395 @override
396 void dispose() {
397 _keyboardMarginNotifier.dispose();
398 _safeAreaBottomNotifier.dispose();
399 WidgetsBinding.instance.removeObserver(this);
400 super.dispose();
401 }
402
403 @override
404 void didChangeMetrics() {
405 _updateMargins();
406 }
407
408 void _updateMargins() {
409 if (!mounted) {
410 return;
411 }
412 final view = View.of(context);
413 final devicePixelRatio = view.devicePixelRatio;
414 final keyboardInset = view.viewInsets.bottom / devicePixelRatio;
415 final viewPaddingBottom = view.viewPadding.bottom / devicePixelRatio;
416 final safeAreaBottom =
417 math.max(0, viewPaddingBottom - keyboardInset).toDouble();
418
419 // Smooth tracking: Follow keyboard height in real-time (Bluesky/Thunder approach)
420 _keyboardMarginNotifier.value = keyboardInset;
421 _safeAreaBottomNotifier.value = safeAreaBottom;
422 }
423
424 @override
425 Widget build(BuildContext context) {
426 return Column(
427 mainAxisSize: MainAxisSize.min,
428 children: [
429 ValueListenableBuilder<double>(
430 valueListenable: _keyboardMarginNotifier,
431 builder: (context, margin, child) {
432 return AnimatedContainer(
433 duration: const Duration(milliseconds: 100),
434 curve: Curves.easeOut,
435 margin: EdgeInsets.only(bottom: margin),
436 color: AppColors.backgroundSecondary,
437 padding: const EdgeInsets.only(
438 left: 8,
439 right: 8,
440 top: 4,
441 bottom: 4,
442 ),
443 child: child,
444 );
445 },
446 child: Row(
447 children: [
448 Semantics(
449 button: true,
450 label: 'Mention user',
451 child: GestureDetector(
452 onTap: widget.onMentionTap,
453 child: const Padding(
454 padding: EdgeInsets.all(8),
455 child: Icon(
456 Icons.alternate_email_rounded,
457 size: 24,
458 color: AppColors.textSecondary,
459 ),
460 ),
461 ),
462 ),
463 const SizedBox(width: 4),
464 Semantics(
465 button: true,
466 label: 'Add image',
467 child: GestureDetector(
468 onTap: widget.onImageTap,
469 child: const Padding(
470 padding: EdgeInsets.all(8),
471 child: Icon(
472 Icons.image_outlined,
473 size: 24,
474 color: AppColors.textSecondary,
475 ),
476 ),
477 ),
478 ),
479 const Spacer(),
480 Semantics(
481 button: true,
482 label: 'Send comment',
483 child: GestureDetector(
484 onTap:
485 (widget.hasText && !widget.isSubmitting)
486 ? widget.onSubmit
487 : null,
488 child: Container(
489 height: 32,
490 padding: const EdgeInsets.symmetric(horizontal: 14),
491 decoration: BoxDecoration(
492 color:
493 (widget.hasText && !widget.isSubmitting)
494 ? AppColors.primary
495 : AppColors.textSecondary.withValues(alpha: 0.3),
496 borderRadius: BorderRadius.circular(20),
497 ),
498 child: Row(
499 mainAxisSize: MainAxisSize.min,
500 children: [
501 if (widget.isSubmitting)
502 const SizedBox(
503 width: 14,
504 height: 14,
505 child: CircularProgressIndicator(
506 strokeWidth: 2,
507 valueColor: AlwaysStoppedAnimation<Color>(
508 AppColors.textPrimary,
509 ),
510 ),
511 )
512 else
513 const Text(
514 'Send',
515 style: TextStyle(
516 color: AppColors.textPrimary,
517 fontSize: 13,
518 fontWeight: FontWeight.normal,
519 ),
520 ),
521 ],
522 ),
523 ),
524 ),
525 ),
526 ],
527 ),
528 ),
529 ValueListenableBuilder<double>(
530 valueListenable: _safeAreaBottomNotifier,
531 builder: (context, safeAreaBottom, child) {
532 return AnimatedContainer(
533 duration: const Duration(milliseconds: 100),
534 curve: Curves.easeOut,
535 height: safeAreaBottom,
536 color: AppColors.backgroundSecondary,
537 );
538 },
539 ),
540 ],
541 );
542 }
543}