From 349ea12404152e65cc21d6a9fe8298f93b801c34 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 18:46:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(comment):=20=E4=BF=AE=E5=A4=8D=20iOS=20?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E8=BE=93=E5=85=A5=E5=BC=B9=E7=AA=97=E9=94=AE?= =?UTF-8?q?=E7=9B=98=E9=81=BF=E8=AE=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 35 +++ .../widgets/comment_composer_sheet.dart | 249 +++++++++--------- test/comment/drama_comments_sheet_test.dart | 59 +++++ 3 files changed, 222 insertions(+), 121 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 94d26f2..d5b67db 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -33,3 +33,38 @@ Running Gradle task 'assembleDebug'... 24.8s 解读:静态分析零告警,553 项全量测试通过,debug APK 构建成功; `pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 + +## 2026-10-21 K97 修复 iOS 评论输入弹窗键盘避让 + +- 已完整读取 K97 `docs/plans/INBOX.md`。 +- 已从最新 `origin/develop` 创建 `fix/k97-ios-comment-keyboard`。 +- 计划见 FruitServer `docs/plans/2026-10-21-K97-ios-comment-keyboard.md`。 +- Android 保留既有 pinned/channel 策略;iOS 与其他平台改用实时 + `MediaQuery.viewInsets.bottom`,并补 iOS 键盘避让测试。 +- 已完成 `_isAndroid` 平台分流;Android 的 channel、80ms 防抖和 pinned 逻辑保持不变, + iOS/其他平台直接读取实时 viewInsets,超过屏幕 60% 的异常值按 0 处理。 +- 面板已使用 160ms `easeOutCubic` AnimatedPadding;Android 不包裹 SafeArea, + iOS/其他平台使用 `SafeArea(top: false)` 处理 home indicator。 +- 已补 iOS insets=300/0 测试,发表按钮在键盘上方;Android pinned 既有用例全部通过。 + +门禁原始输出: + +```text +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:54 +555: All tests passed! + +$ flutter build apk --debug +Running Gradle task 'assembleDebug'... 26.2s +✓ Built build/app/outputs/flutter-apk/app-debug.apk + +$ flutter build ios --simulator --no-codesign +Xcode build done. 44.0s +✓ Built build/ios/iphonesimulator/Runner.app +``` + +解读:静态分析零告警,555 项全量测试通过,Android 与 iOS simulator 构建成功; +`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 diff --git a/lib/pages/player/widgets/comment_composer_sheet.dart b/lib/pages/player/widgets/comment_composer_sheet.dart index 1b32703..8e7ea04 100644 --- a/lib/pages/player/widgets/comment_composer_sheet.dart +++ b/lib/pages/player/widgets/comment_composer_sheet.dart @@ -3,6 +3,7 @@ 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'; @@ -54,6 +55,9 @@ class _CommentComposerSheetState extends State { bool _useChannelInset = false; bool _keyboardVisible = false; + bool get _isAndroid => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + @override void initState() { super.initState(); @@ -66,6 +70,7 @@ class _CommentComposerSheetState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + if (!_isAndroid) return; if (!_pinnedInitialized) { _pinnedInitialized = true; _pinnedKeyboardInset = @@ -75,10 +80,12 @@ class _CommentComposerSheetState extends State { } Future _prepareInput() async { - await _keyboardInset.start(); - if (!mounted) return; - _insetBaseline = _keyboardInset.value; - _insetReady = true; + if (_isAndroid) { + await _keyboardInset.start(); + if (!mounted) return; + _insetBaseline = _keyboardInset.value; + _insetReady = true; + } WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _inputFocus.requestFocus(); @@ -112,7 +119,7 @@ class _CommentComposerSheetState extends State { } void _onKeyboardInsetChanged() { - if (!mounted || !_insetReady) return; + if (!_isAndroid || !mounted || !_insetReady) return; if (_keyboardInset.value != _insetBaseline) { _useChannelInset = true; } @@ -121,6 +128,9 @@ class _CommentComposerSheetState extends State { 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; @@ -134,6 +144,7 @@ class _CommentComposerSheetState extends State { } void _trackKeyboardInset(double inset) { + if (!_isAndroid) return; final visible = inset > 0; if (visible == _keyboardVisible) return; _keyboardVisible = visible; @@ -147,6 +158,7 @@ class _CommentComposerSheetState extends State { } void _captureSettledKeyboardInset() { + if (!_isAndroid) return; _keyboardSettleTimer = null; if (!mounted) return; final inset = _keyboardHeight(context); @@ -193,6 +205,109 @@ class _CommentComposerSheetState extends State { final hasContent = _input.text.trim().isNotEmpty; final canPost = signedIn && 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: !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, + ), + ), + ), + ), + ), + ], + ), + ), + ); return FocusScope( key: const Key('comment-composer-focus-scope'), @@ -222,122 +337,14 @@ class _CommentComposerSheetState extends State { borderRadius: const BorderRadius.vertical( top: Radius.circular(_sheetRadius), ), - child: SafeArea( - top: false, - bottom: 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, - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), + 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), ), ), ), diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index b99cb7d..fdc9c1c 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -88,6 +88,53 @@ void main() { }); }); + testWidgets('should_follow_live_view_insets_on_ios', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + final repository = FakeCommentRepository(pages: [const CommentPage()]); + + await _pumpSheet(tester, repository: repository); + await tester.tap(find.byKey(const Key('comment-input-placeholder'))); + await tester.pumpAndSettle(); + + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 160)); + + final animatedPadding = tester.widget( + find.byKey(const Key('comment-composer-keyboard-padding')), + ); + expect((animatedPadding.padding as EdgeInsets).bottom, 300); + expect( + tester.getBottomLeft(find.byKey(const Key('comment-composer-post'))).dy, + lessThanOrEqualTo(844 - 300), + ); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('should_use_zero_live_inset_when_ios_keyboard_is_hidden', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final repository = FakeCommentRepository(pages: [const CommentPage()]); + + await _pumpSheet(tester, repository: repository); + await tester.tap(find.byKey(const Key('comment-input-placeholder'))); + await tester.pumpAndSettle(); + + final animatedPadding = tester.widget( + find.byKey(const Key('comment-composer-keyboard-padding')), + ); + expect((animatedPadding.padding as EdgeInsets).bottom, 0); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('should_gate_guest_comment_input_without_focus', (tester) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); @@ -402,6 +449,8 @@ void main() { testWidgets('should_keep_comment_panel_fixed_and_open_composer_sheet', ( tester, ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -462,6 +511,7 @@ void main() { tester.view.viewInsets = const FakeViewPadding(bottom: 180); await tester.pump(); + await tester.pump(const Duration(milliseconds: 90)); await tester.pump(const Duration(milliseconds: 200)); final keyboardUpInputTop = tester.getTopLeft(composer).dy; final keyboardUpBottom = tester.getBottomLeft(composer).dy; @@ -497,11 +547,14 @@ void main() { await tester.pumpAndSettle(); expect(composer, findsOneWidget); expect(tester.getBottomLeft(composer).dy, closeTo(844, 0.1)); + debugDefaultTargetPlatformOverride = null; }); testWidgets( 'should_keep_composer_input_position_when_safe_area_padding_changes', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; tester.view.padding = const FakeViewPadding(); @@ -525,6 +578,7 @@ void main() { tester.view.viewInsets = const FakeViewPadding(bottom: 180); await tester.pump(); await tester.pump(const Duration(milliseconds: 90)); + await tester.pump(const Duration(milliseconds: 200)); final keyboardUpInputRect = tester.getRect(input); tester.view.viewInsets = const FakeViewPadding(); @@ -544,12 +598,15 @@ void main() { keyboardClosedInputRect.height, closeTo(keyboardUpInputRect.height, 0.1), ); + debugDefaultTargetPlatformOverride = null; }, ); testWidgets( 'should_keep_composer_position_when_keyboard_insets_change_after_settling', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); tester.view.physicalSize = const Size(390, 2200); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -573,6 +630,7 @@ void main() { tester.view.viewInsets = const FakeViewPadding(bottom: 1100); await tester.pump(); await tester.pump(const Duration(milliseconds: 90)); + await tester.pump(const Duration(milliseconds: 200)); final settledTop = tester.getTopLeft(composer).dy; final settledBottom = tester.getBottomLeft(composer).dy; final settledHeight = tester.getSize(composer).height; @@ -598,6 +656,7 @@ void main() { expect(tester.getTopLeft(composer).dy, closeTo(settledTop, 0.1)); expect(tester.getBottomLeft(composer).dy, closeTo(settledBottom, 0.1)); expect(tester.getSize(composer).height, closeTo(settledHeight, 0.1)); + debugDefaultTargetPlatformOverride = null; }, );