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 showFullText: true,
340 showAuthorFooter: true,
341 textFontSize: 16,
342 textLineHeight: 1.6,
343 embedHeight: 280,
344 titleFontSize: 20,
345 titleFontWeight: FontWeight.w600,
346 );
347 },
348 );
349 } else if (comment != null) {
350 // Show comment thread/chain
351 return Consumer<CommentsProvider>(
352 builder: (context, commentsProvider, child) {
353 return CommentThread(
354 thread: comment!,
355 currentTime: commentsProvider.currentTimeNotifier.value,
356 maxDepth: 6,
357 );
358 },
359 );
360 }
361
362 return const SizedBox.shrink();
363 }
364}
365
366class _ReplyToolbar extends StatefulWidget {
367 const _ReplyToolbar({
368 required this.hasText,
369 required this.isSubmitting,
370 required this.onMentionTap,
371 required this.onImageTap,
372 required this.onSubmit,
373 });
374
375 final bool hasText;
376 final bool isSubmitting;
377 final VoidCallback onMentionTap;
378 final VoidCallback onImageTap;
379 final VoidCallback onSubmit;
380
381 @override
382 State<_ReplyToolbar> createState() => _ReplyToolbarState();
383}
384
385class _ReplyToolbarState extends State<_ReplyToolbar>
386 with WidgetsBindingObserver {
387 final ValueNotifier<double> _keyboardMarginNotifier = ValueNotifier(0);
388 final ValueNotifier<double> _safeAreaBottomNotifier = ValueNotifier(0);
389
390 @override
391 void initState() {
392 super.initState();
393 WidgetsBinding.instance.addObserver(this);
394 }
395
396 @override
397 void didChangeDependencies() {
398 super.didChangeDependencies();
399 _updateMargins();
400 }
401
402 @override
403 void dispose() {
404 _keyboardMarginNotifier.dispose();
405 _safeAreaBottomNotifier.dispose();
406 WidgetsBinding.instance.removeObserver(this);
407 super.dispose();
408 }
409
410 @override
411 void didChangeMetrics() {
412 _updateMargins();
413 }
414
415 void _updateMargins() {
416 if (!mounted) {
417 return;
418 }
419 final view = View.of(context);
420 final devicePixelRatio = view.devicePixelRatio;
421 final keyboardInset = view.viewInsets.bottom / devicePixelRatio;
422 final viewPaddingBottom = view.viewPadding.bottom / devicePixelRatio;
423 final safeAreaBottom =
424 math.max(0, viewPaddingBottom - keyboardInset).toDouble();
425
426 // Smooth tracking: Follow keyboard height in real-time (Bluesky/Thunder approach)
427 _keyboardMarginNotifier.value = keyboardInset;
428 _safeAreaBottomNotifier.value = safeAreaBottom;
429 }
430
431 @override
432 Widget build(BuildContext context) {
433 return Column(
434 mainAxisSize: MainAxisSize.min,
435 children: [
436 ValueListenableBuilder<double>(
437 valueListenable: _keyboardMarginNotifier,
438 builder: (context, margin, child) {
439 return AnimatedContainer(
440 duration: const Duration(milliseconds: 100),
441 curve: Curves.easeOut,
442 margin: EdgeInsets.only(bottom: margin),
443 color: AppColors.backgroundSecondary,
444 padding: const EdgeInsets.only(
445 left: 8,
446 right: 8,
447 top: 4,
448 bottom: 4,
449 ),
450 child: child,
451 );
452 },
453 child: Row(
454 children: [
455 Semantics(
456 button: true,
457 label: 'Mention user',
458 child: GestureDetector(
459 onTap: widget.onMentionTap,
460 child: const Padding(
461 padding: EdgeInsets.all(8),
462 child: Icon(
463 Icons.alternate_email_rounded,
464 size: 24,
465 color: AppColors.textSecondary,
466 ),
467 ),
468 ),
469 ),
470 const SizedBox(width: 4),
471 Semantics(
472 button: true,
473 label: 'Add image',
474 child: GestureDetector(
475 onTap: widget.onImageTap,
476 child: const Padding(
477 padding: EdgeInsets.all(8),
478 child: Icon(
479 Icons.image_outlined,
480 size: 24,
481 color: AppColors.textSecondary,
482 ),
483 ),
484 ),
485 ),
486 const Spacer(),
487 Semantics(
488 button: true,
489 label: 'Send comment',
490 child: GestureDetector(
491 onTap:
492 (widget.hasText && !widget.isSubmitting)
493 ? widget.onSubmit
494 : null,
495 child: Container(
496 height: 32,
497 padding: const EdgeInsets.symmetric(horizontal: 14),
498 decoration: BoxDecoration(
499 color:
500 (widget.hasText && !widget.isSubmitting)
501 ? AppColors.primary
502 : AppColors.textSecondary.withValues(alpha: 0.3),
503 borderRadius: BorderRadius.circular(20),
504 ),
505 child: Row(
506 mainAxisSize: MainAxisSize.min,
507 children: [
508 if (widget.isSubmitting)
509 const SizedBox(
510 width: 14,
511 height: 14,
512 child: CircularProgressIndicator(
513 strokeWidth: 2,
514 valueColor: AlwaysStoppedAnimation<Color>(
515 AppColors.textPrimary,
516 ),
517 ),
518 )
519 else
520 const Text(
521 'Send',
522 style: TextStyle(
523 color: AppColors.textPrimary,
524 fontSize: 13,
525 fontWeight: FontWeight.normal,
526 ),
527 ),
528 ],
529 ),
530 ),
531 ),
532 ),
533 ],
534 ),
535 ),
536 ValueListenableBuilder<double>(
537 valueListenable: _safeAreaBottomNotifier,
538 builder: (context, safeAreaBottom, child) {
539 return AnimatedContainer(
540 duration: const Duration(milliseconds: 100),
541 curve: Curves.easeOut,
542 height: safeAreaBottom,
543 color: AppColors.backgroundSecondary,
544 );
545 },
546 ),
547 ],
548 );
549 }
550}