// Author: fruit.com.cn import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.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; /// 独立评论输入弹窗,复用原评论控制器的发布与列表更新能力。 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; double _pinnedKeyboardInset = 0; @override void initState() { super.initState(); _input.addListener(_onInputChanged); _keyboardInset.addListener(_onKeyboardInsetChanged); widget.controller.addListener(_onControllerChanged); unawaited(_keyboardInset.start()); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _inputFocus.requestFocus(); }); } @override void dispose() { _input.removeListener(_onInputChanged); _keyboardInset.removeListener(_onKeyboardInsetChanged); widget.controller.removeListener(_onControllerChanged); _inputFocus.unfocus(); _input.dispose(); _inputFocus.dispose(); super.dispose(); } void _onInputChanged() { if (mounted) setState(() {}); } void _onControllerChanged() { if (mounted) setState(() {}); } void _onKeyboardInsetChanged() { if (!mounted) return; setState(() {}); } double _keyboardHeight(BuildContext context) => math.max( MediaQuery.viewInsetsOf(context).bottom, _keyboardInset.value / MediaQuery.devicePixelRatioOf(context), ); 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 currentKeyboardInset = _keyboardHeight(context); _pinnedKeyboardInset = math.max(_pinnedKeyboardInset, currentKeyboardInset); 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 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, 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, ), ), ), ), ), ], ), ), ), ), ), ), ), ), ], ); } }