merge: 合并 K97 iOS 评论键盘避让
This commit is contained in:
commit
76f347cc35
3 files changed
+222
-121
No files matched your search
@@ -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` 已还原,无依赖变更,未安装或操作真机。
|
||||
@@ -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<CommentComposerSheet> {
|
||||
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<CommentComposerSheet> {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_isAndroid) return;
|
||||
if (!_pinnedInitialized) {
|
||||
_pinnedInitialized = true;
|
||||
_pinnedKeyboardInset =
|
||||
@@ -75,10 +80,12 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
|
||||
}
|
||||
|
||||
Future<void> _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<CommentComposerSheet> {
|
||||
}
|
||||
|
||||
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<CommentComposerSheet> {
|
||||
|
||||
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<CommentComposerSheet> {
|
||||
}
|
||||
|
||||
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<CommentComposerSheet> {
|
||||
}
|
||||
|
||||
void _captureSettledKeyboardInset() {
|
||||
if (!_isAndroid) return;
|
||||
_keyboardSettleTimer = null;
|
||||
if (!mounted) return;
|
||||
final inset = _keyboardHeight(context);
|
||||
@@ -193,6 +205,109 @@ class _CommentComposerSheetState extends State<CommentComposerSheet> {
|
||||
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<CommentComposerSheet> {
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
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;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user