From 6c2f77926b7a484b77676223b52184ca08f8dca7 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 19:26:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E7=99=BB=E5=BD=95=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E4=B8=8E=E6=89=8B=E6=9C=BA=E5=8F=B7=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=94=AE=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 | 31 +++++++++ lib/core/platform/keyboard_inset.dart | 21 ++++++ lib/pages/auth/auth_view.dart | 77 +++++++++++++++++++++- lib/pages/auth/phone_auth_sheet.dart | 93 ++++++++++++++++++++++++--- test/auth/auth_page_test.dart | 86 +++++++++++++++++++++++++ test/auth/phone_auth_sheet_test.dart | 33 ++++++++++ 6 files changed, 332 insertions(+), 9 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 04af67f..890e1f0 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -92,3 +92,34 @@ Running Gradle task 'assembleDebug'... 25.8s 解读:静态分析零告警,555 项全量测试通过,debug APK 构建成功; `pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 + +## 2026-10-21 K98 修复登录与手机号登录键盘遮挡 + +- 已完整读取 K98 `docs/plans/INBOX.md`。 +- 已从最新 `origin/develop` 创建 `fix/k98-auth-keyboard`。 +- 计划见 FruitServer `docs/plans/2026-10-21-K98-auth-keyboard.md`。 +- 将统一 viewInsets/channel 有效键盘高度,并给登录页与手机号弹窗增加 + 焦点/键盘变化后的字段滚动校正。 +- 已新增 `effectiveKeyboardInset`,兼容 iOS 实时 viewInsets 与 Android + `adjustNothing` 下的 MethodChannel 键盘高度。 +- 登录/注册页已增加 ScrollController、notifier 监听和邮箱/密码焦点校正; + 手机号弹窗已同样接入手机号/验证码框滚动校正。 +- 已补 iOS 登录/注册、Android channel、手机号验证码框键盘避让测试。 + +门禁原始输出: + +```text +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:52 +559: All tests passed! + +$ flutter build apk --debug +Running Gradle task 'assembleDebug'... 26.2s +✓ Built build/app/outputs/flutter-apk/app-debug.apk +``` + +解读:静态分析零告警,559 项全量测试通过,debug APK 构建成功; +`pubspec.lock` 已还原,无依赖变更,未安装或操作真机。 diff --git a/lib/core/platform/keyboard_inset.dart b/lib/core/platform/keyboard_inset.dart index dfa0783..7e77c3a 100644 --- a/lib/core/platform/keyboard_inset.dart +++ b/lib/core/platform/keyboard_inset.dart @@ -1,8 +1,29 @@ // Author: fruit.com.cn +import 'dart:math' as math; + import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +/// iOS 使用实时 viewInsets;Android adjustNothing 下取 MethodChannel 最大值。 +double effectiveKeyboardInset( + BuildContext context, + KeyboardInsetNotifier notifier, { + double maxRatio = 0.6, +}) { + final media = MediaQuery.of(context); + final viewInset = media.viewInsets.bottom; + final channelInset = + !kIsWeb && defaultTargetPlatform == TargetPlatform.android + ? notifier.value / media.devicePixelRatio + : 0.0; + final inset = math.max(viewInset, channelInset); + final maxInset = media.size.height * maxRatio; + if (!inset.isFinite || inset > maxInset) return 0; + return math.max(0, inset); +} + /// Provides the Android IME height even when `adjustNothing` keeps /// `MediaQuery.viewInsets` at zero. class KeyboardInsetNotifier extends ChangeNotifier { diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 03d8505..59b38b2 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../../core/data/remote/api_client.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/platform/haptics.dart'; +import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; @@ -101,6 +102,9 @@ class _AuthPageState extends State { late final TextEditingController _passwordController; late final FocusNode _emailFocus; late final FocusNode _passwordFocus; + late final ScrollController _scrollController; + final _keyboardInset = KeyboardInsetNotifier.instance; + double _lastKeyboardInset = 0; bool _obscurePassword = true; bool _emailEntryOpen = false; @@ -112,17 +116,87 @@ class _AuthPageState extends State { _passwordController = TextEditingController(); _emailFocus = FocusNode(debugLabel: 'auth-email-focus'); _passwordFocus = FocusNode(debugLabel: 'auth-password-focus'); + _scrollController = ScrollController(); + _emailFocus.addListener(_onFocusChanged); + _passwordFocus.addListener(_onFocusChanged); + _keyboardInset.addListener(_onKeyboardInsetChanged); + unawaited(_keyboardInset.start()); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final inset = effectiveKeyboardInset(context, _keyboardInset); + if ((inset - _lastKeyboardInset).abs() >= 1) { + _lastKeyboardInset = inset; + _scheduleRevealFocusedField(); + } } @override void dispose() { + _keyboardInset.removeListener(_onKeyboardInsetChanged); + _emailFocus.removeListener(_onFocusChanged); + _passwordFocus.removeListener(_onFocusChanged); _emailController.dispose(); _passwordController.dispose(); _emailFocus.dispose(); _passwordFocus.dispose(); + _scrollController.dispose(); super.dispose(); } + void _onKeyboardInsetChanged() { + if (!mounted) return; + setState(() {}); + _scheduleRevealFocusedField(); + } + + void _onFocusChanged() { + if (_emailFocus.hasFocus || _passwordFocus.hasFocus) { + _scheduleRevealFocusedField(); + } + } + + void _scheduleRevealFocusedField() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _revealFocusedField(); + }); + } + + void _revealFocusedField() { + if (!_scrollController.hasClients) return; + final focus = _passwordFocus.hasFocus + ? _passwordFocus + : _emailFocus.hasFocus + ? _emailFocus + : null; + final fieldContext = focus?.context; + final renderObject = fieldContext?.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) return; + final keyboardInset = effectiveKeyboardInset(context, _keyboardInset); + if (keyboardInset <= 0) return; + final fieldBottom = renderObject + .localToGlobal(Offset(0, renderObject.size.height)) + .dy; + final visibleBottom = + MediaQuery.sizeOf(context).height - keyboardInset - 24; + final overlap = fieldBottom - visibleBottom; + if (overlap <= 0) return; + final position = _scrollController.position; + final target = (position.pixels + overlap) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + if (target <= position.pixels + 0.5) return; + unawaited( + _scrollController.animateTo( + target, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ), + ); + } + String _errorText(AppLocalizations l10n, AuthFieldError error) { switch (error) { case AuthFieldError.emailRequired: @@ -246,12 +320,13 @@ class _AuthPageState extends State { : _topContentPadding; final bottom = _bottomContentPadding + - MediaQuery.viewInsetsOf(context).bottom; + effectiveKeyboardInset(context, _keyboardInset); final available = constraints.maxHeight - top - bottom; final minHeight = available > 0 ? available : 0.0; return AutofillGroup( child: SingleChildScrollView( key: const Key('auth-scroll'), + controller: _scrollController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: EdgeInsets.fromLTRB( diff --git a/lib/pages/auth/phone_auth_sheet.dart b/lib/pages/auth/phone_auth_sheet.dart index 541e830..c37f6c7 100644 --- a/lib/pages/auth/phone_auth_sheet.dart +++ b/lib/pages/auth/phone_auth_sheet.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import '../../core/data/remote/api_client.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/platform/haptics.dart'; +import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; import '../../l10n/app_localizations.dart'; @@ -46,7 +47,10 @@ class _PhoneAuthSheetState extends State { late final TextEditingController _code; late final FocusNode _phoneFocus; late final FocusNode _codeFocus; + late final ScrollController _scrollController; + final _keyboardInset = KeyboardInsetNotifier.instance; Timer? _countdown; + double _lastKeyboardInset = 0; bool _signUp = false; bool _sending = false; bool _submitting = false; @@ -61,18 +65,88 @@ class _PhoneAuthSheetState extends State { _code = TextEditingController(); _phoneFocus = FocusNode(debugLabel: 'phone-auth-number'); _codeFocus = FocusNode(debugLabel: 'phone-auth-code'); + _scrollController = ScrollController(); + _phoneFocus.addListener(_onFocusChanged); + _codeFocus.addListener(_onFocusChanged); + _keyboardInset.addListener(_onKeyboardInsetChanged); + unawaited(_keyboardInset.start()); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final inset = effectiveKeyboardInset(context, _keyboardInset); + if ((inset - _lastKeyboardInset).abs() >= 1) { + _lastKeyboardInset = inset; + _scheduleRevealFocusedField(); + } } @override void dispose() { + _keyboardInset.removeListener(_onKeyboardInsetChanged); + _phoneFocus.removeListener(_onFocusChanged); + _codeFocus.removeListener(_onFocusChanged); _countdown?.cancel(); _phone.dispose(); _code.dispose(); _phoneFocus.dispose(); _codeFocus.dispose(); + _scrollController.dispose(); super.dispose(); } + void _onKeyboardInsetChanged() { + if (!mounted) return; + setState(() {}); + _scheduleRevealFocusedField(); + } + + void _onFocusChanged() { + if (_phoneFocus.hasFocus || _codeFocus.hasFocus) { + _scheduleRevealFocusedField(); + } + } + + void _scheduleRevealFocusedField() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _revealFocusedField(); + }); + } + + void _revealFocusedField() { + if (!_scrollController.hasClients) return; + final focus = _codeFocus.hasFocus + ? _codeFocus + : _phoneFocus.hasFocus + ? _phoneFocus + : null; + final fieldContext = focus?.context; + final renderObject = fieldContext?.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) return; + final keyboardInset = effectiveKeyboardInset(context, _keyboardInset); + if (keyboardInset <= 0) return; + final fieldBottom = renderObject + .localToGlobal(Offset(0, renderObject.size.height)) + .dy; + final visibleBottom = + MediaQuery.sizeOf(context).height - keyboardInset - 24; + final overlap = fieldBottom - visibleBottom; + if (overlap <= 0) return; + final position = _scrollController.position; + final target = (position.pixels + overlap) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + if (target <= position.pixels + 0.5) return; + unawaited( + _scrollController.animateTo( + target, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ), + ); + } + String get _target => _phone.text.replaceAll(RegExp(r'[\s-]'), ''); bool get _phoneValid => _target.length >= _phoneMinLength; @@ -99,9 +173,8 @@ class _PhoneAuthSheetState extends State { if (!mounted) return; _startCountdown(); setState(() => _error = null); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.authCodeSent))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(l10n.authCodeSent))); _codeFocus.requestFocus(); } on ApiException catch (error) { if (mounted) setState(() => _error = error.message); @@ -166,16 +239,19 @@ class _PhoneAuthSheetState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final bottom = MediaQuery.viewInsetsOf(context).bottom; + final bottom = effectiveKeyboardInset(context, _keyboardInset); return Padding( padding: EdgeInsets.only(bottom: bottom), child: Container( key: const Key('phone-auth-sheet'), decoration: const BoxDecoration( color: AppColors.card, - borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)), + borderRadius: BorderRadius.vertical( + top: Radius.circular(_sheetRadius), + ), ), child: SingleChildScrollView( + controller: _scrollController, padding: const EdgeInsets.fromLTRB(22, 10, 22, 26), child: Column( mainAxisSize: MainAxisSize.min, @@ -278,9 +354,10 @@ class _PhoneAuthSheetState extends State { onSubmitted: (_) => _submit(), onChanged: (_) => setState(() => _error = null), style: const TextStyle(color: AppColors.onSurface, fontSize: 15), - decoration: _decoration(l10n.authCodeLabel, l10n.authCodeHint).copyWith( - suffixIcon: _sendButton(l10n), - ), + decoration: _decoration( + l10n.authCodeLabel, + l10n.authCodeHint, + ).copyWith(suffixIcon: _sendButton(l10n)), ), ); diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index 3a5851c..1e02e3c 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; @@ -263,6 +265,90 @@ void main() { expect(tester.takeException(), isNull); }, ); + + testWidgets('should_keep_password_above_ios_keyboard', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + + await pumpAuthPage(tester); + await tester.tap(find.byKey(const Key('auth-password'))); + await tester.pumpAndSettle(); + + _expectAboveKeyboard(tester, const Key('auth-password'), 300); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('should_keep_signup_password_above_ios_keyboard', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + + await pumpAuthPage(tester, signUp: true); + await tester.tap(find.byKey(const Key('auth-password'))); + await tester.pumpAndSettle(); + + _expectAboveKeyboard(tester, const Key('auth-password'), 300); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('should_keep_password_above_android_channel_keyboard', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const keyboardChannel = MethodChannel('com.stormg.lumadrama/keyboard'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + keyboardChannel, + (call) async => call.method == 'get' ? 300 : null, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + keyboardChannel, + null, + ); + }); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.viewInsets = const FakeViewPadding(); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + + await pumpAuthPage(tester); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + keyboardChannel.name, + const StandardMethodCodec().encodeMethodCall( + const MethodCall('insetsChanged', 300), + ), + (_) {}, + ); + await tester.pump(); + await tester.tap(find.byKey(const Key('auth-password'))); + await tester.pumpAndSettle(); + + _expectAboveKeyboard(tester, const Key('auth-password'), 300); + debugDefaultTargetPlatformOverride = null; + }); +} + +void _expectAboveKeyboard(WidgetTester tester, Key fieldKey, double inset) { + final screenHeight = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect( + tester.getBottomLeft(find.byKey(fieldKey)).dy, + lessThanOrEqualTo(screenHeight - inset), + ); } class _PendingAuthApi extends AuthApi { diff --git a/test/auth/phone_auth_sheet_test.dart b/test/auth/phone_auth_sheet_test.dart index 9656a31..036b286 100644 --- a/test/auth/phone_auth_sheet_test.dart +++ b/test/auth/phone_auth_sheet_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; @@ -97,6 +98,38 @@ void main() { expect(find.text('Enter a valid phone number'), findsOneWidget); expect(adapter.requests, isEmpty); }); + + testWidgets('should_keep_code_above_ios_keyboard', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + jsonEncode({'code': 0, 'message': 'ok', 'data': null}), + ), + ); + final auth = AuthController( + AuthApi(ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter)), + AuthSessionStore(), + ); + await _pumpHost(tester, auth); + await tester.tap(find.byKey(const Key('open-phone-sheet'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('auth-phone-code'))); + await tester.pumpAndSettle(); + + expect( + tester.getBottomLeft(find.byKey(const Key('auth-phone-code'))).dy, + lessThanOrEqualTo(844 - 300), + ); + debugDefaultTargetPlatformOverride = null; + }); } Future _pumpHost(WidgetTester tester, AuthController auth) {