// Author: fruit.com.cn import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../../core/data/models/comment.dart'; import '../../../core/data/services/auth_controller.dart'; import '../../../core/data/services/comment_controller.dart'; import '../../../core/platform/haptics.dart'; import '../../../core/platform/keyboard_inset.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/auth_gate.dart'; import '../../../l10n/app_localizations.dart'; const double _sheetRadius = 20; const double _inputRadius = 12; const double _postButtonHeight = 32; const int _maxCommentLength = 300; const Duration _keyboardSettleDelay = Duration(milliseconds: 80); const double _maxKeyboardInsetRatio = 0.6; /// 独立评论输入弹窗,复用原评论控制器的发布与列表更新能力。 class CommentComposerSheet extends StatefulWidget { const CommentComposerSheet({ super.key, required this.controller, required this.onDismiss, required this.onPosted, this.replyTo, }); final CommentController controller; final VoidCallback onDismiss; final VoidCallback onPosted; final DramaComment? replyTo; @override State createState() => _CommentComposerSheetState(); } class _CommentComposerSheetState extends State { final _input = TextEditingController(); final _inputFocus = FocusNode(); final _keyboardInset = KeyboardInsetNotifier.instance; Timer? _keyboardSettleTimer; double _pinnedKeyboardInset = 0; int _insetBaseline = 0; bool _pinnedInitialized = false; bool _insetReady = false; bool _useChannelInset = false; bool _keyboardVisible = false; @override void initState() { super.initState(); _input.addListener(_onInputChanged); _keyboardInset.addListener(_onKeyboardInsetChanged); widget.controller.addListener(_onControllerChanged); unawaited(_prepareInput()); } @override void didChangeDependencies() { super.didChangeDependencies(); if (!_pinnedInitialized) { _pinnedInitialized = true; _pinnedKeyboardInset = _normalizeKeyboardInset(MediaQuery.viewInsetsOf(context).bottom) ?? 0; } _trackKeyboardInset(_keyboardHeight(context)); } Future _prepareInput() async { await _keyboardInset.start(); if (!mounted) return; _insetBaseline = _keyboardInset.value; _insetReady = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _inputFocus.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || _keyboardHeight(context) > 0) return; unawaited( SystemChannels.textInput.invokeMethod('TextInput.show'), ); }); }); } @override void dispose() { _input.removeListener(_onInputChanged); _keyboardInset.removeListener(_onKeyboardInsetChanged); widget.controller.removeListener(_onControllerChanged); _keyboardSettleTimer?.cancel(); _inputFocus.unfocus(); _input.dispose(); _inputFocus.dispose(); super.dispose(); } void _onInputChanged() { if (mounted) setState(() {}); } void _onControllerChanged() { if (mounted) setState(() {}); } void _onKeyboardInsetChanged() { if (!mounted || !_insetReady) return; if (_keyboardInset.value != _insetBaseline) { _useChannelInset = true; } _trackKeyboardInset(_keyboardHeight(context)); } double _keyboardHeight(BuildContext context) { final viewInset = MediaQuery.viewInsetsOf(context).bottom; final channelInset = _useChannelInset ? _keyboardInset.value / MediaQuery.devicePixelRatioOf(context) : 0.0; return math.max(viewInset, channelInset); } double? _normalizeKeyboardInset(double inset) { final maxInset = MediaQuery.sizeOf(context).height * _maxKeyboardInsetRatio; if (!inset.isFinite || inset > maxInset) return null; return math.max(0, inset).roundToDouble(); } void _trackKeyboardInset(double inset) { final visible = inset > 0; if (visible == _keyboardVisible) return; _keyboardVisible = visible; _keyboardSettleTimer?.cancel(); _keyboardSettleTimer = null; if (!visible) return; _keyboardSettleTimer = Timer( _keyboardSettleDelay, _captureSettledKeyboardInset, ); } void _captureSettledKeyboardInset() { _keyboardSettleTimer = null; if (!mounted) return; final inset = _keyboardHeight(context); if (inset <= 0) { _keyboardVisible = false; return; } final next = _normalizeKeyboardInset(inset); if (next == null || next == _pinnedKeyboardInset) return; setState(() => _pinnedKeyboardInset = next); } Future _submit() async { if (widget.controller.submitting) return; final text = _input.text.trim(); if (text.isEmpty) return; unawaited(context.read().lightImpact()); final result = await widget.controller.publish( text, replyTo: widget.replyTo, ); if (!mounted) return; if (result.isSuccess) { _input.clear(); widget.onPosted(); return; } final l10n = AppLocalizations.of(context)!; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(result.errorMessage ?? l10n.commentPostFailed)), ); } Future _authenticate() async { await ensureSignedIn(context); if (!mounted || !context.read().isSignedIn) return; _inputFocus.requestFocus(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final signedIn = context.watch().isSignedIn; final hasContent = _input.text.trim().isNotEmpty; final canPost = signedIn && hasContent && !widget.controller.submitting; final multiline = _input.text.contains('\n'); return FocusScope( key: const Key('comment-composer-focus-scope'), autofocus: true, child: Stack( key: const Key('comment-composer-overlay'), fit: StackFit.expand, children: [ Positioned.fill( child: GestureDetector( key: const Key('comment-composer-dismiss-layer'), behavior: HitTestBehavior.opaque, onTap: widget.onDismiss, ), ), Positioned( left: 0, right: 0, bottom: 0, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () {}, child: Material( key: const Key('comment-composer-panel'), color: AppColors.card, clipBehavior: Clip.antiAlias, borderRadius: const BorderRadius.vertical( top: Radius.circular(_sheetRadius), ), child: SafeArea( top: false, bottom: false, child: Padding( padding: EdgeInsets.only(bottom: _pinnedKeyboardInset), child: Padding( key: const Key('comment-composer-sheet'), padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), child: TextFieldTapRegion( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (widget.replyTo != null) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( l10n.replyingTo( widget.replyTo!.nickname.isEmpty ? l10n.anonymousUser : widget.replyTo!.nickname, ), style: const TextStyle( color: AppColors.muted, fontSize: 12, ), ), ), Container( decoration: BoxDecoration( color: AppColors.commentInput, borderRadius: BorderRadius.circular( _inputRadius, ), ), child: TextField( key: const Key('comment-composer-input'), controller: _input, focusNode: _inputFocus, readOnly: !signedIn, canRequestFocus: signedIn, minLines: 1, maxLines: 4, maxLength: _maxCommentLength, textAlignVertical: multiline ? TextAlignVertical.top : TextAlignVertical.center, style: TextStyle( color: AppColors.cream, fontSize: 15, height: multiline ? 1.4 : null, ), cursorColor: AppColors.blue, decoration: InputDecoration( hintText: signedIn ? l10n.writeComment : l10n.commentLoginHint, hintStyle: const TextStyle( color: AppColors.muted, fontSize: 14, ), counterText: '', contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, ), onTap: signedIn ? null : _authenticate, ), ), const SizedBox(height: 12), Align( alignment: Alignment.centerRight, child: SizedBox( height: _postButtonHeight, child: TextButton( key: const Key('comment-composer-post'), onPressed: canPost ? _submit : null, style: TextButton.styleFrom( minimumSize: const Size( 0, _postButtonHeight, ), padding: const EdgeInsets.symmetric( horizontal: 16, ), tapTargetSize: MaterialTapTargetSize.shrinkWrap, backgroundColor: hasContent ? AppColors.blue.withValues(alpha: 0.12) : AppColors.raised, foregroundColor: AppColors.blue, disabledForegroundColor: AppColors.muted, disabledBackgroundColor: AppColors.raised, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), child: Text( l10n.postComment, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, ), ), ), ), ), ], ), ), ), ), ), ), ), ), ], ), ); } }