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