diff --git a/assets/icon/google_g.png b/assets/icon/google_g.png new file mode 100644 index 0000000..8d17e48 Binary files /dev/null and b/assets/icon/google_g.png differ 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), ); });