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