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