diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 99cb453..c2e0bf8 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -106,12 +106,14 @@ class _DramaCommentsSheetState extends State { ); void _measureSheetWithoutKeyboard(double keyboardHeight) { - if (keyboardHeight > 0 || _sheetMeasureScheduled) return; + if (keyboardHeight > 0 || _inputFocus.hasFocus || _sheetMeasureScheduled) { + return; + } _sheetMeasureScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { _sheetMeasureScheduled = false; if (!mounted) return; - if (_keyboardHeight(context) > 0) return; + if (_inputFocus.hasFocus || _keyboardHeight(context) > 0) return; final box = _sheetMeasureKey.currentContext?.findRenderObject(); if (box is! RenderBox || !box.hasSize) return; final height = box.size.height; @@ -195,7 +197,7 @@ class _DramaCommentsSheetState extends State { final inputFocused = _inputFocus.hasFocus; final keyboardHeight = _keyboardHeight(context); final keyboardOpen = keyboardHeight > 0; - final expanded = inputFocused && keyboardOpen; + final expanded = inputFocused; final availableHeight = math.max(0.0, screenLimit - keyboardHeight); final keyboardPanelHeight = math.min( _sheetHeightWithoutKeyboard ?? maxHeight, @@ -221,10 +223,9 @@ class _DramaCommentsSheetState extends State { child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () {}, - child: SizedBox( - key: _sheetMeasureKey, + child: KeyedSubtree( + key: const Key('player-comments-sheet'), child: Container( - key: const Key('player-comments-sheet'), decoration: const BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.vertical( @@ -245,24 +246,27 @@ class _DramaCommentsSheetState extends State { duration: const Duration(milliseconds: 200), curve: Curves.easeOut, alignment: Alignment.bottomCenter, - child: ConstrainedBox( + child: KeyedSubtree( key: const Key('comment-sheet-constraints'), - constraints: keyboardOpen - ? BoxConstraints.tightFor( - height: keyboardPanelHeight, - ) - : BoxConstraints( - minHeight: minHeight, - maxHeight: maxHeight, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - child: _content( - context, - expanded: expanded, - keyboardHeight: keyboardHeight, + child: ConstrainedBox( + key: _sheetMeasureKey, + constraints: keyboardOpen + ? BoxConstraints.tightFor( + height: keyboardPanelHeight, + ) + : BoxConstraints( + minHeight: minHeight, + maxHeight: maxHeight, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: _content( + context, + expanded: expanded, + keyboardHeight: keyboardHeight, + ), ), ), ), @@ -476,132 +480,158 @@ class _DramaCommentsSheetState extends State { ); } + 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'), - padding: focused ? EdgeInsets.zero : const EdgeInsets.only(right: 3), - decoration: BoxDecoration( - color: focused ? Colors.transparent : AppColors.commentInput, - borderRadius: BorderRadius.circular(focused ? 0 : _composerRadius), - ), child: TextFieldTapRegion( child: 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: focused ? AppColors.commentInput : Colors.transparent, - borderRadius: BorderRadius.circular( - focused ? _expandedInputRadius : _composerRadius, - ), - ), - child: SizedBox( - height: focused ? null : _composerHeight, - child: Row( - crossAxisAlignment: focused - ? CrossAxisAlignment.start - : CrossAxisAlignment.center, - children: [ - Expanded( - child: SizedBox( - height: focused ? null : _composerHeight, - child: TextField( - key: const Key('comment-input'), - controller: _input, - focusNode: _inputFocus, - readOnly: !signedIn, - canRequestFocus: signedIn, - minLines: 1, - maxLines: focused ? 4 : 1, - maxLength: _maxCommentLength, - textAlignVertical: focused - ? TextAlignVertical.top - : TextAlignVertical.center, - style: TextStyle( - color: AppColors.cream, - fontSize: focused ? 15 : 14, - ), - cursorColor: AppColors.blue, - decoration: _composerDecoration( - signedIn - ? l10n.writeComment - : l10n.commentLoginHint, - focused: focused, - ), - onTap: signedIn ? null : _authenticateForComment, - onSubmitted: focused ? null : (_) => _post(), - ), - ), - ), - if (!focused) - SizedBox.square( - dimension: _sendButtonSize, - child: IconButton.filled( - key: const Key('comment-post'), - tooltip: l10n.postComment, - padding: EdgeInsets.zero, - iconSize: 18, - onPressed: _controller.submitting ? null : _post, - icon: _controller.submitting - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.send_rounded), - ), - ), - ], - ), + Offstage( + offstage: focused, + child: _fakeComposer(l10n, signedIn: signedIn), + ), + Offstage( + offstage: !focused, + child: _realComposer( + l10n, + signedIn: signedIn, + hasContent: hasContent, + canPost: canPost, ), ), - if (focused) ...[ - 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, - ), - ), - ), - ), - ), - ], ], ), ), ); } + + Widget _fakeComposer(AppLocalizations l10n, {required bool signedIn}) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _activateComposer(signedIn), + child: Container( + key: const Key('comment-input-placeholder'), + height: _composerHeight, + padding: const EdgeInsets.only(right: 3), + decoration: BoxDecoration( + color: AppColors.commentInput, + borderRadius: BorderRadius.circular(_composerRadius), + ), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 16), + child: Text( + signedIn ? l10n.writeComment : l10n.commentLoginHint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.muted, fontSize: 14), + ), + ), + ), + SizedBox.square( + dimension: _sendButtonSize, + child: IconButton.filled( + key: const Key('comment-post'), + tooltip: l10n.postComment, + padding: EdgeInsets.zero, + iconSize: 18, + onPressed: () => _activateComposer(signedIn), + icon: const Icon(Icons.send_rounded), + ), + ), + ], + ), + ), + ); + } + + Widget _realComposer( + AppLocalizations l10n, { + required bool signedIn, + required bool hasContent, + required bool canPost, + }) { + 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: TextAlignVertical.top, + style: const TextStyle(color: AppColors.cream, fontSize: 15), + cursorColor: AppColors.blue, + decoration: _composerDecoration( + signedIn ? l10n.writeComment : l10n.commentLoginHint, + focused: true, + ), + 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 { diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index 383c1bf..098a524 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -48,12 +48,14 @@ void main() { final repository = FakeCommentRepository(pages: [const CommentPage()]); await _pumpSheet(tester, repository: repository); - final input = find.byKey(const Key('comment-input')); - final field = tester.widget(input); + final input = find.byKey(const Key('comment-input'), skipOffstage: false); + final placeholder = find.byKey(const Key('comment-input-placeholder')); - expect(field.decoration?.hintText, 'Sign in to comment'); - expect(field.focusNode?.hasFocus, isFalse); - await tester.tap(input); + 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); @@ -71,7 +73,7 @@ void main() { final repository = FakeCommentRepository(pages: [const CommentPage()]); final auth = await _pumpSheet(tester, repository: repository); - await tester.tap(find.byKey(const Key('comment-input'))); + await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); await tester.tap(find.text('Sign In')); @@ -92,6 +94,23 @@ void main() { ); }); + testWidgets('should_focus_real_input_when_fake_send_is_tapped', ( + tester, + ) async { + 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')); + expect(input, findsOneWidget); + expect(tester.widget(input).focusNode?.hasFocus, isTrue); + }); + testWidgets('should_gate_guest_post_without_creating_comment', ( tester, ) async { @@ -202,10 +221,13 @@ void main() { ); await _pumpSheet(tester, repository: repository); + await tester.tap(find.byKey(const Key('comment-input-placeholder'))); + await tester.pumpAndSettle(); await tester.enterText( find.byKey(const Key('comment-input')), 'blocked text', ); + await tester.pump(); await tester.tap(find.byKey(const Key('comment-post'))); await tester.pumpAndSettle(); @@ -232,7 +254,7 @@ void main() { await _pumpSheet(tester, repository: repository); final input = find.byKey(const Key('comment-input')); tester.view.viewInsets = const FakeViewPadding(bottom: 180); - await tester.tap(input); + await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.pumpAndSettle(); expect(tester.widget(input).focusNode?.hasFocus, isTrue); await tester.enterText(input, 'Nice demo'); @@ -278,25 +300,36 @@ 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: find.byKey(const Key('comment-input')), - matching: find.byType(EditableText), + of: hiddenInput, + matching: find.byType(EditableText, skipOffstage: false), + skipOffstage: false, ), ); - await tester.tap(find.byKey(const Key('comment-input'))); + final sheet = 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); + 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, - 1, + 4, ); - expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 44); final editableAfter = tester.state( find.descendant( of: find.byKey(const Key('comment-input')), @@ -305,13 +338,6 @@ void main() { ); expect(identical(editableBefore, editableAfter), isTrue); - final sheet = 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); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 200)); - expect(sheet, findsOneWidget); expect(list, findsOneWidget); expect(tester.getSize(sheet).height, closeTo(heightBeforeKeyboard, 0.1)); @@ -370,39 +396,28 @@ void main() { isTrue, ); + 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(list, findsOneWidget); expect(find.byKey(const Key('comment-count-label')), findsOneWidget); expect(find.byKey(const Key('comment-composer')), findsOneWidget); - final collapsedField = tester.widget( - find.byKey(const Key('comment-input')), - ); - expect(collapsedField.maxLines, 1); - expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 44); + 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: find.byKey(const Key('comment-input')), - matching: find.byType(EditableText), + of: hiddenInput, + matching: find.byType(EditableText, skipOffstage: false), + skipOffstage: false, ), ); expect(identical(editableAfter, editableAfterKeyboard), isTrue); - expect( - tester - .widget(find.byKey(const Key('comment-input'))) - .controller - ?.text, - 'Nice', - ); - expect( - tester - .widget(find.byKey(const Key('comment-input'))) - .focusNode - ?.hasFocus, - isTrue, - ); + expect(tester.widget(hiddenInput).controller?.text, 'Nice'); + expect(tester.widget(hiddenInput).focusNode?.hasFocus, isFalse); }); testWidgets('should_dismiss_from_outside_in_two_steps', (tester) async { @@ -422,15 +437,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(input); + 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'))); @@ -440,7 +460,9 @@ void main() { await tester.tapAt(const Offset(20, 20)); await tester.pump(); expect(sheet, findsOneWidget); - expect(tester.widget(input).focusNode?.hasFocus, isFalse); + 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(); @@ -466,7 +488,10 @@ void main() { tester.getSize(find.byKey(const Key('comment-post'))), const Size(38, 38), ); - expect(tester.getSize(find.byKey(const Key('comment-input'))).height, 44); + expect( + tester.getSize(find.byKey(const Key('comment-input-placeholder'))).height, + 44, + ); expect(find.byKey(const Key('player-comments-drag-handle')), findsNothing); expect( tester.getSize(find.byKey(const Key('player-comments-close'))), @@ -502,7 +527,7 @@ void main() { expect(body.style?.fontSize, 16); expect(body.style?.height, 1.4); final composer = tester.widget( - find.byKey(const Key('comment-composer')), + find.byKey(const Key('comment-input-placeholder')), ); final decoration = composer.decoration! as BoxDecoration; expect(decoration.color, AppColors.commentInput); @@ -655,10 +680,13 @@ void main() { final haptics = FakeHaptics(); await _pumpSheet(tester, repository: repository, haptics: haptics); + await tester.tap(find.byKey(const Key('comment-input-placeholder'))); + await tester.pumpAndSettle(); await tester.enterText( find.byKey(const Key('comment-input')), 'hello badword', ); + await tester.pump(); await tester.tap(find.byKey(const Key('comment-post'))); await tester.pumpAndSettle(); @@ -668,7 +696,7 @@ void main() { expect(find.text('Under review'), findsOneWidget); expect(haptics.lightImpacts, 1); final field = tester.widget( - find.byKey(const Key('comment-input')), + find.byKey(const Key('comment-input'), skipOffstage: false), ); expect(field.controller?.text, isEmpty); }); diff --git a/test/widget_test.dart b/test/widget_test.dart index 492e7a6..65ddeff 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -248,6 +248,8 @@ void main() { await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); 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.pump(); await tester.tap(find.byKey(const Key('comment-post')));