merge: 合并 K86.16 评论提示与输入弹层修复

This commit is contained in:
Zhou Weidong committed 2026-10-21 13:59:16 +08:00
commit f9f77090d9
3 files changed
+274 -200

No files matched your search

@@ -155,130 +155,160 @@ class _CommentComposerSheetState extends State<CommentComposerSheet>
final canPost = signedIn && hasContent && !widget.controller.submitting; final canPost = signedIn && hasContent && !widget.controller.submitting;
final multiline = _input.text.contains('\n'); final multiline = _input.text.contains('\n');
return AnimatedPadding( return Stack(
duration: const Duration(milliseconds: 200), fit: StackFit.expand,
curve: Curves.easeOut, children: [
padding: EdgeInsets.only(bottom: keyboardHeight), Positioned.fill(
child: SizedBox( child: GestureDetector(
key: const Key('comment-composer-sheet'), key: const Key('comment-composer-dismiss-layer'),
width: double.infinity, behavior: HitTestBehavior.opaque,
child: Material( onTap: () {
color: AppColors.card, _inputFocus.unfocus();
clipBehavior: Clip.antiAlias, _closeSheet();
borderRadius: const BorderRadius.vertical( },
top: Radius.circular(_sheetRadius),
), ),
child: SafeArea( ),
top: false, Align(
child: Padding( alignment: Alignment.bottomCenter,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), child: AnimatedPadding(
child: TextFieldTapRegion( duration: const Duration(milliseconds: 200),
child: Column( curve: Curves.easeOut,
mainAxisSize: MainAxisSize.min, padding: EdgeInsets.only(bottom: keyboardHeight),
crossAxisAlignment: CrossAxisAlignment.stretch, child: GestureDetector(
children: [ behavior: HitTestBehavior.opaque,
if (widget.replyTo != null) onTap: () {},
Padding( child: SizedBox(
padding: const EdgeInsets.only(bottom: 8), key: const Key('comment-composer-sheet'),
child: Text( width: double.infinity,
l10n.replyingTo( child: Material(
widget.replyTo!.nickname.isEmpty color: AppColors.card,
? l10n.anonymousUser clipBehavior: Clip.antiAlias,
: widget.replyTo!.nickname, borderRadius: const BorderRadius.vertical(
), top: Radius.circular(_sheetRadius),
style: const TextStyle( ),
color: AppColors.muted, child: SafeArea(
fontSize: 12, top: false,
), child: Padding(
), padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
), child: TextFieldTapRegion(
Container( child: Column(
decoration: BoxDecoration( mainAxisSize: MainAxisSize.min,
color: AppColors.commentInput, crossAxisAlignment: CrossAxisAlignment.stretch,
borderRadius: BorderRadius.circular(_inputRadius), children: [
), if (widget.replyTo != null)
child: TextField( Padding(
key: const Key('comment-composer-input'), padding: const EdgeInsets.only(bottom: 8),
autofocus: true, child: Text(
controller: _input, l10n.replyingTo(
focusNode: _inputFocus, widget.replyTo!.nickname.isEmpty
readOnly: !signedIn, ? l10n.anonymousUser
canRequestFocus: signedIn, : widget.replyTo!.nickname,
minLines: 1, ),
maxLines: 4, style: const TextStyle(
maxLength: _maxCommentLength, color: AppColors.muted,
textAlignVertical: multiline fontSize: 12,
? TextAlignVertical.top ),
: TextAlignVertical.center, ),
style: TextStyle( ),
color: AppColors.cream, Container(
fontSize: 15, decoration: BoxDecoration(
height: multiline ? 1.4 : null, color: AppColors.commentInput,
), borderRadius: BorderRadius.circular(
cursorColor: AppColors.blue, _inputRadius,
decoration: InputDecoration( ),
hintText: signedIn ),
? l10n.writeComment child: TextField(
: l10n.commentLoginHint, key: const Key('comment-composer-input'),
hintStyle: const TextStyle( autofocus: true,
color: AppColors.muted, controller: _input,
fontSize: 14, focusNode: _inputFocus,
), readOnly: !signedIn,
counterText: '', canRequestFocus: signedIn,
contentPadding: const EdgeInsets.symmetric( minLines: 1,
horizontal: 16, maxLines: 4,
vertical: 12, maxLength: _maxCommentLength,
), textAlignVertical: multiline
border: InputBorder.none, ? TextAlignVertical.top
enabledBorder: InputBorder.none, : TextAlignVertical.center,
focusedBorder: InputBorder.none, style: TextStyle(
), color: AppColors.cream,
onTap: signedIn ? null : _authenticate, fontSize: 15,
onTapOutside: (_) { height: multiline ? 1.4 : null,
_inputFocus.unfocus(); ),
_closeSheet(); cursorColor: AppColors.blue,
}, decoration: InputDecoration(
), hintText: signedIn
), ? l10n.writeComment
const SizedBox(height: 12), : l10n.commentLoginHint,
Align( hintStyle: const TextStyle(
alignment: Alignment.centerRight, color: AppColors.muted,
child: SizedBox( fontSize: 14,
height: _postButtonHeight, ),
child: TextButton( counterText: '',
key: const Key('comment-composer-post'), contentPadding: const EdgeInsets.symmetric(
onPressed: canPost ? _submit : null, horizontal: 16,
style: TextButton.styleFrom( vertical: 12,
minimumSize: const Size(0, _postButtonHeight), ),
padding: const EdgeInsets.symmetric(horizontal: 16), border: InputBorder.none,
tapTargetSize: MaterialTapTargetSize.shrinkWrap, enabledBorder: InputBorder.none,
backgroundColor: hasContent focusedBorder: InputBorder.none,
? AppColors.blue.withValues(alpha: 0.12) ),
: AppColors.raised, onTap: signedIn ? null : _authenticate,
foregroundColor: AppColors.blue, onTapOutside: (_) {
disabledForegroundColor: AppColors.muted, _inputFocus.unfocus();
disabledBackgroundColor: AppColors.raised, _closeSheet();
shape: RoundedRectangleBorder( },
borderRadius: BorderRadius.circular(8), ),
), ),
), const SizedBox(height: 12),
child: Text( Align(
l10n.postComment, alignment: Alignment.centerRight,
style: const TextStyle( child: SizedBox(
fontSize: 14, height: _postButtonHeight,
fontWeight: FontWeight.w600, 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,
),
),
),
),
), ),
), ],
), ),
), ),
), ),
], ),
), ),
), ),
), ),
), ),
), ),
), ],
); );
} }
} }
@@ -41,7 +41,8 @@ class DramaCommentsSheet extends StatefulWidget {
class _DramaCommentsSheetState extends State<DramaCommentsSheet> { class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
late final CommentRepository _comments; late final CommentRepository _comments;
late final CommentController _controller; late final CommentController _controller;
final _messengerKey = GlobalKey<ScaffoldMessengerState>(); OverlayEntry? _successOverlay;
Timer? _successOverlayTimer;
int? _serverCount; int? _serverCount;
int _countRequest = 0; int _countRequest = 0;
@@ -60,6 +61,8 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
@override @override
void dispose() { void dispose() {
_successOverlayTimer?.cancel();
_successOverlay?.remove();
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }
@@ -88,6 +91,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
isScrollControlled: true, isScrollControlled: true,
isDismissible: true, isDismissible: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
barrierColor: Colors.transparent,
showDragHandle: false, showDragHandle: false,
builder: (_) => builder: (_) =>
CommentComposerSheet(controller: _controller, replyTo: replyTo), CommentComposerSheet(controller: _controller, replyTo: replyTo),
@@ -103,32 +107,19 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
} }
void _showPostSuccess() { void _showPostSuccess() {
final messenger = _messengerKey.currentState; _successOverlayTimer?.cancel();
if (messenger == null) return; _successOverlay?.remove();
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
messenger final entry = OverlayEntry(
..hideCurrentSnackBar() builder: (_) =>
..showSnackBar( _CommentPostSuccessOverlay(message: l10n.commentPostSuccess),
SnackBar( );
key: const Key('comment-post-success-snackbar'), _successOverlay = entry;
content: Text( Overlay.of(context, rootOverlay: true).insert(entry);
l10n.commentPostSuccess, _successOverlayTimer = Timer(const Duration(milliseconds: 1200), () {
style: const TextStyle( if (entry.mounted) entry.remove();
color: Colors.white, if (identical(_successOverlay, entry)) _successOverlay = null;
fontSize: 14, });
fontWeight: FontWeight.w500,
),
),
behavior: SnackBarBehavior.floating,
duration: const Duration(milliseconds: 1500),
backgroundColor: AppColors.ink,
elevation: 6,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
margin: EdgeInsets.fromLTRB(16, 0, 16, _sheetHeight(context) + 12),
),
);
} }
double _sheetHeight(BuildContext context) { double _sheetHeight(BuildContext context) {
@@ -142,60 +133,51 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sheetHeight = _sheetHeight(context); final sheetHeight = _sheetHeight(context);
return ScaffoldMessenger( return Stack(
key: _messengerKey, fit: StackFit.expand,
child: Scaffold( children: [
backgroundColor: Colors.transparent, Positioned.fill(
resizeToAvoidBottomInset: false, child: GestureDetector(
body: Stack( key: const Key('player-comments-dismiss-layer'),
fit: StackFit.expand, behavior: HitTestBehavior.opaque,
children: [ onTap: _handleDismissLayerTap,
Positioned.fill( ),
child: GestureDetector( ),
key: const Key('player-comments-dismiss-layer'), Align(
behavior: HitTestBehavior.opaque, alignment: Alignment.bottomCenter,
onTap: _handleDismissLayerTap, child: GestureDetector(
), behavior: HitTestBehavior.opaque,
), onTap: () {},
Align( child: KeyedSubtree(
alignment: Alignment.bottomCenter, key: const Key('player-comments-sheet'),
child: GestureDetector( child: SizedBox(
behavior: HitTestBehavior.opaque, key: const Key('comment-sheet-constraints'),
onTap: () {}, height: sheetHeight,
child: KeyedSubtree( child: Container(
key: const Key('player-comments-sheet'), decoration: const BoxDecoration(
child: SizedBox( color: AppColors.card,
key: const Key('comment-sheet-constraints'), borderRadius: BorderRadius.vertical(
height: sheetHeight, top: Radius.circular(_sheetRadius),
child: Container( ),
decoration: const BoxDecoration( ),
color: AppColors.card, child: SafeArea(
borderRadius: BorderRadius.vertical( top: false,
top: Radius.circular(_sheetRadius), child: ListenableBuilder(
), listenable: _controller,
), builder: (context, _) {
child: SafeArea( return Padding(
top: false, padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListenableBuilder( child: _content(context),
listenable: _controller, );
builder: (context, _) { },
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: _content(context),
);
},
),
),
), ),
), ),
), ),
), ),
), ),
], ),
), ),
), ],
); );
} }
@@ -372,6 +354,44 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
} }
} }
class _CommentPostSuccessOverlay extends StatelessWidget {
const _CommentPostSuccessOverlay({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: IgnorePointer(
child: SafeArea(
child: Align(
alignment: const Alignment(0, 0.55),
child: Semantics(
liveRegion: true,
child: Container(
key: const Key('comment-post-success-snackbar'),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(8),
),
child: Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
),
),
),
),
);
}
}
class _CommentThread extends StatelessWidget { class _CommentThread extends StatelessWidget {
const _CommentThread({ const _CommentThread({
required this.comment, required this.comment,
+37 -13
View File
@@ -291,21 +291,32 @@ void main() {
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500));
expect(find.byKey(const Key('comment-composer-sheet')), findsNothing); expect(find.byKey(const Key('comment-composer-sheet')), findsNothing);
final successSnackBar = find.byKey( final successOverlay = find.byKey(
const Key('comment-post-success-snackbar'), const Key('comment-post-success-snackbar'),
); );
expect(successSnackBar, findsOneWidget); expect(successOverlay, findsOneWidget);
expect(find.text('发表成功'), findsOneWidget); expect(find.text('发表成功'), findsOneWidget);
final snackBar = tester.widget<SnackBar>(successSnackBar); expect(find.byType(SnackBar), findsNothing);
expect(snackBar.action, isNull); final capsule = tester.widget<Container>(successOverlay);
expect(snackBar.behavior, SnackBarBehavior.floating); final decoration = capsule.decoration! as BoxDecoration;
expect(snackBar.duration, const Duration(milliseconds: 1500)); expect(decoration.color, Colors.black.withValues(alpha: 0.82));
expect(decoration.borderRadius, BorderRadius.circular(8));
expect( expect(
tester.getRect(find.text('发表成功')).bottom, capsule.padding,
lessThanOrEqualTo( const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
tester.getRect(find.byKey(const Key('player-comments-sheet'))).top,
),
); );
final successText = tester.widget<Text>(find.text('发表成功'));
expect(successText.style?.color, Colors.white);
expect(successText.style?.fontSize, 14);
final placeholderRect = tester.getRect(
find.byKey(const Key('comment-input-placeholder')),
);
expect(
tester.getRect(successOverlay).bottom,
lessThan(placeholderRect.top),
);
await tester.pump(const Duration(milliseconds: 1200));
expect(successOverlay, findsNothing);
expect(find.byKey(const Key('comment-list')), findsOneWidget); expect(find.byKey(const Key('comment-list')), findsOneWidget);
expect(find.byKey(const Key('comment-count-label')), findsOneWidget); expect(find.byKey(const Key('comment-count-label')), findsOneWidget);
expect(find.byKey(const Key('comment-input-placeholder')), findsOneWidget); expect(find.byKey(const Key('comment-input-placeholder')), findsOneWidget);
@@ -341,6 +352,10 @@ void main() {
final composer = find.byKey(const Key('comment-composer-sheet')); final composer = find.byKey(const Key('comment-composer-sheet'));
final composerInput = find.byKey(const Key('comment-composer-input')); final composerInput = find.byKey(const Key('comment-composer-input'));
expect(composer, findsOneWidget); expect(composer, findsOneWidget);
expect(
ModalRoute.of(tester.element(composer))?.barrierColor,
Colors.transparent,
);
final compactHeight = tester.getSize(composer).height; final compactHeight = tester.getSize(composer).height;
expect(compactHeight, inInclusiveRange(100, 150)); expect(compactHeight, inInclusiveRange(100, 150));
expect(tester.widget<TextField>(composerInput).focusNode?.hasFocus, isTrue); expect(tester.widget<TextField>(composerInput).focusNode?.hasFocus, isTrue);
@@ -406,12 +421,21 @@ void main() {
await tester.tap(find.byKey(const Key('comment-input-placeholder'))); await tester.tap(find.byKey(const Key('comment-input-placeholder')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(sheet, findsOneWidget); expect(sheet, findsOneWidget);
expect(find.byKey(const Key('comment-composer-sheet')), findsOneWidget); final composer = find.byKey(const Key('comment-composer-sheet'));
expect(composer, findsOneWidget);
expect(
find.byKey(const Key('comment-composer-dismiss-layer')),
findsOneWidget,
);
await tester.tapAt(const Offset(20, 20)); final sheetRect = tester.getRect(sheet);
final composerRect = tester.getRect(composer);
await tester.tapAt(
Offset(sheetRect.center.dx, (sheetRect.top + composerRect.top) / 2),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(sheet, findsOneWidget); expect(sheet, findsOneWidget);
expect(find.byKey(const Key('comment-composer-sheet')), findsNothing); expect(composer, findsNothing);
await tester.tapAt(const Offset(20, 20)); await tester.tapAt(const Offset(20, 20));
await tester.pumpAndSettle(); await tester.pumpAndSettle();