diff --git a/lib/pages/player/widgets/comment_composer_sheet.dart b/lib/pages/player/widgets/comment_composer_sheet.dart new file mode 100644 index 0000000..7ef0c89 --- /dev/null +++ b/lib/pages/player/widgets/comment_composer_sheet.dart @@ -0,0 +1,294 @@ +// 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, + this.replyTo, + }); + + final CommentController controller; + final DramaComment? replyTo; + + @override + State createState() => _CommentComposerSheetState(); +} + +class _CommentComposerSheetState extends State + with WidgetsBindingObserver { + final _input = TextEditingController(); + final _inputFocus = FocusNode(); + final _keyboardInset = KeyboardInsetNotifier.instance; + bool _keyboardWasVisible = false; + bool _hadFocus = false; + bool _closing = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _input.addListener(_onInputChanged); + _inputFocus.addListener(_onFocusChanged); + _keyboardInset.addListener(_onKeyboardInsetChanged); + widget.controller.addListener(_onControllerChanged); + unawaited(_keyboardInset.start()); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _input.removeListener(_onInputChanged); + _inputFocus.removeListener(_onFocusChanged); + _keyboardInset.removeListener(_onKeyboardInsetChanged); + widget.controller.removeListener(_onControllerChanged); + _input.dispose(); + _inputFocus.dispose(); + super.dispose(); + } + + void _onInputChanged() { + if (mounted) setState(() {}); + } + + void _onControllerChanged() { + if (mounted) setState(() {}); + } + + void _onFocusChanged() { + if (!mounted) return; + final hasFocus = _inputFocus.hasFocus; + if (_hadFocus && !hasFocus) { + _closeSheet(); + } + _hadFocus = hasFocus; + setState(() {}); + } + + void _onKeyboardInsetChanged() { + if (!mounted) return; + setState(() {}); + _syncKeyboardVisibility(_keyboardHeight(context)); + } + + @override + void didChangeMetrics() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _syncKeyboardVisibility(_keyboardHeight(context)); + }); + } + + void _syncKeyboardVisibility(double keyboardHeight) { + if (keyboardHeight > 0) { + _keyboardWasVisible = true; + return; + } + if (_keyboardWasVisible) _closeSheet(); + } + + double _keyboardHeight(BuildContext context) => math.max( + MediaQuery.viewInsetsOf(context).bottom, + _keyboardInset.value / MediaQuery.devicePixelRatioOf(context), + ); + + void _closeSheet() { + if (_closing || !mounted) return; + _closing = true; + Navigator.pop(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) { + _closing = true; + Navigator.pop(context, true); + 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 screenHeight = MediaQuery.sizeOf(context).height; + final halfHeight = (screenHeight * 0.5).roundToDouble(); + final maxHeight = math.max( + 0.0, + screenHeight - MediaQuery.paddingOf(context).top, + ); + final sheetHeight = math.min(halfHeight, maxHeight); + final keyboardHeight = _keyboardHeight(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 AnimatedPadding( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: EdgeInsets.only(bottom: keyboardHeight), + child: SizedBox( + key: const Key('comment-composer-sheet'), + height: sheetHeight, + child: Material( + color: AppColors.card, + clipBehavior: Clip.antiAlias, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(_sheetRadius), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: TextFieldTapRegion( + child: Column( + 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, + ), + ), + ), + Expanded( + child: Align( + alignment: Alignment.topCenter, + child: Container( + decoration: BoxDecoration( + color: AppColors.commentInput, + borderRadius: BorderRadius.circular(_inputRadius), + ), + child: TextField( + key: const Key('comment-composer-input'), + autofocus: true, + 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, + onTapOutside: (_) { + _inputFocus.unfocus(); + _closeSheet(); + }, + ), + ), + ), + ), + 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, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index cc206e7..683702b 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -10,18 +10,16 @@ import '../../../core/data/models/comment.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/repositories/comment_repository.dart'; import '../../../core/data/repositories/interaction_repository.dart'; -import '../../../core/data/services/comment_controller.dart'; import '../../../core/data/services/auth_controller.dart'; +import '../../../core/data/services/comment_controller.dart'; import '../../../core/formatting/error_text.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 '../../../core/widgets/empty_state.dart'; import '../../../l10n/app_localizations.dart'; +import 'comment_composer_sheet.dart'; import 'comment_tile.dart'; -const int _maxCommentLength = 300; const double _composerRadius = 22; const double _composerHeight = 44; const double _sendButtonSize = 38; @@ -29,10 +27,6 @@ const double _sheetRadius = 20; const double _headerHeight = 44; const double _replyIndent = 52; const double _composerTopGap = 8; -const double _replyBannerHeight = 46; -const double _expandedInputRadius = 12; -const double _postButtonHeight = 32; -const double _expandedComposerReserve = 113; /// 服务端评论底部弹窗:匿名列表、游标翻页、回复与登录发布。 class DramaCommentsSheet extends StatefulWidget { @@ -45,22 +39,14 @@ class DramaCommentsSheet extends StatefulWidget { } class _DramaCommentsSheetState extends State { - final _input = TextEditingController(); - final _inputFocus = FocusNode(); - final _keyboardInset = KeyboardInsetNotifier.instance; late final CommentRepository _comments; late final CommentController _controller; - DramaComment? _replyTo; int? _serverCount; int _countRequest = 0; @override void initState() { super.initState(); - _inputFocus.addListener(_onFocusChanged); - _input.addListener(_onInputChanged); - _keyboardInset.addListener(_onKeyboardInsetChanged); - unawaited(_keyboardInset.start()); _comments = context.read(); _controller = CommentController( comments: _comments, @@ -73,67 +59,12 @@ class _DramaCommentsSheetState extends State { @override void dispose() { - _keyboardInset.removeListener(_onKeyboardInsetChanged); - _inputFocus.removeListener(_onFocusChanged); - _input.removeListener(_onInputChanged); _controller.dispose(); - _input.dispose(); - _inputFocus.dispose(); super.dispose(); } - void _onFocusChanged() { - if (mounted) setState(() {}); - } - - void _onInputChanged() { - 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 _post() async { - if (_controller.submitting) return; - if (!await ensureSignedIn(context) || !mounted) return; - if (_input.text.trim().isEmpty) return; - unawaited(context.read().lightImpact()); - final result = await _controller.publish(_input.text, replyTo: _replyTo); - if (!mounted) return; - if (result.isSuccess) { - _input.clear(); - setState(() => _replyTo = null); - _inputFocus.requestFocus(); - await _refreshCount(); - return; - } - _showMessage( - result.errorMessage ?? AppLocalizations.of(context)!.commentPostFailed, - ); - } - void _beginReply(DramaComment comment) { - setState(() => _replyTo = comment); - _inputFocus.requestFocus(); - } - - void _cancelReply() { - if (_replyTo == null) return; - setState(() => _replyTo = null); - } - - Future _authenticateForComment() async { - _inputFocus.unfocus(); - await ensureSignedIn(context); - if (!mounted || !context.read().isSignedIn) return; - _inputFocus.requestFocus(); + unawaited(_openComposer(replyTo: comment)); } Future _refreshCount() async { @@ -148,17 +79,22 @@ class _DramaCommentsSheetState extends State { } } - void _showMessage(String message) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(message))); + Future _openComposer({DramaComment? replyTo}) async { + await ensureSignedIn(context); + if (!mounted || !context.read().isSignedIn) return; + final published = await showModalBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: true, + backgroundColor: Colors.transparent, + showDragHandle: false, + builder: (_) => + CommentComposerSheet(controller: _controller, replyTo: replyTo), + ); + if (published == true && mounted) await _refreshCount(); } void _handleDismissLayerTap() { - if (_inputFocus.hasFocus || _keyboardHeight(context) > 0) { - _inputFocus.unfocus(); - return; - } Navigator.pop(context); } @@ -169,9 +105,6 @@ class _DramaCommentsSheetState extends State { final halfHeight = (screenHeight * 0.5).roundToDouble(); final maxHeight = math.max(0.0, screenHeight - topSafeArea); final sheetHeight = math.min(halfHeight, maxHeight); - final inputFocused = _inputFocus.hasFocus; - final keyboardHeight = _keyboardHeight(context); - final expanded = inputFocused; return Stack( fit: StackFit.expand, children: [ @@ -184,40 +117,31 @@ class _DramaCommentsSheetState extends State { ), Align( alignment: Alignment.bottomCenter, - child: AnimatedPadding( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - padding: EdgeInsets.only(bottom: keyboardHeight), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () {}, - child: KeyedSubtree( - key: const Key('player-comments-sheet'), - child: SizedBox( - key: const Key('comment-sheet-constraints'), - height: sheetHeight, - child: Container( - decoration: const BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.vertical( - top: Radius.circular(_sheetRadius), - ), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, + child: KeyedSubtree( + key: const Key('player-comments-sheet'), + child: SizedBox( + key: const Key('comment-sheet-constraints'), + height: sheetHeight, + child: Container( + decoration: const BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.vertical( + top: Radius.circular(_sheetRadius), ), - child: SafeArea( - top: false, - child: ListenableBuilder( - listenable: _controller, - builder: (context, _) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: _content( - context, - expanded: expanded, - keyboardHeight: keyboardHeight, - ), - ); - }, - ), + ), + child: SafeArea( + top: false, + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _content(context), + ); + }, ), ), ), @@ -229,16 +153,11 @@ class _DramaCommentsSheetState extends State { ); } - Widget _content( - BuildContext context, { - required bool expanded, - required double keyboardHeight, - }) { + Widget _content(BuildContext context) { final l10n = AppLocalizations.of(context)!; final count = _serverCount ?? _controller.exactCount; - final composerReserve = expanded - ? _expandedComposerReserve - : _composerHeight + _composerTopGap; + final signedIn = context.watch().isSignedIn; + final composerReserve = _composerHeight + _composerTopGap; return Column( key: const Key('comment-sheet-content'), mainAxisSize: MainAxisSize.min, @@ -280,31 +199,17 @@ class _DramaCommentsSheetState extends State { ], ), ), - Expanded( - child: _list( - l10n, - composerReserve: composerReserve, - keyboardHeight: keyboardHeight, - ), - ), - if (_replyTo != null) _replyBanner(l10n, _replyTo!), + Expanded(child: _list(l10n, composerReserve: composerReserve)), Padding( key: const Key('comment-composer-area'), - padding: EdgeInsets.only( - top: expanded ? 12 : _composerTopGap, - bottom: expanded ? 12 : 0, - ), - child: _composer(l10n, focused: expanded), + padding: const EdgeInsets.only(top: _composerTopGap), + child: _fakeComposer(l10n, signedIn: signedIn), ), ], ); } - Widget _list( - AppLocalizations l10n, { - required double composerReserve, - required double keyboardHeight, - }) { + Widget _list(AppLocalizations l10n, {required double composerReserve}) { if (_controller.loading && _controller.comments.isEmpty) { return const Padding( padding: EdgeInsets.only(top: 4), @@ -340,10 +245,7 @@ class _DramaCommentsSheetState extends State { } return ListView.builder( key: const Key('comment-list'), - padding: EdgeInsets.only( - top: 4, - bottom: composerReserve + keyboardHeight, - ), + padding: EdgeInsets.only(top: 4, bottom: composerReserve), itemCount: _controller.comments.length + 1, itemBuilder: (context, index) { if (index == _controller.comments.length) return _footer(l10n); @@ -373,107 +275,10 @@ class _DramaCommentsSheetState extends State { return const SizedBox(height: 8); } - Widget _replyBanner(AppLocalizations l10n, DramaComment replyTo) => SizedBox( - height: _replyBannerHeight, - child: Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Row( - children: [ - Expanded( - child: Text( - l10n.replyingTo( - replyTo.nickname.isEmpty - ? l10n.anonymousUser - : replyTo.nickname, - ), - style: const TextStyle(color: AppColors.muted, fontSize: 12), - ), - ), - IconButton( - key: const Key('comment-cancel-reply'), - tooltip: l10n.cancelReply, - onPressed: _cancelReply, - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.close_rounded, size: 18), - ), - ], - ), - ), - ); - - InputDecoration _composerDecoration( - String hint, { - required bool focused, - required bool multiline, - }) { - final radius = BorderRadius.circular( - focused ? _expandedInputRadius : _composerRadius, - ); - final clear = OutlineInputBorder( - borderRadius: radius, - borderSide: BorderSide.none, - ); - return InputDecoration( - hintText: hint, - hintStyle: TextStyle( - color: AppColors.muted, - fontSize: 14, - height: multiline ? 1.4 : null, - ), - counterText: '', - contentPadding: EdgeInsets.symmetric( - horizontal: 16, - vertical: focused ? 12 : 0, - ), - isDense: true, - border: clear, - enabledBorder: clear, - focusedBorder: clear, - ); - } - - void _activateComposer(bool signedIn) { - if (signedIn) { - _inputFocus.requestFocus(); - return; - } - unawaited(_authenticateForComment()); - } - - Widget _composer(AppLocalizations l10n, {required bool focused}) { - final signedIn = context.watch().isSignedIn; - final hasContent = _input.text.trim().isNotEmpty; - final canPost = signedIn && hasContent && !_controller.submitting; - return Container( - key: const Key('comment-composer'), - child: TextFieldTapRegion( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Offstage( - offstage: focused, - child: _fakeComposer(l10n, signedIn: signedIn), - ), - Offstage( - offstage: !focused, - child: _realComposer( - l10n, - signedIn: signedIn, - hasContent: hasContent, - canPost: canPost, - ), - ), - ], - ), - ), - ); - } - Widget _fakeComposer(AppLocalizations l10n, {required bool signedIn}) { return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => _activateComposer(signedIn), + onTap: _openComposer, child: Container( key: const Key('comment-input-placeholder'), height: _composerHeight, @@ -510,7 +315,7 @@ class _DramaCommentsSheetState extends State { tooltip: l10n.postComment, padding: EdgeInsets.zero, iconSize: 18, - onPressed: () => _activateComposer(signedIn), + onPressed: _openComposer, icon: const Icon(Icons.send_rounded), ), ), @@ -519,87 +324,6 @@ class _DramaCommentsSheetState extends State { ), ); } - - Widget _realComposer( - AppLocalizations l10n, { - required bool signedIn, - required bool hasContent, - required bool canPost, - }) { - final multiline = _input.text.contains('\n'); - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AnimatedContainer( - key: const Key('comment-input-surface'), - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - decoration: BoxDecoration( - color: AppColors.commentInput, - borderRadius: BorderRadius.circular(_expandedInputRadius), - ), - child: TextField( - key: const Key('comment-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: _composerDecoration( - signedIn ? l10n.writeComment : l10n.commentLoginHint, - focused: true, - multiline: multiline, - ), - onTap: signedIn ? null : _authenticateForComment, - ), - ), - const SizedBox(height: 12), - Align( - alignment: Alignment.centerRight, - child: SizedBox( - height: _postButtonHeight, - child: TextButton( - key: const Key('comment-post'), - onPressed: canPost ? _post : 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, - ), - ), - ), - ), - ), - ], - ); - } } class _CommentThread extends StatelessWidget { @@ -641,9 +365,8 @@ class _CommentThread extends StatelessWidget { Text( expanded ? AppLocalizations.of(context)!.hideReplies - : AppLocalizations.of( - context, - )!.viewReplies(comment.replyCount), + : AppLocalizations.of(context)! + .viewReplies(comment.replyCount), style: const TextStyle( color: AppColors.muted, fontSize: 12, diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 16a70bf..a52cbb2 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -48,21 +48,18 @@ void main() { final repository = FakeCommentRepository(pages: [const CommentPage()]); await _pumpSheet(tester, repository: repository); - final input = find.byKey(const Key('comment-input'), skipOffstage: false); final placeholder = find.byKey(const Key('comment-input-placeholder')); expect(placeholder, findsOneWidget); - expect(find.byKey(const Key('comment-input')), findsNothing); expect(find.text('Sign in to comment'), findsOneWidget); - expect(tester.widget(input).focusNode?.hasFocus, isFalse); await tester.tap(placeholder); await tester.pumpAndSettle(); expect(find.text('Sign in to continue'), findsOneWidget); - expect(tester.widget(input).focusNode?.hasFocus, isFalse); await tester.tap(find.text('Not now')); await tester.pumpAndSettle(); - expect(tester.widget(input).focusNode?.hasFocus, isFalse); + expect(find.byKey(const Key('comment-composer-sheet')), findsNothing); + expect(placeholder, findsOneWidget); }); testWidgets('should_focus_comment_input_after_successful_login', ( @@ -82,31 +79,35 @@ void main() { (auth as _TestAuthController).setSignedIn(true); await tester.pump(); Navigator.of(tester.element(find.byKey(const Key('auth-landing')))).pop(); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); expect( tester - .widget(find.byKey(const Key('comment-input'))) + .widget(find.byKey(const Key('comment-composer-input'))) .focusNode ?.hasFocus, isTrue, ); }); - testWidgets('should_focus_real_input_when_fake_send_is_tapped', ( + testWidgets('should_open_composer_sheet_when_fake_send_is_tapped', ( tester, ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); final repository = FakeCommentRepository(pages: [const CommentPage()]); await _pumpSheet(tester, repository: repository); - expect(find.byKey(const Key('comment-input')), findsNothing); await tester.tap(find.byKey(const Key('comment-post'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('comment-input-placeholder')), findsNothing); - final input = find.byKey(const Key('comment-input')); + final composer = find.byKey(const Key('comment-composer-sheet')); + expect(composer, findsOneWidget); + expect(tester.getSize(composer).height, closeTo(844 / 2, 0.1)); + final input = find.byKey(const Key('comment-composer-input')); expect(input, findsOneWidget); expect(tester.widget(input).focusNode?.hasFocus, isTrue); expect( @@ -234,16 +235,16 @@ void main() { await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); await tester.enterText( - find.byKey(const Key('comment-input')), + find.byKey(const Key('comment-composer-input')), 'blocked text', ); await tester.pump(); - await tester.tap(find.byKey(const Key('comment-post'))); + await tester.tap(find.byKey(const Key('comment-composer-post'))); await tester.pumpAndSettle(); expect(find.text('评论包含敏感词'), findsOneWidget); final field = tester.widget( - find.byKey(const Key('comment-input')), + find.byKey(const Key('comment-composer-input')), ); expect(field.controller?.text, 'blocked text'); // 发送失败时保留输入与焦点,用户可以直接改完再发。 @@ -251,9 +252,7 @@ void main() { }, ); - testWidgets('should_clear_and_keep_input_focused_after_successful_post', ( - tester, - ) async { + testWidgets('should_close_composer_after_successful_post', (tester) async { addTearDown(tester.view.resetViewInsets); final repository = FakeCommentRepository( pages: [const CommentPage()], @@ -262,7 +261,7 @@ void main() { ); await _pumpSheet(tester, repository: repository); - final input = find.byKey(const Key('comment-input')); + final input = find.byKey(const Key('comment-composer-input')); tester.view.viewInsets = const FakeViewPadding(bottom: 180); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); @@ -271,28 +270,21 @@ void main() { await tester.pump(); expect( tester - .widget(find.byKey(const Key('comment-post'))) + .widget(find.byKey(const Key('comment-composer-post'))) .onPressed, isNotNull, ); - await tester.tap(find.byKey(const Key('comment-post'))); + await tester.tap(find.byKey(const Key('comment-composer-post'))); await tester.pumpAndSettle(); - expect(tester.widget(input).controller?.text, isEmpty); - expect(tester.widget(input).focusNode?.hasFocus, isTrue); - expect(tester.widget(input).maxLines, 4); + expect(find.byKey(const Key('comment-composer-sheet')), findsNothing); expect(find.byKey(const Key('comment-list')), findsOneWidget); expect(find.byKey(const Key('comment-count-label')), findsOneWidget); - expect( - tester - .widget(find.byKey(const Key('comment-post'))) - .onPressed, - isNull, - ); + expect(find.byKey(const Key('comment-input-placeholder')), findsOneWidget); expect(repository.countCalls, ['5', '5']); }); - testWidgets('should_keep_composer_separate_when_keyboard_opens', ( + testWidgets('should_keep_comment_panel_fixed_and_open_composer_sheet', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); @@ -309,128 +301,59 @@ void main() { ); await _pumpSheet(tester, repository: repository); - expect(find.byKey(const Key('comment-list')), findsOneWidget); - expect(find.byKey(const Key('comment-input')), findsNothing); - expect(find.byKey(const Key('comment-input-placeholder')), findsOneWidget); - final hiddenInput = find.byKey( - const Key('comment-input'), - skipOffstage: false, - ); - final editableBefore = tester.state( - find.descendant( - of: hiddenInput, - matching: find.byType(EditableText, skipOffstage: false), - skipOffstage: false, - ), - ); - - final sheet = find.byKey(const Key('player-comments-sheet')); + final commentsSheet = find.byKey(const Key('player-comments-sheet')); final list = find.byKey(const Key('comment-list')); - final heightBeforeKeyboard = tester.getSize(sheet).height; - tester.view.viewInsets = const FakeViewPadding(bottom: 180); + final placeholder = find.byKey(const Key('comment-input-placeholder')); + final sheetRect = tester.getRect(commentsSheet); + final sheetHeight = tester.getSize(commentsSheet).height; + await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('comment-list')), findsOneWidget); - expect(find.byKey(const Key('comment-count-label')), findsOneWidget); - expect(find.byKey(const Key('comment-composer')), findsOneWidget); - expect(find.byKey(const Key('comment-input-surface')), findsOneWidget); - expect(find.byKey(const Key('comment-input-placeholder')), findsNothing); - expect( - tester.widget(find.byKey(const Key('comment-input'))).maxLines, - 4, + final composer = find.byKey(const Key('comment-composer-sheet')); + final composerInput = find.byKey(const Key('comment-composer-input')); + expect(composer, findsOneWidget); + expect(tester.getSize(composer).height, closeTo(844 / 2, 0.1)); + expect(tester.widget(composerInput).focusNode?.hasFocus, isTrue); + final underlyingSheet = find.byKey( + const Key('player-comments-sheet'), + skipOffstage: false, ); - final editableAfter = tester.state( - find.descendant( - of: find.byKey(const Key('comment-input')), - matching: find.byType(EditableText), - ), - ); - expect(identical(editableBefore, editableAfter), isTrue); + expect(tester.getRect(underlyingSheet), sheetRect); + expect(tester.getSize(underlyingSheet).height, closeTo(sheetHeight, 0.1)); - expect(sheet, findsOneWidget); - expect(list, findsOneWidget); - expect(tester.getSize(sheet).height, closeTo(heightBeforeKeyboard, 0.1)); - expect(find.byKey(const Key('comment-count-label')), findsOneWidget); - final field = tester.widget( - find.byKey(const Key('comment-input')), - ); + final field = tester.widget(composerInput); expect(field.minLines, 1); expect(field.maxLines, 4); expect(field.style?.fontSize, 15); - expect( - tester - .widget(find.byKey(const Key('comment-post'))) - .onPressed, - isNull, - ); - expect(tester.getSize(find.byKey(const Key('comment-post'))).height, 32); - final listRect = tester.getRect(list); - final inputRect = tester.getRect(find.byKey(const Key('comment-input'))); - expect(listRect.overlaps(inputRect), isFalse); - expect(listRect.bottom, lessThanOrEqualTo(inputRect.top)); expect(find.byIcon(Icons.image_outlined), findsNothing); expect(find.byIcon(Icons.emoji_emotions_outlined), findsNothing); expect(find.byIcon(Icons.alternate_email_rounded), findsNothing); - await tester.enterText(find.byKey(const Key('comment-input')), 'Nice'); + tester.view.viewInsets = const FakeViewPadding(bottom: 180); await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.getSize(composer).height, closeTo(844 / 2, 0.1)); + expect(tester.getBottomLeft(composer).dy, closeTo(844 - 180, 0.1)); expect( - tester - .widget(find.byKey(const Key('comment-post'))) - .onPressed, - isNotNull, - ); - - final editableDuringKeyboard = tester.state( - find.descendant( - of: find.byKey(const Key('comment-input')), - matching: find.byType(EditableText), - ), - ); - expect(identical(editableAfter, editableDuringKeyboard), isTrue); - expect(tester.getBottomLeft(sheet).dy, closeTo(844 - 180, 0.1)); - expect( - tester.widget(list).padding?.resolve(TextDirection.ltr).bottom, - greaterThanOrEqualTo(180 + 44), - ); - expect( - tester.getBottomLeft(find.byKey(const Key('comment-input'))).dy, + tester.getBottomLeft(composerInput).dy, lessThanOrEqualTo(844 - 180), ); - expect( - tester - .widget(find.byKey(const Key('comment-input'))) - .focusNode - ?.hasFocus, - isTrue, - ); + expect(tester.getRect(underlyingSheet), sheetRect); - await tester.tapAt(const Offset(20, 20)); - await tester.pump(); - expect(tester.widget(hiddenInput).focusNode?.hasFocus, isFalse); tester.view.viewInsets = const FakeViewPadding(); await tester.pumpAndSettle(); + expect(composer, findsNothing); + expect(commentsSheet, findsOneWidget); expect(list, findsOneWidget); - expect(find.byKey(const Key('comment-count-label')), findsOneWidget); - expect(find.byKey(const Key('comment-composer')), findsOneWidget); - expect(find.byKey(const Key('comment-input-placeholder')), findsOneWidget); - expect(find.byKey(const Key('comment-input')), findsNothing); - expect(tester.getSize(sheet).height, closeTo(heightBeforeKeyboard, 0.1)); - expect(tester.getSize(sheet).height, lessThan(520)); - final editableAfterKeyboard = tester.state( - find.descendant( - of: hiddenInput, - matching: find.byType(EditableText, skipOffstage: false), - skipOffstage: false, - ), - ); - expect(identical(editableAfter, editableAfterKeyboard), isTrue); - expect(tester.widget(hiddenInput).controller?.text, 'Nice'); - expect(tester.widget(hiddenInput).focusNode?.hasFocus, isFalse); + expect(placeholder, findsOneWidget); + expect(tester.getRect(commentsSheet), sheetRect); + expect(tester.getSize(commentsSheet).height, closeTo(sheetHeight, 0.1)); }); - testWidgets('should_dismiss_from_outside_in_two_steps', (tester) async { + testWidgets('should_close_composer_before_comment_panel_from_outside', ( + tester, + ) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -446,37 +369,20 @@ void main() { await _pumpSheet(tester, repository: repository); final sheet = find.byKey(const Key('player-comments-sheet')); - final input = find.byKey(const Key('comment-input')); - final hiddenInput = find.byKey( - const Key('comment-input'), - skipOffstage: false, - ); expect( find.byKey(const Key('player-comments-dismiss-layer')), findsOneWidget, ); - tester.view.viewInsets = const FakeViewPadding(bottom: 180); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); expect(sheet, findsOneWidget); - expect(input, findsOneWidget); - expect(tester.widget(input).focusNode?.hasFocus, isTrue); - - await tester.tap(find.byKey(const Key('comment-list'))); - await tester.pump(); - expect(sheet, findsOneWidget); + expect(find.byKey(const Key('comment-composer-sheet')), findsOneWidget); await tester.tapAt(const Offset(20, 20)); - await tester.pump(); - expect(sheet, findsOneWidget); - expect(tester.widget(hiddenInput).focusNode?.hasFocus, isFalse); - expect(find.byKey(const Key('comment-input-placeholder')), findsOneWidget); - expect(input, findsNothing); - - tester.view.viewInsets = const FakeViewPadding(); await tester.pumpAndSettle(); expect(sheet, findsOneWidget); + expect(find.byKey(const Key('comment-composer-sheet')), findsNothing); await tester.tapAt(const Offset(20, 20)); await tester.pumpAndSettle(); @@ -694,22 +600,18 @@ void main() { await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); await tester.enterText( - find.byKey(const Key('comment-input')), + find.byKey(const Key('comment-composer-input')), 'hello badword', ); await tester.pump(); - await tester.tap(find.byKey(const Key('comment-post'))); + await tester.tap(find.byKey(const Key('comment-composer-post'))); await tester.pumpAndSettle(); - FocusManager.instance.primaryFocus?.unfocus(); - await tester.pumpAndSettle(); + expect(find.byKey(const Key('comment-composer-sheet')), findsNothing); expect(find.text('hello ***'), findsOneWidget); expect(find.text('Under review'), findsOneWidget); expect(haptics.lightImpacts, 1); - final field = tester.widget( - find.byKey(const Key('comment-input'), skipOffstage: false), - ); - expect(field.controller?.text, isEmpty); + expect(find.byKey(const Key('comment-input-placeholder')), findsOneWidget); }); testWidgets('should_send_parent_id_when_replying_to_top_level_comment', ( @@ -731,16 +633,19 @@ void main() { await _pumpSheet(tester, repository: repository); await tester.tap(find.byKey(const Key('comment-reply-10'))); - await tester.pump(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('comment-composer-sheet')), findsOneWidget); expect(find.textContaining('Replying to'), findsOneWidget); - await tester.enterText(find.byKey(const Key('comment-input')), 'My reply'); + await tester.enterText( + find.byKey(const Key('comment-composer-input')), + 'My reply', + ); await tester.pump(); - await tester.tap(find.byKey(const Key('comment-post'))); + await tester.tap(find.byKey(const Key('comment-composer-post'))); await tester.pumpAndSettle(); expect(repository.publishCalls.single.parentId, '10'); - FocusManager.instance.primaryFocus?.unfocus(); - await tester.pumpAndSettle(); + expect(find.byKey(const Key('comment-composer-sheet')), findsNothing); expect(find.text('My reply'), findsOneWidget); }); diff --git a/test/widget_test.dart b/test/widget_test.dart index 65ddeff..6ee71f8 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -250,11 +250,12 @@ void main() { expect(find.text('No comments yet.'), findsOneWidget); await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo'); + await tester.enterText( + find.byKey(const Key('comment-composer-input')), + 'Nice demo', + ); await tester.pump(); - await tester.tap(find.byKey(const Key('comment-post'))); - await tester.pumpAndSettle(); - FocusManager.instance.primaryFocus?.unfocus(); + await tester.tap(find.byKey(const Key('comment-composer-post'))); await tester.pumpAndSettle(); expect(find.text('Nice demo'), findsOneWidget); await tester.binding.handlePopRoute();