merge: 合并 K97 iOS 评论键盘避让

This commit is contained in:
Zhou Weidong committed 2026-10-21 18:47:04 +08:00
commit 76f347cc35
3 files changed
+152 -51

No files matched your search

+35
View File
@@ -33,3 +33,38 @@ Running Gradle task 'assembleDebug'... 24.8s
解读:静态分析零告警,553 项全量测试通过,debug APK 构建成功; 解读:静态分析零告警,553 项全量测试通过,debug APK 构建成功;
`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 `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` 已还原,无依赖变更,未安装或操作真机。
@@ -3,6 +3,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -54,6 +55,9 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
bool _useChannelInset = false; bool _useChannelInset = false;
bool _keyboardVisible = false; bool _keyboardVisible = false;
bool get _isAndroid =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -66,6 +70,7 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
if (!_isAndroid) return;
if (!_pinnedInitialized) { if (!_pinnedInitialized) {
_pinnedInitialized = true; _pinnedInitialized = true;
_pinnedKeyboardInset = _pinnedKeyboardInset =
@@ -75,10 +80,12 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
} }
Future<void> _prepareInput() async { Future<void> _prepareInput() async {
if (_isAndroid) {
await _keyboardInset.start(); await _keyboardInset.start();
if (!mounted) return; if (!mounted) return;
_insetBaseline = _keyboardInset.value; _insetBaseline = _keyboardInset.value;
_insetReady = true; _insetReady = true;
}
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
_inputFocus.requestFocus(); _inputFocus.requestFocus();
@@ -112,7 +119,7 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
} }
void _onKeyboardInsetChanged() { void _onKeyboardInsetChanged() {
if (!mounted || !_insetReady) return; if (!_isAndroid || !mounted || !_insetReady) return;
if (_keyboardInset.value != _insetBaseline) { if (_keyboardInset.value != _insetBaseline) {
_useChannelInset = true; _useChannelInset = true;
} }
@@ -121,6 +128,9 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
double _keyboardHeight(BuildContext context) { double _keyboardHeight(BuildContext context) {
final viewInset = MediaQuery.viewInsetsOf(context).bottom; final viewInset = MediaQuery.viewInsetsOf(context).bottom;
if (!_isAndroid) {
return _normalizeKeyboardInset(viewInset) ?? 0;
}
final channelInset = _useChannelInset final channelInset = _useChannelInset
? _keyboardInset.value / MediaQuery.devicePixelRatioOf(context) ? _keyboardInset.value / MediaQuery.devicePixelRatioOf(context)
: 0.0; : 0.0;
@@ -134,6 +144,7 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
} }
void _trackKeyboardInset(double inset) { void _trackKeyboardInset(double inset) {
if (!_isAndroid) return;
final visible = inset > 0; final visible = inset > 0;
if (visible == _keyboardVisible) return; if (visible == _keyboardVisible) return;
_keyboardVisible = visible; _keyboardVisible = visible;
@@ -147,6 +158,7 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
} }
void _captureSettledKeyboardInset() { void _captureSettledKeyboardInset() {
if (!_isAndroid) return;
_keyboardSettleTimer = null; _keyboardSettleTimer = null;
if (!mounted) return; if (!mounted) return;
final inset = _keyboardHeight(context); final inset = _keyboardHeight(context);
@@ -193,41 +205,11 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
final hasContent = _input.text.trim().isNotEmpty; final hasContent = _input.text.trim().isNotEmpty;
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');
final bottomInset = _isAndroid
? _pinnedKeyboardInset
: _normalizeKeyboardInset(MediaQuery.viewInsetsOf(context).bottom) ?? 0;
return FocusScope( final content = Padding(
key: const Key('comment-composer-focus-scope'),
autofocus: true,
child: 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,
bottom: false,
child: Padding(
padding: EdgeInsets.only(bottom: _pinnedKeyboardInset),
child: Padding(
key: const Key('comment-composer-sheet'), key: const Key('comment-composer-sheet'),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: TextFieldTapRegion( child: TextFieldTapRegion(
@@ -244,18 +226,13 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
? l10n.anonymousUser ? l10n.anonymousUser
: widget.replyTo!.nickname, : widget.replyTo!.nickname,
), ),
style: const TextStyle( style: const TextStyle(color: AppColors.muted, fontSize: 12),
color: AppColors.muted,
fontSize: 12,
),
), ),
), ),
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.commentInput, color: AppColors.commentInput,
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(_inputRadius),
_inputRadius,
),
), ),
child: TextField( child: TextField(
key: const Key('comment-composer-input'), key: const Key('comment-composer-input'),
@@ -304,15 +281,9 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
key: const Key('comment-composer-post'), key: const Key('comment-composer-post'),
onPressed: canPost ? _submit : null, onPressed: canPost ? _submit : null,
style: TextButton.styleFrom( style: TextButton.styleFrom(
minimumSize: const Size( minimumSize: const Size(0, _postButtonHeight),
0, padding: const EdgeInsets.symmetric(horizontal: 16),
_postButtonHeight, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
backgroundColor: hasContent backgroundColor: hasContent
? AppColors.blue.withValues(alpha: 0.12) ? AppColors.blue.withValues(alpha: 0.12)
: AppColors.raised, : AppColors.raised,
@@ -336,8 +307,44 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
], ],
), ),
), ),
);
return FocusScope(
key: const Key('comment-composer-focus-scope'),
autofocus: true,
child: 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: 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),
), ),
), ),
), ),
@@ -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<AnimatedPadding>(
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<AnimatedPadding>(
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 { testWidgets('should_gate_guest_comment_input_without_focus', (tester) async {
SharedPreferencesAsyncPlatform.instance = SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty(); InMemorySharedPreferencesAsync.empty();
@@ -402,6 +449,8 @@ void main() {
testWidgets('should_keep_comment_panel_fixed_and_open_composer_sheet', ( testWidgets('should_keep_comment_panel_fixed_and_open_composer_sheet', (
tester, tester,
) async { ) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
@@ -462,6 +511,7 @@ void main() {
tester.view.viewInsets = const FakeViewPadding(bottom: 180); tester.view.viewInsets = const FakeViewPadding(bottom: 180);
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 200));
final keyboardUpInputTop = tester.getTopLeft(composer).dy; final keyboardUpInputTop = tester.getTopLeft(composer).dy;
final keyboardUpBottom = tester.getBottomLeft(composer).dy; final keyboardUpBottom = tester.getBottomLeft(composer).dy;
@@ -497,11 +547,14 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(composer, findsOneWidget); expect(composer, findsOneWidget);
expect(tester.getBottomLeft(composer).dy, closeTo(844, 0.1)); expect(tester.getBottomLeft(composer).dy, closeTo(844, 0.1));
debugDefaultTargetPlatformOverride = null;
}); });
testWidgets( testWidgets(
'should_keep_composer_input_position_when_safe_area_padding_changes', 'should_keep_composer_input_position_when_safe_area_padding_changes',
(tester) async { (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
tester.view.physicalSize = const Size(390, 844); tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
tester.view.padding = const FakeViewPadding(); tester.view.padding = const FakeViewPadding();
@@ -525,6 +578,7 @@ void main() {
tester.view.viewInsets = const FakeViewPadding(bottom: 180); tester.view.viewInsets = const FakeViewPadding(bottom: 180);
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 90)); await tester.pump(const Duration(milliseconds: 90));
await tester.pump(const Duration(milliseconds: 200));
final keyboardUpInputRect = tester.getRect(input); final keyboardUpInputRect = tester.getRect(input);
tester.view.viewInsets = const FakeViewPadding(); tester.view.viewInsets = const FakeViewPadding();
@@ -544,12 +598,15 @@ void main() {
keyboardClosedInputRect.height, keyboardClosedInputRect.height,
closeTo(keyboardUpInputRect.height, 0.1), closeTo(keyboardUpInputRect.height, 0.1),
); );
debugDefaultTargetPlatformOverride = null;
}, },
); );
testWidgets( testWidgets(
'should_keep_composer_position_when_keyboard_insets_change_after_settling', 'should_keep_composer_position_when_keyboard_insets_change_after_settling',
(tester) async { (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
tester.view.physicalSize = const Size(390, 2200); tester.view.physicalSize = const Size(390, 2200);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
@@ -573,6 +630,7 @@ void main() {
tester.view.viewInsets = const FakeViewPadding(bottom: 1100); tester.view.viewInsets = const FakeViewPadding(bottom: 1100);
await tester.pump(); await tester.pump();
await tester.pump(const Duration(milliseconds: 90)); await tester.pump(const Duration(milliseconds: 90));
await tester.pump(const Duration(milliseconds: 200));
final settledTop = tester.getTopLeft(composer).dy; final settledTop = tester.getTopLeft(composer).dy;
final settledBottom = tester.getBottomLeft(composer).dy; final settledBottom = tester.getBottomLeft(composer).dy;
final settledHeight = tester.getSize(composer).height; final settledHeight = tester.getSize(composer).height;
@@ -598,6 +656,7 @@ void main() {
expect(tester.getTopLeft(composer).dy, closeTo(settledTop, 0.1)); expect(tester.getTopLeft(composer).dy, closeTo(settledTop, 0.1));
expect(tester.getBottomLeft(composer).dy, closeTo(settledBottom, 0.1)); expect(tester.getBottomLeft(composer).dy, closeTo(settledBottom, 0.1));
expect(tester.getSize(composer).height, closeTo(settledHeight, 0.1)); expect(tester.getSize(composer).height, closeTo(settledHeight, 0.1));
debugDefaultTargetPlatformOverride = null;
}, },
); );