// Author: fruit.com.cn import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; 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/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; bool get _isAndroid => !kIsWeb && defaultTargetPlatform == TargetPlatform.android; @override void initState() { super.initState(); _input.addListener(_onInputChanged); _keyboardInset.addListener(_onKeyboardInsetChanged); widget.controller.addListener(_onControllerChanged); unawaited(_prepareInput()); } @override void didChangeDependencies() { super.didChangeDependencies(); if (!_isAndroid) return; if (!_pinnedInitialized) { _pinnedInitialized = true; _pinnedKeyboardInset = _normalizeKeyboardInset(MediaQuery.viewInsetsOf(context).bottom) ?? 0; } _trackKeyboardInset(_keyboardHeight(context)); } @override void didUpdateWidget(covariant CommentComposerSheet oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { oldWidget.controller.removeListener(_onControllerChanged); widget.controller.addListener(_onControllerChanged); } } Future _prepareInput() async { if (_isAndroid) { 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 (!_isAndroid || !mounted || !_insetReady) return; if (_keyboardInset.value != _insetBaseline) { _useChannelInset = true; } _trackKeyboardInset(_keyboardHeight(context)); } double _keyboardHeight(BuildContext context) { final viewInset = MediaQuery.viewInsetsOf(context).bottom; if (!_isAndroid) { return _normalizeKeyboardInset(viewInset) ?? 0; } 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) { if (!_isAndroid) return; final visible = inset > 0; if (visible == _keyboardVisible) return; _keyboardVisible = visible; _keyboardSettleTimer?.cancel(); _keyboardSettleTimer = null; if (!visible) return; _keyboardSettleTimer = Timer( _keyboardSettleDelay, _captureSettledKeyboardInset, ); } void _captureSettledKeyboardInset() { if (!_isAndroid) return; _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)!; if (result.errorCode == 200007) { await ensureSignedIn(context); return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(result.errorMessage ?? l10n.commentPostFailed)), ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final hasContent = _input.text.trim().isNotEmpty; final canPost = hasContent && !widget.controller.submitting; final multiline = _input.text.contains('\n'); final bottomInset = _isAndroid ? _pinnedKeyboardInset : _normalizeKeyboardInset(MediaQuery.viewInsetsOf(context).bottom) ?? 0; final content = 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: false, canRequestFocus: true, 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: l10n.writeComment, 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: null, ), ), 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, ), ), ), ), ), ], ), ), ); 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: AnimatedPadding( key: const Key('comment-composer-keyboard-padding'), duration: const Duration(milliseconds: 160), curve: Curves.easeOutCubic, padding: EdgeInsets.only(bottom: bottomInset), child: _isAndroid ? content : SafeArea(top: false, child: content), ), ), ), ), ], ), ); } }