···
+
import 'dart:math' as math;
+
import 'package:flutter/material.dart';
+
import 'package:flutter/services.dart';
+
import 'package:provider/provider.dart';
+
import '../../constants/app_colors.dart';
+
import '../../models/comment.dart';
+
import '../../models/post.dart';
+
import '../../providers/comments_provider.dart';
+
import '../../widgets/comment_thread.dart';
+
import '../../widgets/post_card.dart';
+
/// Full-screen reply interface inspired by Thunder's natural scrolling
+
/// - Scrollable content area (post/comment preview + text input)
+
/// - Fixed bottom action bar with keyboard-aware margin
+
/// - "Cancel" button in app bar (left)
+
/// - "Reply" button in app bar (right, pill-shaped, enabled when text
+
/// - Natural scrolling without fixed split ratios
+
/// - Thunder-style keyboard handling with manual margin
+
/// - Post/comment context visible while composing
+
/// - Text selection and copy/paste enabled
+
class ReplyScreen extends StatefulWidget {
+
required this.onSubmit,
+
(post != null) != (comment != null),
+
'Must provide exactly one: post or comment',
+
/// Post being replied to (mutually exclusive with comment)
+
final FeedViewPost? post;
+
/// Comment being replied to (mutually exclusive with post)
+
final ThreadViewComment? comment;
+
/// Callback when user submits reply
+
final Future<void> Function(String content) onSubmit;
+
State<ReplyScreen> createState() => _ReplyScreenState();
+
class _ReplyScreenState extends State<ReplyScreen> with WidgetsBindingObserver {
+
final TextEditingController _textController = TextEditingController();
+
final FocusNode _focusNode = FocusNode();
+
final ScrollController _scrollController = ScrollController();
+
bool _isKeyboardOpening = false;
+
bool _isSubmitting = false;
+
double _lastKeyboardHeight = 0;
+
Timer? _bannerDismissTimer;
+
WidgetsBinding.instance.addObserver(this);
+
_textController.addListener(_onTextChanged);
+
_focusNode.addListener(_onFocusChanged);
+
// Autofocus with delay (Thunder approach - let screen render first)
+
Future.delayed(const Duration(milliseconds: 300), () {
+
_isKeyboardOpening = true;
+
_focusNode.requestFocus();
+
void _onFocusChanged() {
+
// When text field gains focus, scroll to bottom as keyboard opens
+
if (_focusNode.hasFocus) {
+
_isKeyboardOpening = true;
+
void didChangeMetrics() {
+
super.didChangeMetrics();
+
final keyboardHeight = View.of(context).viewInsets.bottom;
+
// Detect keyboard closing and unfocus text field
+
if (_lastKeyboardHeight > 0 && keyboardHeight == 0) {
+
// Keyboard just closed - unfocus the text field
+
if (_focusNode.hasFocus) {
+
_lastKeyboardHeight = keyboardHeight;
+
// Scroll to bottom as keyboard opens
+
if (_isKeyboardOpening && _scrollController.hasClients) {
+
WidgetsBinding.instance.addPostFrameCallback((_) {
+
if (mounted && _scrollController.hasClients) {
+
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
+
// Stop auto-scrolling after keyboard animation completes
+
if (keyboardHeight > 100) {
+
// Keyboard is substantially open, stop tracking after a delay
+
Future.delayed(const Duration(milliseconds: 500), () {
+
_isKeyboardOpening = false;
+
_bannerDismissTimer?.cancel();
+
WidgetsBinding.instance.removeObserver(this);
+
_textController.dispose();
+
_scrollController.dispose();
+
void _onTextChanged() {
+
final hasText = _textController.text.trim().isNotEmpty;
+
if (hasText != _hasText) {
+
Future<void> _handleSubmit() async {
+
final content = _textController.text.trim();
+
// Add haptic feedback before submission
+
await HapticFeedback.lightImpact();
+
await widget.onSubmit(content);
+
// Pop screen after successful submission
+
Navigator.of(context).pop();
+
} on Exception catch (e) {
+
// Show error if submission fails
+
ScaffoldMessenger.of(context).showSnackBar(
+
content: Text('Failed to submit: $e'),
+
backgroundColor: AppColors.primary,
+
behavior: SnackBarBehavior.floating,
+
// Reset loading state on error
+
void _showComingSoonBanner(String feature) {
+
// Cancel any existing timer to prevent multiple banners
+
_bannerDismissTimer?.cancel();
+
final messenger = ScaffoldMessenger.of(context);
+
messenger.showMaterialBanner(
+
content: Text('$feature coming soon!'),
+
backgroundColor: AppColors.primary,
+
leading: const Icon(Icons.info_outline, color: AppColors.textPrimary),
+
onPressed: messenger.hideCurrentMaterialBanner,
+
style: TextStyle(color: AppColors.textPrimary),
+
// Auto-hide after 2 seconds with cancelable timer
+
_bannerDismissTimer = Timer(const Duration(seconds: 2), () {
+
messenger.hideCurrentMaterialBanner();
+
void _handleMentionTap() {
+
_showComingSoonBanner('Mention feature');
+
void _handleImageTap() {
+
_showComingSoonBanner('Image upload');
+
Navigator.of(context).pop();
+
Widget build(BuildContext context) {
+
return GestureDetector(
+
// Dismiss keyboard when tapping outside
+
FocusManager.instance.primaryFocus?.unfocus();
+
backgroundColor: AppColors.background,
+
resizeToAvoidBottomInset: false, // Thunder approach
+
backgroundColor: AppColors.background,
+
surfaceTintColor: Colors.transparent,
+
foregroundColor: AppColors.textPrimary,
+
automaticallyImplyLeading: false,
+
onPressed: _handleCancel,
+
style: TextStyle(color: AppColors.textPrimary, fontSize: 16),
+
// Scrollable content area (Thunder style)
+
child: SingleChildScrollView(
+
controller: _scrollController,
+
padding: const EdgeInsets.only(bottom: 16),
+
// Post or comment preview
+
const SizedBox(height: 8),
+
// Divider between post and text input
+
Container(height: 1, color: AppColors.border),
+
// Text input - no background box, types directly into
+
padding: const EdgeInsets.all(16),
+
controller: _textController,
+
keyboardType: TextInputType.multiline,
+
textCapitalization: TextCapitalization.sentences,
+
textInputAction: TextInputAction.newline,
+
style: const TextStyle(
+
color: AppColors.textPrimary,
+
decoration: const InputDecoration(
+
hintText: 'Say something...',
+
color: AppColors.textSecondary,
+
border: InputBorder.none,
+
contentPadding: EdgeInsets.zero,
+
// Divider - simple straight line like posts and comments
+
Container(height: 1, color: AppColors.border),
+
isSubmitting: _isSubmitting,
+
onImageTap: _handleImageTap,
+
onMentionTap: _handleMentionTap,
+
onSubmit: _handleSubmit,
+
/// Build context area (post or comment chain)
+
Widget _buildContext() {
+
// Wrap in RepaintBoundary to isolate from keyboard animation rebuilds
+
return RepaintBoundary(
+
child: _ContextPreview(post: widget.post, comment: widget.comment),
+
/// Isolated context preview that doesn't rebuild on keyboard changes
+
class _ContextPreview extends StatelessWidget {
+
const _ContextPreview({this.post, this.comment});
+
final FeedViewPost? post;
+
final ThreadViewComment? comment;
+
Widget build(BuildContext context) {
+
// Show full post card - Consumer only rebuilds THIS widget, not parents
+
return Consumer<CommentsProvider>(
+
builder: (context, commentsProvider, child) {
+
currentTime: commentsProvider.currentTimeNotifier.value,
+
showCommentButton: false,
+
disableNavigation: true,
+
} else if (comment != null) {
+
// Show comment thread/chain
+
return Consumer<CommentsProvider>(
+
builder: (context, commentsProvider, child) {
+
currentTime: commentsProvider.currentTimeNotifier.value,
+
return const SizedBox.shrink();
+
class _ReplyToolbar extends StatefulWidget {
+
required this.isSubmitting,
+
required this.onMentionTap,
+
required this.onImageTap,
+
required this.onSubmit,
+
final bool isSubmitting;
+
final VoidCallback onMentionTap;
+
final VoidCallback onImageTap;
+
final VoidCallback onSubmit;
+
State<_ReplyToolbar> createState() => _ReplyToolbarState();
+
class _ReplyToolbarState extends State<_ReplyToolbar>
+
with WidgetsBindingObserver {
+
final ValueNotifier<double> _keyboardMarginNotifier = ValueNotifier(0);
+
final ValueNotifier<double> _safeAreaBottomNotifier = ValueNotifier(0);
+
WidgetsBinding.instance.addObserver(this);
+
void didChangeDependencies() {
+
super.didChangeDependencies();
+
_keyboardMarginNotifier.dispose();
+
_safeAreaBottomNotifier.dispose();
+
WidgetsBinding.instance.removeObserver(this);
+
void didChangeMetrics() {
+
void _updateMargins() {
+
final view = View.of(context);
+
final devicePixelRatio = view.devicePixelRatio;
+
final keyboardInset = view.viewInsets.bottom / devicePixelRatio;
+
final viewPaddingBottom = view.viewPadding.bottom / devicePixelRatio;
+
math.max(0, viewPaddingBottom - keyboardInset).toDouble();
+
// Smooth tracking: Follow keyboard height in real-time (Bluesky/Thunder approach)
+
_keyboardMarginNotifier.value = keyboardInset;
+
_safeAreaBottomNotifier.value = safeAreaBottom;
+
Widget build(BuildContext context) {
+
mainAxisSize: MainAxisSize.min,
+
ValueListenableBuilder<double>(
+
valueListenable: _keyboardMarginNotifier,
+
builder: (context, margin, child) {
+
return AnimatedContainer(
+
duration: const Duration(milliseconds: 100),
+
margin: EdgeInsets.only(bottom: margin),
+
color: AppColors.backgroundSecondary,
+
padding: const EdgeInsets.only(
+
child: GestureDetector(
+
onTap: widget.onMentionTap,
+
padding: EdgeInsets.all(8),
+
Icons.alternate_email_rounded,
+
color: AppColors.textSecondary,
+
const SizedBox(width: 4),
+
child: GestureDetector(
+
onTap: widget.onImageTap,
+
padding: EdgeInsets.all(8),
+
color: AppColors.textSecondary,
+
child: GestureDetector(
+
(widget.hasText && !widget.isSubmitting)
+
padding: const EdgeInsets.symmetric(horizontal: 14),
+
decoration: BoxDecoration(
+
(widget.hasText && !widget.isSubmitting)
+
: AppColors.textSecondary.withValues(alpha: 0.3),
+
borderRadius: BorderRadius.circular(20),
+
mainAxisSize: MainAxisSize.min,
+
if (widget.isSubmitting)
+
child: CircularProgressIndicator(
+
valueColor: AlwaysStoppedAnimation<Color>(
+
color: AppColors.textPrimary,
+
fontWeight: FontWeight.normal,
+
ValueListenableBuilder<double>(
+
valueListenable: _safeAreaBottomNotifier,
+
builder: (context, safeAreaBottom, child) {
+
return AnimatedContainer(
+
duration: const Duration(milliseconds: 100),
+
height: safeAreaBottom,
+
color: AppColors.backgroundSecondary,