1import 'dart:async'; 2import 'dart:math' as math; 3 4import 'package:flutter/foundation.dart'; 5import 'package:flutter/material.dart'; 6import 'package:flutter/services.dart'; 7import 'package:provider/provider.dart'; 8 9import '../../constants/app_colors.dart'; 10import '../../models/comment.dart'; 11import '../../models/post.dart'; 12import '../../providers/auth_provider.dart'; 13import '../../providers/comments_provider.dart'; 14import '../../widgets/comment_thread.dart'; 15import '../../widgets/post_card.dart'; 16 17/// Reply Screen 18/// 19/// Full-screen reply interface inspired by Thunder's natural scrolling 20/// approach: 21/// - Scrollable content area (post/comment preview + text input) 22/// - Fixed bottom action bar with keyboard-aware margin 23/// - "Cancel" button in app bar (left) 24/// - "Reply" button in app bar (right, pill-shaped, enabled when text 25/// present) 26/// 27/// Key Features: 28/// - Natural scrolling without fixed split ratios 29/// - Thunder-style keyboard handling with manual margin 30/// - Post/comment context visible while composing 31/// - Text selection and copy/paste enabled 32class ReplyScreen extends StatefulWidget { 33 const ReplyScreen({ 34 this.post, 35 this.comment, 36 required this.onSubmit, 37 required this.commentsProvider, 38 super.key, 39 }) : assert( 40 (post != null) != (comment != null), 41 'Must provide exactly one: post or comment', 42 ); 43 44 /// Post being replied to (mutually exclusive with comment) 45 final FeedViewPost? post; 46 47 /// Comment being replied to (mutually exclusive with post) 48 final ThreadViewComment? comment; 49 50 /// Callback when user submits reply 51 final Future<void> Function(String content) onSubmit; 52 53 /// CommentsProvider for draft save/restore and time updates 54 final CommentsProvider commentsProvider; 55 56 @override 57 State<ReplyScreen> createState() => _ReplyScreenState(); 58} 59 60class _ReplyScreenState extends State<ReplyScreen> with WidgetsBindingObserver { 61 final TextEditingController _textController = TextEditingController(); 62 final FocusNode _focusNode = FocusNode(); 63 final ScrollController _scrollController = ScrollController(); 64 bool _hasText = false; 65 bool _isKeyboardOpening = false; 66 bool _isSubmitting = false; 67 bool _authInvalidated = false; 68 double _lastKeyboardHeight = 0; 69 Timer? _bannerDismissTimer; 70 71 @override 72 void initState() { 73 super.initState(); 74 WidgetsBinding.instance.addObserver(this); 75 _textController.addListener(_onTextChanged); 76 _focusNode.addListener(_onFocusChanged); 77 78 // Restore draft and autofocus after frame is built 79 WidgetsBinding.instance.addPostFrameCallback((_) { 80 if (mounted) { 81 _setupAuthListener(); 82 _restoreDraft(); 83 84 // Autofocus with delay (Thunder approach - let screen render first) 85 Future.delayed(const Duration(milliseconds: 300), () { 86 if (mounted) { 87 _isKeyboardOpening = true; 88 _focusNode.requestFocus(); 89 } 90 }); 91 } 92 }); 93 } 94 95 void _setupAuthListener() { 96 try { 97 context.read<AuthProvider>().addListener(_onAuthChanged); 98 } on Exception { 99 // AuthProvider may not be available (e.g., tests) 100 } 101 } 102 103 void _onAuthChanged() { 104 if (!mounted || _authInvalidated) return; 105 106 try { 107 final authProvider = context.read<AuthProvider>(); 108 if (!authProvider.isAuthenticated) { 109 _authInvalidated = true; 110 if (mounted) { 111 Navigator.of(context).pop(); 112 } 113 } 114 } on Exception { 115 // AuthProvider may not be available 116 } 117 } 118 119 /// Restore draft text if available for this reply context 120 void _restoreDraft() { 121 try { 122 final commentsProvider = context.read<CommentsProvider>(); 123 final ourParentUri = widget.comment?.comment.uri; 124 125 // Get draft for this specific parent URI 126 final draft = commentsProvider.getDraft(parentUri: ourParentUri); 127 128 if (draft.isNotEmpty) { 129 _textController.text = draft; 130 setState(() { 131 _hasText = true; 132 }); 133 } 134 } on Exception catch (e) { 135 // CommentsProvider might not be available (e.g., during testing) 136 if (kDebugMode) { 137 debugPrint('📝 Draft not restored: $e'); 138 } 139 } 140 } 141 142 void _onFocusChanged() { 143 // When text field gains focus, scroll to bottom as keyboard opens 144 if (_focusNode.hasFocus) { 145 _isKeyboardOpening = true; 146 } 147 } 148 149 @override 150 void didChangeMetrics() { 151 super.didChangeMetrics(); 152 // Guard against being called after widget is deactivated 153 // (can happen during keyboard animation while navigating away) 154 if (!mounted) return; 155 156 final keyboardHeight = View.of(context).viewInsets.bottom; 157 158 // Detect keyboard closing and unfocus text field 159 if (_lastKeyboardHeight > 0 && keyboardHeight == 0) { 160 // Keyboard just closed - unfocus the text field 161 if (_focusNode.hasFocus) { 162 _focusNode.unfocus(); 163 } 164 } 165 166 _lastKeyboardHeight = keyboardHeight; 167 168 // Scroll to bottom as keyboard opens 169 if (_isKeyboardOpening && _scrollController.hasClients) { 170 WidgetsBinding.instance.addPostFrameCallback((_) { 171 if (mounted && _scrollController.hasClients) { 172 _scrollController.jumpTo(_scrollController.position.maxScrollExtent); 173 174 // Stop auto-scrolling after keyboard animation completes 175 if (keyboardHeight > 100) { 176 // Keyboard is substantially open, stop tracking after a delay 177 Future.delayed(const Duration(milliseconds: 500), () { 178 _isKeyboardOpening = false; 179 }); 180 } 181 } 182 }); 183 } 184 } 185 186 @override 187 void dispose() { 188 _bannerDismissTimer?.cancel(); 189 try { 190 context.read<AuthProvider>().removeListener(_onAuthChanged); 191 } on Exception { 192 // AuthProvider may not be available 193 } 194 WidgetsBinding.instance.removeObserver(this); 195 _textController.dispose(); 196 _focusNode.dispose(); 197 _scrollController.dispose(); 198 super.dispose(); 199 } 200 201 void _onTextChanged() { 202 final hasText = _textController.text.trim().isNotEmpty; 203 if (hasText != _hasText) { 204 setState(() { 205 _hasText = hasText; 206 }); 207 } 208 } 209 210 Future<void> _handleSubmit() async { 211 if (_authInvalidated) { 212 return; 213 } 214 215 final content = _textController.text.trim(); 216 if (content.isEmpty) { 217 return; 218 } 219 220 // Add haptic feedback before submission 221 await HapticFeedback.lightImpact(); 222 223 // Set loading state 224 setState(() { 225 _isSubmitting = true; 226 }); 227 228 try { 229 await widget.onSubmit(content); 230 // Clear draft on success 231 try { 232 if (mounted) { 233 final parentUri = widget.comment?.comment.uri; 234 context.read<CommentsProvider>().clearDraft(parentUri: parentUri); 235 } 236 } on Exception catch (e) { 237 // CommentsProvider might not be available 238 if (kDebugMode) { 239 debugPrint('📝 Draft not cleared: $e'); 240 } 241 } 242 // Pop screen after successful submission 243 if (mounted) { 244 Navigator.of(context).pop(); 245 } 246 } on Exception catch (e) { 247 // Show error if submission fails 248 if (mounted) { 249 ScaffoldMessenger.of(context).showSnackBar( 250 SnackBar( 251 content: Text('Failed to submit: $e'), 252 backgroundColor: AppColors.primary, 253 behavior: SnackBarBehavior.floating, 254 ), 255 ); 256 // Reset loading state on error 257 setState(() { 258 _isSubmitting = false; 259 }); 260 } 261 } 262 } 263 264 void _showComingSoonBanner(String feature) { 265 // Cancel any existing timer to prevent multiple banners 266 _bannerDismissTimer?.cancel(); 267 268 final messenger = ScaffoldMessenger.of(context); 269 messenger.showMaterialBanner( 270 MaterialBanner( 271 content: Text('$feature coming soon!'), 272 backgroundColor: AppColors.primary, 273 leading: const Icon(Icons.info_outline, color: AppColors.textPrimary), 274 actions: [ 275 TextButton( 276 onPressed: messenger.hideCurrentMaterialBanner, 277 child: const Text( 278 'Dismiss', 279 style: TextStyle(color: AppColors.textPrimary), 280 ), 281 ), 282 ], 283 ), 284 ); 285 286 // Auto-hide after 2 seconds with cancelable timer 287 _bannerDismissTimer = Timer(const Duration(seconds: 2), () { 288 if (mounted) { 289 messenger.hideCurrentMaterialBanner(); 290 } 291 }); 292 } 293 294 void _handleMentionTap() { 295 _showComingSoonBanner('Mention feature'); 296 } 297 298 void _handleImageTap() { 299 _showComingSoonBanner('Image upload'); 300 } 301 302 void _handleCancel() { 303 // Save draft before closing (if text is not empty) 304 _saveDraft(); 305 Navigator.of(context).pop(); 306 } 307 308 /// Save current text as draft 309 void _saveDraft() { 310 try { 311 final commentsProvider = context.read<CommentsProvider>(); 312 commentsProvider.saveDraft( 313 _textController.text, 314 parentUri: widget.comment?.comment.uri, 315 ); 316 } on Exception catch (e) { 317 // CommentsProvider might not be available 318 if (kDebugMode) { 319 debugPrint('📝 Draft not saved: $e'); 320 } 321 } 322 } 323 324 @override 325 Widget build(BuildContext context) { 326 // Provide CommentsProvider to descendant widgets (Consumer in _ContextPreview) 327 return ChangeNotifierProvider.value( 328 value: widget.commentsProvider, 329 child: GestureDetector( 330 onTap: () { 331 // Dismiss keyboard when tapping outside 332 FocusManager.instance.primaryFocus?.unfocus(); 333 }, 334 child: Scaffold( 335 backgroundColor: AppColors.background, 336 resizeToAvoidBottomInset: false, // Thunder approach 337 appBar: AppBar( 338 backgroundColor: AppColors.background, 339 surfaceTintColor: Colors.transparent, 340 foregroundColor: AppColors.textPrimary, 341 elevation: 0, 342 automaticallyImplyLeading: false, 343 leading: TextButton( 344 onPressed: _handleCancel, 345 child: const Text( 346 'Cancel', 347 style: TextStyle(color: AppColors.textPrimary, fontSize: 16), 348 ), 349 ), 350 leadingWidth: 80, 351 ), 352 body: Column( 353 children: [ 354 // Scrollable content area (Thunder style) 355 Expanded( 356 child: SingleChildScrollView( 357 controller: _scrollController, 358 padding: const EdgeInsets.only(bottom: 16), 359 child: Column( 360 children: [ 361 // Post or comment preview 362 _buildContext(), 363 364 const SizedBox(height: 8), 365 366 // Divider between post and text input 367 Container(height: 1, color: AppColors.border), 368 369 // Text input - no background box, types directly into 370 // main area 371 Padding( 372 padding: const EdgeInsets.all(16), 373 child: TextField( 374 controller: _textController, 375 focusNode: _focusNode, 376 maxLines: null, 377 minLines: 8, 378 keyboardType: TextInputType.multiline, 379 textCapitalization: TextCapitalization.sentences, 380 textInputAction: TextInputAction.newline, 381 style: const TextStyle( 382 color: AppColors.textPrimary, 383 fontSize: 16, 384 height: 1.4, 385 ), 386 decoration: const InputDecoration( 387 hintText: 'Say something...', 388 hintStyle: TextStyle( 389 color: AppColors.textSecondary, 390 fontSize: 16, 391 ), 392 border: InputBorder.none, 393 contentPadding: EdgeInsets.zero, 394 ), 395 ), 396 ), 397 ], 398 ), 399 ), 400 ), 401 402 // Divider - simple straight line like posts and comments 403 Container(height: 1, color: AppColors.border), 404 405 _ReplyToolbar( 406 hasText: _hasText, 407 isSubmitting: _isSubmitting, 408 onImageTap: _handleImageTap, 409 onMentionTap: _handleMentionTap, 410 onSubmit: _handleSubmit, 411 ), 412 ], 413 ), 414 ), 415 ), 416 ); 417 } 418 419 /// Build context area (post or comment chain) 420 Widget _buildContext() { 421 // Wrap in RepaintBoundary to isolate from keyboard animation rebuilds 422 return RepaintBoundary( 423 child: _ContextPreview(post: widget.post, comment: widget.comment), 424 ); 425 } 426} 427 428/// Isolated context preview that doesn't rebuild on keyboard changes 429class _ContextPreview extends StatelessWidget { 430 const _ContextPreview({this.post, this.comment}); 431 432 final FeedViewPost? post; 433 final ThreadViewComment? comment; 434 435 @override 436 Widget build(BuildContext context) { 437 if (post != null) { 438 // Show full post card - Consumer only rebuilds THIS widget, not parents 439 return Consumer<CommentsProvider>( 440 builder: (context, commentsProvider, child) { 441 return PostCard( 442 post: post!, 443 currentTime: commentsProvider.currentTimeNotifier.value, 444 showCommentButton: false, 445 disableNavigation: true, 446 showActions: false, 447 showBorder: false, 448 showFullText: true, 449 showAuthorFooter: true, 450 textFontSize: 16, 451 textLineHeight: 1.6, 452 embedHeight: 280, 453 titleFontSize: 20, 454 titleFontWeight: FontWeight.w600, 455 ); 456 }, 457 ); 458 } else if (comment != null) { 459 // Show comment thread/chain 460 return Consumer<CommentsProvider>( 461 builder: (context, commentsProvider, child) { 462 return CommentThread( 463 thread: comment!, 464 currentTime: commentsProvider.currentTimeNotifier.value, 465 maxDepth: 6, 466 ); 467 }, 468 ); 469 } 470 471 return const SizedBox.shrink(); 472 } 473} 474 475class _ReplyToolbar extends StatefulWidget { 476 const _ReplyToolbar({ 477 required this.hasText, 478 required this.isSubmitting, 479 required this.onMentionTap, 480 required this.onImageTap, 481 required this.onSubmit, 482 }); 483 484 final bool hasText; 485 final bool isSubmitting; 486 final VoidCallback onMentionTap; 487 final VoidCallback onImageTap; 488 final VoidCallback onSubmit; 489 490 @override 491 State<_ReplyToolbar> createState() => _ReplyToolbarState(); 492} 493 494class _ReplyToolbarState extends State<_ReplyToolbar> 495 with WidgetsBindingObserver { 496 final ValueNotifier<double> _keyboardMarginNotifier = ValueNotifier(0); 497 final ValueNotifier<double> _safeAreaBottomNotifier = ValueNotifier(0); 498 499 @override 500 void initState() { 501 super.initState(); 502 WidgetsBinding.instance.addObserver(this); 503 } 504 505 @override 506 void didChangeDependencies() { 507 super.didChangeDependencies(); 508 _updateMargins(); 509 } 510 511 @override 512 void dispose() { 513 _keyboardMarginNotifier.dispose(); 514 _safeAreaBottomNotifier.dispose(); 515 WidgetsBinding.instance.removeObserver(this); 516 super.dispose(); 517 } 518 519 @override 520 void didChangeMetrics() { 521 _updateMargins(); 522 } 523 524 void _updateMargins() { 525 if (!mounted) { 526 return; 527 } 528 final view = View.of(context); 529 final devicePixelRatio = view.devicePixelRatio; 530 final keyboardInset = view.viewInsets.bottom / devicePixelRatio; 531 final viewPaddingBottom = view.viewPadding.bottom / devicePixelRatio; 532 final safeAreaBottom = 533 math.max(0, viewPaddingBottom - keyboardInset).toDouble(); 534 535 // Smooth tracking: Follow keyboard height in real-time (Bluesky/Thunder approach) 536 _keyboardMarginNotifier.value = keyboardInset; 537 _safeAreaBottomNotifier.value = safeAreaBottom; 538 } 539 540 @override 541 Widget build(BuildContext context) { 542 return Column( 543 mainAxisSize: MainAxisSize.min, 544 children: [ 545 ValueListenableBuilder<double>( 546 valueListenable: _keyboardMarginNotifier, 547 builder: (context, margin, child) { 548 return AnimatedContainer( 549 duration: const Duration(milliseconds: 100), 550 curve: Curves.easeOut, 551 margin: EdgeInsets.only(bottom: margin), 552 color: AppColors.backgroundSecondary, 553 padding: const EdgeInsets.only( 554 left: 8, 555 right: 8, 556 top: 4, 557 bottom: 4, 558 ), 559 child: child, 560 ); 561 }, 562 child: Row( 563 children: [ 564 Semantics( 565 button: true, 566 label: 'Mention user', 567 child: GestureDetector( 568 onTap: widget.onMentionTap, 569 child: const Padding( 570 padding: EdgeInsets.all(8), 571 child: Icon( 572 Icons.alternate_email_rounded, 573 size: 24, 574 color: AppColors.textSecondary, 575 ), 576 ), 577 ), 578 ), 579 const SizedBox(width: 4), 580 Semantics( 581 button: true, 582 label: 'Add image', 583 child: GestureDetector( 584 onTap: widget.onImageTap, 585 child: const Padding( 586 padding: EdgeInsets.all(8), 587 child: Icon( 588 Icons.image_outlined, 589 size: 24, 590 color: AppColors.textSecondary, 591 ), 592 ), 593 ), 594 ), 595 const Spacer(), 596 Semantics( 597 button: true, 598 label: 'Send comment', 599 child: GestureDetector( 600 onTap: 601 (widget.hasText && !widget.isSubmitting) 602 ? widget.onSubmit 603 : null, 604 child: Container( 605 height: 32, 606 padding: const EdgeInsets.symmetric(horizontal: 14), 607 decoration: BoxDecoration( 608 color: 609 (widget.hasText && !widget.isSubmitting) 610 ? AppColors.primary 611 : AppColors.textSecondary.withValues(alpha: 0.3), 612 borderRadius: BorderRadius.circular(20), 613 ), 614 child: Row( 615 mainAxisSize: MainAxisSize.min, 616 children: [ 617 if (widget.isSubmitting) 618 const SizedBox( 619 width: 14, 620 height: 14, 621 child: CircularProgressIndicator( 622 strokeWidth: 2, 623 valueColor: AlwaysStoppedAnimation<Color>( 624 AppColors.textPrimary, 625 ), 626 ), 627 ) 628 else 629 const Text( 630 'Send', 631 style: TextStyle( 632 color: AppColors.textPrimary, 633 fontSize: 13, 634 fontWeight: FontWeight.normal, 635 ), 636 ), 637 ], 638 ), 639 ), 640 ), 641 ), 642 ], 643 ), 644 ), 645 ValueListenableBuilder<double>( 646 valueListenable: _safeAreaBottomNotifier, 647 builder: (context, safeAreaBottom, child) { 648 return AnimatedContainer( 649 duration: const Duration(milliseconds: 100), 650 curve: Curves.easeOut, 651 height: safeAreaBottom, 652 color: AppColors.backgroundSecondary, 653 ); 654 }, 655 ), 656 ], 657 ); 658 } 659}