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