···
2
+
import 'dart:math' as math;
4
+
import 'package:flutter/material.dart';
5
+
import 'package:flutter/services.dart';
6
+
import 'package:provider/provider.dart';
8
+
import '../../constants/app_colors.dart';
9
+
import '../../models/comment.dart';
10
+
import '../../models/post.dart';
11
+
import '../../providers/comments_provider.dart';
12
+
import '../../widgets/comment_thread.dart';
13
+
import '../../widgets/post_card.dart';
17
+
/// Full-screen reply interface inspired by Thunder's natural scrolling
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
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
30
+
class ReplyScreen extends StatefulWidget {
34
+
required this.onSubmit,
37
+
(post != null) != (comment != null),
38
+
'Must provide exactly one: post or comment',
41
+
/// Post being replied to (mutually exclusive with comment)
42
+
final FeedViewPost? post;
44
+
/// Comment being replied to (mutually exclusive with post)
45
+
final ThreadViewComment? comment;
47
+
/// Callback when user submits reply
48
+
final Future<void> Function(String content) onSubmit;
51
+
State<ReplyScreen> createState() => _ReplyScreenState();
54
+
class _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;
67
+
WidgetsBinding.instance.addObserver(this);
68
+
_textController.addListener(_onTextChanged);
69
+
_focusNode.addListener(_onFocusChanged);
71
+
// Autofocus with delay (Thunder approach - let screen render first)
72
+
Future.delayed(const Duration(milliseconds: 300), () {
74
+
_isKeyboardOpening = true;
75
+
_focusNode.requestFocus();
80
+
void _onFocusChanged() {
81
+
// When text field gains focus, scroll to bottom as keyboard opens
82
+
if (_focusNode.hasFocus) {
83
+
_isKeyboardOpening = true;
88
+
void didChangeMetrics() {
89
+
super.didChangeMetrics();
90
+
final keyboardHeight = View.of(context).viewInsets.bottom;
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();
100
+
_lastKeyboardHeight = keyboardHeight;
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);
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;
122
+
_bannerDismissTimer?.cancel();
123
+
WidgetsBinding.instance.removeObserver(this);
124
+
_textController.dispose();
125
+
_focusNode.dispose();
126
+
_scrollController.dispose();
130
+
void _onTextChanged() {
131
+
final hasText = _textController.text.trim().isNotEmpty;
132
+
if (hasText != _hasText) {
134
+
_hasText = hasText;
139
+
Future<void> _handleSubmit() async {
140
+
final content = _textController.text.trim();
141
+
if (content.isEmpty) {
145
+
// Add haptic feedback before submission
146
+
await HapticFeedback.lightImpact();
148
+
// Set loading state
150
+
_isSubmitting = true;
154
+
await widget.onSubmit(content);
155
+
// Pop screen after successful submission
157
+
Navigator.of(context).pop();
159
+
} on Exception catch (e) {
160
+
// Show error if submission fails
162
+
ScaffoldMessenger.of(context).showSnackBar(
164
+
content: Text('Failed to submit: $e'),
165
+
backgroundColor: AppColors.primary,
166
+
behavior: SnackBarBehavior.floating,
169
+
// Reset loading state on error
171
+
_isSubmitting = false;
177
+
void _showComingSoonBanner(String feature) {
178
+
// Cancel any existing timer to prevent multiple banners
179
+
_bannerDismissTimer?.cancel();
181
+
final messenger = ScaffoldMessenger.of(context);
182
+
messenger.showMaterialBanner(
184
+
content: Text('$feature coming soon!'),
185
+
backgroundColor: AppColors.primary,
186
+
leading: const Icon(Icons.info_outline, color: AppColors.textPrimary),
189
+
onPressed: messenger.hideCurrentMaterialBanner,
192
+
style: TextStyle(color: AppColors.textPrimary),
199
+
// Auto-hide after 2 seconds with cancelable timer
200
+
_bannerDismissTimer = Timer(const Duration(seconds: 2), () {
202
+
messenger.hideCurrentMaterialBanner();
207
+
void _handleMentionTap() {
208
+
_showComingSoonBanner('Mention feature');
211
+
void _handleImageTap() {
212
+
_showComingSoonBanner('Image upload');
215
+
void _handleCancel() {
216
+
Navigator.of(context).pop();
220
+
Widget build(BuildContext context) {
221
+
return GestureDetector(
223
+
// Dismiss keyboard when tapping outside
224
+
FocusManager.instance.primaryFocus?.unfocus();
227
+
backgroundColor: AppColors.background,
228
+
resizeToAvoidBottomInset: false, // Thunder approach
230
+
backgroundColor: AppColors.background,
231
+
surfaceTintColor: Colors.transparent,
232
+
foregroundColor: AppColors.textPrimary,
234
+
automaticallyImplyLeading: false,
235
+
leading: TextButton(
236
+
onPressed: _handleCancel,
239
+
style: TextStyle(color: AppColors.textPrimary, fontSize: 16),
246
+
// Scrollable content area (Thunder style)
248
+
child: SingleChildScrollView(
249
+
controller: _scrollController,
250
+
padding: const EdgeInsets.only(bottom: 16),
253
+
// Post or comment preview
256
+
const SizedBox(height: 8),
258
+
// Divider between post and text input
259
+
Container(height: 1, color: AppColors.border),
261
+
// Text input - no background box, types directly into
264
+
padding: const EdgeInsets.all(16),
266
+
controller: _textController,
267
+
focusNode: _focusNode,
270
+
keyboardType: TextInputType.multiline,
271
+
textCapitalization: TextCapitalization.sentences,
272
+
textInputAction: TextInputAction.newline,
273
+
style: const TextStyle(
274
+
color: AppColors.textPrimary,
278
+
decoration: const InputDecoration(
279
+
hintText: 'Say something...',
280
+
hintStyle: TextStyle(
281
+
color: AppColors.textSecondary,
284
+
border: InputBorder.none,
285
+
contentPadding: EdgeInsets.zero,
294
+
// Divider - simple straight line like posts and comments
295
+
Container(height: 1, color: AppColors.border),
299
+
isSubmitting: _isSubmitting,
300
+
onImageTap: _handleImageTap,
301
+
onMentionTap: _handleMentionTap,
302
+
onSubmit: _handleSubmit,
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),
319
+
/// Isolated context preview that doesn't rebuild on keyboard changes
320
+
class _ContextPreview extends StatelessWidget {
321
+
const _ContextPreview({this.post, this.comment});
323
+
final FeedViewPost? post;
324
+
final ThreadViewComment? comment;
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) {
334
+
currentTime: commentsProvider.currentTimeNotifier.value,
335
+
showCommentButton: false,
336
+
disableNavigation: true,
337
+
showActions: false,
342
+
} else if (comment != null) {
343
+
// Show comment thread/chain
344
+
return Consumer<CommentsProvider>(
345
+
builder: (context, commentsProvider, child) {
346
+
return CommentThread(
348
+
currentTime: commentsProvider.currentTimeNotifier.value,
355
+
return const SizedBox.shrink();
359
+
class _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,
368
+
final bool hasText;
369
+
final bool isSubmitting;
370
+
final VoidCallback onMentionTap;
371
+
final VoidCallback onImageTap;
372
+
final VoidCallback onSubmit;
375
+
State<_ReplyToolbar> createState() => _ReplyToolbarState();
378
+
class _ReplyToolbarState extends State<_ReplyToolbar>
379
+
with WidgetsBindingObserver {
380
+
final ValueNotifier<double> _keyboardMarginNotifier = ValueNotifier(0);
381
+
final ValueNotifier<double> _safeAreaBottomNotifier = ValueNotifier(0);
386
+
WidgetsBinding.instance.addObserver(this);
390
+
void didChangeDependencies() {
391
+
super.didChangeDependencies();
397
+
_keyboardMarginNotifier.dispose();
398
+
_safeAreaBottomNotifier.dispose();
399
+
WidgetsBinding.instance.removeObserver(this);
404
+
void didChangeMetrics() {
408
+
void _updateMargins() {
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();
419
+
// Smooth tracking: Follow keyboard height in real-time (Bluesky/Thunder approach)
420
+
_keyboardMarginNotifier.value = keyboardInset;
421
+
_safeAreaBottomNotifier.value = safeAreaBottom;
425
+
Widget build(BuildContext context) {
427
+
mainAxisSize: MainAxisSize.min,
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(
450
+
label: 'Mention user',
451
+
child: GestureDetector(
452
+
onTap: widget.onMentionTap,
453
+
child: const Padding(
454
+
padding: EdgeInsets.all(8),
456
+
Icons.alternate_email_rounded,
458
+
color: AppColors.textSecondary,
463
+
const SizedBox(width: 4),
466
+
label: 'Add image',
467
+
child: GestureDetector(
468
+
onTap: widget.onImageTap,
469
+
child: const Padding(
470
+
padding: EdgeInsets.all(8),
472
+
Icons.image_outlined,
474
+
color: AppColors.textSecondary,
482
+
label: 'Send comment',
483
+
child: GestureDetector(
485
+
(widget.hasText && !widget.isSubmitting)
490
+
padding: const EdgeInsets.symmetric(horizontal: 14),
491
+
decoration: BoxDecoration(
493
+
(widget.hasText && !widget.isSubmitting)
494
+
? AppColors.primary
495
+
: AppColors.textSecondary.withValues(alpha: 0.3),
496
+
borderRadius: BorderRadius.circular(20),
499
+
mainAxisSize: MainAxisSize.min,
501
+
if (widget.isSubmitting)
505
+
child: CircularProgressIndicator(
507
+
valueColor: AlwaysStoppedAnimation<Color>(
508
+
AppColors.textPrimary,
516
+
color: AppColors.textPrimary,
518
+
fontWeight: FontWeight.normal,
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,