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}