From 67670d46b1c9f459c02834343bb045e9a547f67b Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Fri, 13 Nov 2026 12:30:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=8E=A5=E5=85=A5=20Google=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 登录页保留邮箱与 Google 两个入口,Google 按钮按官方规范改为白底四色图标 - 新增 GoogleAuthService:封装 initialize/authenticate,失败以结果对象返回 - AuthApi 增加 googleLogin,AuthController 增加 signInWithGoogle - iOS 补充 CFBundleURLTypes 与 GIDClientID,Bundle ID 统一为 com.stormg.lumadrama - Google 请求期间按钮显示转圈并禁止重复点击 --- assets/icon/google_g.png | Bin 0 -> 9844 bytes ios/Runner.xcodeproj/project.pbxproj | 6 +- ios/Runner/Info.plist | 13 ++ lib/core/data/remote/auth_api.dart | 23 ++++ lib/core/data/services/auth_controller.dart | 11 ++ .../data/services/google_auth_service.dart | 97 +++++++++++++++ lib/l10n/app_en.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_id.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/l10n/app_zh.arb | 1 + lib/pages/auth/auth_view.dart | 115 ++++++++++++++---- pubspec.lock | 58 ++++++++- pubspec.yaml | 1 + test/auth/auth_landing_test.dart | 11 +- test/auth/auth_visual_test.dart | 37 +++--- 18 files changed, 335 insertions(+), 55 deletions(-) create mode 100644 assets/icon/google_g.png create mode 100644 lib/core/data/services/google_auth_service.dart diff --git a/assets/icon/google_g.png b/assets/icon/google_g.png new file mode 100644 index 0000000000000000000000000000000000000000..8d17e486fa41de2c12b6e5d887ae787754bf504e GIT binary patch literal 9844 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 literal 0 HcmV?d00001 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 7609503..233b1fe 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -387,7 +387,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadramv; + PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadrama; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -567,7 +567,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadramv; + PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadrama; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -590,7 +590,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadramv; + PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadrama; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 25c3834..4c7589a 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -24,6 +24,19 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.11115008580-51gv4sj2c78rud6n127g3shu4d2n96un + + + + GIDClientID + 11115008580-51gv4sj2c78rud6n127g3shu4d2n96un.apps.googleusercontent.com LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/lib/core/data/remote/auth_api.dart b/lib/core/data/remote/auth_api.dart index 603e181..e15e53e 100644 --- a/lib/core/data/remote/auth_api.dart +++ b/lib/core/data/remote/auth_api.dart @@ -125,6 +125,29 @@ class AuthApi { return AuthSession.fromJson((data as Map).cast()); } + /// Google 登录:把客户端拿到的 idToken 交给后端校验并换发令牌。 + /// + /// 账号按 Google 返回的邮箱归并:邮箱已存在则直接登入,不存在则自动建号。 + Future googleLogin({ + required String idToken, + required String deviceId, + String platform = '', + String osVersion = '', + String appVersion = '', + }) async { + final data = await _client.post( + '/app/auth/google', + body: { + 'idToken': idToken, + 'deviceId': deviceId, + 'platform': platform, + 'osVersion': osVersion, + 'appVersion': appVersion, + }, + ); + return AuthSession.fromJson((data as Map).cast()); + } + Future refresh( String refreshToken, { String platform = '', diff --git a/lib/core/data/services/auth_controller.dart b/lib/core/data/services/auth_controller.dart index 12ed578..442b497 100644 --- a/lib/core/data/services/auth_controller.dart +++ b/lib/core/data/services/auth_controller.dart @@ -103,6 +103,17 @@ class AuthController extends ChangeNotifier { await _apply(session); } + /// Google 登录:把 Google 返回的 idToken 交给后端校验并落登录态。 + Future signInWithGoogle(String idToken) async { + final session = await _api.googleLogin( + idToken: idToken, + deviceId: await _store.deviceId(), + platform: _platform, + osVersion: _osVersion, + ); + await _apply(session); + } + Future clear() async { _session = null; await _store.clear(); diff --git a/lib/core/data/services/google_auth_service.dart b/lib/core/data/services/google_auth_service.dart new file mode 100644 index 0000000..aaaa5e6 --- /dev/null +++ b/lib/core/data/services/google_auth_service.dart @@ -0,0 +1,97 @@ +// Author: fruit.com.cn + +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:google_sign_in/google_sign_in.dart'; + +/// Google 授权结局。 +/// +/// 插件调用与 IO 一样用结果对象返回,不把异常抛到页面: +/// 页面只需要区分"成功 / 用户取消 / 不可用 / 失败"四种情况。 +class GoogleSignInOutcome { + const GoogleSignInOutcome._({ + this.idToken, + this.canceled = false, + this.unavailable = false, + this.failed = false, + }); + + /// 成功拿到可交给后端校验的 idToken。 + const GoogleSignInOutcome.success(String idToken) : this._(idToken: idToken); + + /// 用户主动取消授权,页面不应提示错误。 + const GoogleSignInOutcome.canceled() : this._(canceled: true); + + /// 当前平台或构建不支持交互式登录。 + const GoogleSignInOutcome.unavailable() : this._(unavailable: true); + + /// 授权过程失败(网络、配置缺失、Google 拒绝等)。 + const GoogleSignInOutcome.failed() : this._(failed: true); + + /// Google 返回的 idToken;未成功时为 null。 + final String? idToken; + + /// 用户是否主动取消。 + final bool canceled; + + /// 平台是否不支持。 + final bool unavailable; + + /// 是否失败。 + final bool failed; + + /// 是否拿到了可用的 idToken。 + bool get isSuccess => idToken != null && idToken!.isNotEmpty; +} + +/// Google 登录客户端封装。 +/// +/// 只负责拉起 Google 授权并取出 idToken;换发本应用令牌由 AuthController 完成, +/// 插件调用与网络层各自独立,页面通过 [GoogleSignInOutcome] 判断结局。 +class GoogleAuthService { + GoogleAuthService({GoogleSignIn? signIn}) + : _signIn = signIn ?? GoogleSignIn.instance; + + /// Android 必须显式传 serverClientId 才会返回 idToken,且要传 Web 客户端 id; + /// iOS 由 Info.plist 的 GIDClientID 决定,无需再传。 + static const String _serverClientId = + '11115008580-dbqe47fhp5560nhrocrppiq7hcnlvl1s.apps.googleusercontent.com'; + + final GoogleSignIn _signIn; + bool _initialized = false; + + /// 拉起授权并返回结局;不会抛异常。 + Future signIn() async { + try { + await _ensureInitialized(); + if (!_signIn.supportsAuthenticate()) { + return const GoogleSignInOutcome.unavailable(); + } + final account = await _signIn.authenticate(); + final idToken = account.authentication.idToken; + if (idToken == null || idToken.isEmpty) { + return const GoogleSignInOutcome.failed(); + } + return GoogleSignInOutcome.success(idToken); + } on GoogleSignInException catch (error) { + if (error.code == GoogleSignInExceptionCode.canceled) { + return const GoogleSignInOutcome.canceled(); + } + return const GoogleSignInOutcome.failed(); + } catch (_) { + return const GoogleSignInOutcome.failed(); + } + } + + /// 插件要求先 initialize 才能 authenticate;只做一次。 + Future _ensureInitialized() async { + if (_initialized) return; + await _signIn.initialize( + serverClientId: _isAndroid ? _serverClientId : null, + ); + _initialized = true; + } + + bool get _isAndroid => !kIsWeb && Platform.isAndroid; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4633e72..485612e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -32,6 +32,7 @@ "authContinueGoogle": "Continue with Google", "authPhoneSignIn": "Phone sign in", "authEmailSignIn": "Email sign in", + "authGoogleFailed": "Google sign in failed, please try again", "suspense": "Suspense", "romance": "Romance", "actionGenre": "Action", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 1f9f6d6..7bda80a 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -32,6 +32,7 @@ "authContinueGoogle": "Lanjutkan dengan Google", "authPhoneSignIn": "Masuk dengan ponsel", "authEmailSignIn": "Masuk dengan email", + "authGoogleFailed": "Gagal masuk dengan Google, silakan coba lagi", "suspense": "Thriller", "romance": "Romantis", "actionGenre": "Aksi", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ed98fdc..99c011d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -292,6 +292,12 @@ abstract class AppLocalizations { /// **'Email sign in'** String get authEmailSignIn; + /// No description provided for @authGoogleFailed. + /// + /// In en, this message translates to: + /// **'Google sign in failed, please try again'** + String get authGoogleFailed; + /// No description provided for @suspense. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 58cdf14..023579c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -106,6 +106,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get authEmailSignIn => 'Email sign in'; + @override + String get authGoogleFailed => 'Google sign in failed, please try again'; + @override String get suspense => 'Suspense'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index d77c7ff..51724b3 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -106,6 +106,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get authEmailSignIn => 'Masuk dengan email'; + @override + String get authGoogleFailed => 'Gagal masuk dengan Google, silakan coba lagi'; + @override String get suspense => 'Thriller'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 12b9a6c..f202190 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -104,6 +104,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get authEmailSignIn => '邮箱登录'; + @override + String get authGoogleFailed => 'Google 登录失败,请稍后重试'; + @override String get suspense => '悬疑'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 09377b6..84eec2c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -32,6 +32,7 @@ "authContinueGoogle": "使用 Google 继续", "authPhoneSignIn": "手机号登录", "authEmailSignIn": "邮箱登录", + "authGoogleFailed": "Google 登录失败,请稍后重试", "suspense": "悬疑", "romance": "都市爱情", "actionGenre": "动作", diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 4970a42..dc0b360 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -9,6 +9,7 @@ import '../../core/data/remote/api_client.dart'; import '../../core/formatting/error_text.dart'; import '../../core/data/services/app_config_controller.dart'; import '../../core/data/services/auth_controller.dart'; +import '../../core/data/services/google_auth_service.dart'; import '../../core/platform/haptics.dart'; import '../../core/platform/keyboard_inset.dart'; import '../../core/theme/app_colors.dart'; @@ -20,7 +21,6 @@ import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import 'auth_legal_links.dart'; import 'auth_logic.dart'; -import 'phone_auth_sheet.dart'; part 'auth_widgets.dart'; part 'auth_form_widgets.dart'; @@ -31,6 +31,7 @@ const double _compactBrandMarkSize = 48; const double _brandMarkRadius = 26; const double _compactBrandMarkRadius = 14; const String _appLogoAsset = 'assets/icon/ymb.png'; +const String _googleIconAsset = 'assets/icon/google_g.png'; const double _contentMaxWidth = 440; const double _fieldHeight = 52; const double _fieldRadius = 12; @@ -49,6 +50,8 @@ const double _entryButtonHeight = 52; const double _entryButtonRadius = 26; const double _entryButtonFontSize = 15; const double _entryIconSize = 20; +const double _loadingStrokeWidth = 2; +const Color _googleOnLight = Color(0xFF1F1F1F); const Duration _enterDuration = Duration(milliseconds: 220); Widget _brandMarkWidget({ @@ -80,6 +83,7 @@ class AuthPage extends StatefulWidget { class _AuthPageState extends State { late AuthLogic _logic; + late final GoogleAuthService _googleAuth; late final TextEditingController _emailController; late final TextEditingController _passwordController; late final FocusNode _emailFocus; @@ -94,6 +98,7 @@ class _AuthPageState extends State { void initState() { super.initState(); _logic = AuthLogic(signUpMode: widget.signUpMode); + _googleAuth = GoogleAuthService(); _emailController = TextEditingController(); _passwordController = TextEditingController(); _emailFocus = FocusNode(debugLabel: 'auth-email-focus'); @@ -248,6 +253,42 @@ class _AuthPageState extends State { showCenteredToast(context, failureMessage); } + /// Google 登录:先取 Google idToken,再交给后端换发本应用令牌。 + /// + /// 用户取消不提示错误;平台不支持或授权失败提示重试。 + Future _signInWithGoogle() async { + if (_logic.busy) return; + final l10n = AppLocalizations.of(context)!; + unawaited(context.read().lightImpact()); + FocusScope.of(context).unfocus(); + setState(() { + _logic.busy = true; + _logic.errorMessage = null; + }); + final auth = context.read(); + String? failureMessage; + try { + final outcome = await _googleAuth.signIn(); + if (!mounted) return; + if (outcome.canceled) return; + if (!outcome.isSuccess) { + failureMessage = l10n.authGoogleFailed; + } else { + await auth.signInWithGoogle(outcome.idToken!); + if (!mounted) return; + Navigator.of(context).maybePop(); + } + } on ApiException catch (error) { + failureMessage = userFacingApiMessage(l10n, error.code, error.message); + } catch (_) { + failureMessage = l10n.authNetworkError; + } finally { + if (mounted) setState(() => _logic.busy = false); + } + if (!mounted || failureMessage == null) return; + showCenteredToast(context, failureMessage); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -437,21 +478,23 @@ class _AuthPageState extends State { ), ), const Spacer(flex: 2), - _AuthEntryButton( - key: const Key('auth-entry-phone'), - label: l10n.authPhoneSignIn, - icon: Icons.phone_android_rounded, - primary: true, - onTap: _openPhoneSheet, - ), - const SizedBox(height: 12), _AuthEntryButton( key: const Key('auth-entry-email'), label: l10n.authEmailSignIn, icon: Icons.mail_outline_rounded, - primary: false, + primary: true, onTap: () => setState(() => _emailEntryOpen = true), ), + const SizedBox(height: 12), + _AuthEntryButton( + key: const Key('auth-entry-google'), + label: l10n.authContinueGoogle, + imageAsset: _googleIconAsset, + light: true, + primary: false, + loading: _logic.busy, + onTap: _signInWithGoogle, + ), const SizedBox(height: 14), const Opacity(opacity: 0.75, child: AuthLegalLinks()), const Spacer(flex: 6), @@ -459,14 +502,6 @@ class _AuthPageState extends State { ), ); - Future _openPhoneSheet() async { - final signedIn = await showPhoneAuthSheet( - context, - signUp: _logic.signUpMode, - ); - if (signedIn == true && mounted) Navigator.of(context).maybePop(); - } - Widget _authCard(AppLocalizations l10n) => _AuthCard( state: _AuthCardState( l10n: l10n, @@ -513,6 +548,9 @@ class _AuthEntryButton extends StatelessWidget { required this.primary, required this.onTap, this.icon, + this.imageAsset, + this.light = false, + this.loading = false, }); final String label; @@ -520,13 +558,24 @@ class _AuthEntryButton extends StatelessWidget { final VoidCallback onTap; final IconData? icon; + /// 需要显示彩色品牌图标时用图片;与 [icon] 二选一。 + final String? imageAsset; + + /// 白底深字样式(Google 官方按钮规范),用于第三方登录入口。 + final bool light; + + /// 请求进行中:显示转圈并禁止重复点击。 + final bool loading; + @override Widget build(BuildContext context) { - // 主按钮纯品牌红;次按钮为无边框深色玻璃(白 6%),Apple 风格。 + // 主按钮纯品牌红;次按钮为无边框深色玻璃(白 6%);第三方登录用白底深字。 final background = primary ? AppColors.brandRed - : Colors.white.withValues(alpha: 0.06); - final foreground = primary ? AppColors.onPrimary : AppColors.onSurface; + : (light ? Colors.white : Colors.white.withValues(alpha: 0.06)); + final foreground = primary + ? AppColors.onPrimary + : (light ? _googleOnLight : AppColors.onSurface); return Material( color: background, shape: RoundedRectangleBorder( @@ -534,16 +583,34 @@ class _AuthEntryButton extends StatelessWidget { ), clipBehavior: Clip.antiAlias, child: InkWell( - onTap: onTap, + onTap: loading ? null : onTap, borderRadius: BorderRadius.circular(_entryButtonRadius), child: SizedBox( height: _entryButtonHeight, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (icon != null) + if (loading) ...[ + SizedBox( + width: _entryIconSize, + height: _entryIconSize, + child: CircularProgressIndicator( + strokeWidth: _loadingStrokeWidth, + valueColor: AlwaysStoppedAnimation(foreground), + ), + ), + const SizedBox(width: 10), + ] else if (imageAsset != null) ...[ + Image.asset( + imageAsset!, + width: _entryIconSize, + height: _entryIconSize, + ), + const SizedBox(width: 10), + ] else if (icon != null) ...[ Icon(icon, size: _entryIconSize, color: foreground), - if (icon != null) const SizedBox(width: 8), + const SizedBox(width: 8), + ], Flexible( child: Text( label, diff --git a/pubspec.lock b/pubspec.lock index eec0a23..d29a50f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -207,6 +207,54 @@ packages: description: flutter source: sdk version: "0.0.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: af51a005b7c3c3f54cb5e7d04dd5db96f081e6f92b36468c60d14726a047f918 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.2.19" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: bc03e8f8cf0e5a5cbbc1d582516e5f792c734f0a41d324d4760dcb8bef523b28 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.4.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.3" hooks: dependency: transitive description: @@ -223,6 +271,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.15.7" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" http_parser: dependency: transitive description: @@ -779,4 +835,4 @@ packages: version: "3.1.4" sdks: dart: ">=3.13.0 <4.0.0" - flutter: ">=3.44.0" + flutter: ">=3.47.0" diff --git a/pubspec.yaml b/pubspec.yaml index 10dcd44..2f1f3bb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: floating: ^6.0.0 # 运行时权限:systemAlertWindow(「显示在其他应用上层」) permission_handler: ^13.0.2 + google_sign_in: ^7.2.0 dev_dependencies: flutter_test: diff --git a/test/auth/auth_landing_test.dart b/test/auth/auth_landing_test.dart index 75e6ff6..b97e57e 100644 --- a/test/auth/auth_landing_test.dart +++ b/test/auth/auth_landing_test.dart @@ -53,22 +53,15 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const Key('auth-entry-google')), findsNothing); - expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-phone')), findsNothing); expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-google')), findsOneWidget); expect(find.byKey(const Key('auth-legal')), findsOneWidget); expect( tester.getTopLeft(find.byKey(const Key('auth-landing'))).dy, lessThan(80), ); - await tester.tap(find.byKey(const Key('auth-entry-phone'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('phone-auth-sheet')), findsOneWidget); - - await tester.tapAt(const Offset(8, 8)); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('auth-entry-email'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('auth-email')), findsOneWidget); diff --git a/test/auth/auth_visual_test.dart b/test/auth/auth_visual_test.dart index def9a26..df909bc 100644 --- a/test/auth/auth_visual_test.dart +++ b/test/auth/auth_visual_test.dart @@ -90,28 +90,17 @@ void main() { tester.getSize(find.byKey(const Key('auth-brand-logo'))), const Size(88, 88), ); - expect(find.byKey(const Key('auth-entry-google')), findsNothing); - expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-google')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-phone')), findsNothing); expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); expect(find.byKey(const Key('auth-legal')), findsOneWidget); - expect( - tester.getSize(find.byKey(const Key('auth-entry-phone'))).height, - 52, - ); expect( tester.getSize(find.byKey(const Key('auth-entry-email'))).height, 52, ); - final phoneButton = tester.widget( - find.descendant( - of: find.byKey(const Key('auth-entry-phone')), - matching: find.byType(Material), - ), - ); - expect(phoneButton.color, AppColors.brandRed); expect( - (phoneButton.shape! as RoundedRectangleBorder).borderRadius, - BorderRadius.circular(26), + tester.getSize(find.byKey(const Key('auth-entry-google'))).height, + 52, ); final emailButton = tester.widget( find.descendant( @@ -119,8 +108,20 @@ void main() { matching: find.byType(Material), ), ); - // 次按钮是「白 6%」玻璃拟态,不再有描边。 - expect(emailButton.color, Colors.white.withValues(alpha: 0.06)); + // 邮箱是主入口,用品牌红。 + expect(emailButton.color, AppColors.brandRed); + expect( + (emailButton.shape! as RoundedRectangleBorder).borderRadius, + BorderRadius.circular(26), + ); + final googleButton = tester.widget( + find.descendant( + of: find.byKey(const Key('auth-entry-google')), + matching: find.byType(Material), + ), + ); + // 第三方登录按 Google 规范用白底深字。 + expect(googleButton.color, Colors.white); final glow = tester.widget( find.byKey(const Key('auth-red-glow')), @@ -237,7 +238,7 @@ void main() { expect(tester.getCenter(logo).dx, closeTo(195, 0.1)); expect( tester.getBottomLeft(logo).dy, - lessThan(tester.getTopLeft(find.byKey(const Key('auth-entry-phone'))).dy), + lessThan(tester.getTopLeft(find.byKey(const Key('auth-entry-email'))).dy), ); });