feat(auth): 接入 Google 登录

- 登录页保留邮箱与 Google 两个入口,Google 按钮按官方规范改为白底四色图标
- 新增 GoogleAuthService:封装 initialize/authenticate,失败以结果对象返回
- AuthApi 增加 googleLogin,AuthController 增加 signInWithGoogle
- iOS 补充 CFBundleURLTypes 与 GIDClientID,Bundle ID 统一为 com.stormg.lumadrama
- Google 请求期间按钮显示转圈并禁止重复点击
This commit is contained in:
Vicky committed 2026-11-13 12:30:47 +08:00
1 parent 7b90bd59b9
commit 67670d46b1
18 files changed
+335 -55

No files matched your search

+23
View File
@@ -125,6 +125,29 @@ class AuthApi {
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
}
/// Google 登录:把客户端拿到的 idToken 交给后端校验并换发令牌。
///
/// 账号按 Google 返回的邮箱归并:邮箱已存在则直接登入,不存在则自动建号。
Future<AuthSession> 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<String, dynamic>());
}
Future<AuthSession> refresh(
String refreshToken, {
String platform = '',
@@ -103,6 +103,17 @@ class AuthController extends ChangeNotifier {
await _apply(session);
}
/// Google 登录:把 Google 返回的 idToken 交给后端校验并落登录态。
Future<void> signInWithGoogle(String idToken) async {
final session = await _api.googleLogin(
idToken: idToken,
deviceId: await _store.deviceId(),
platform: _platform,
osVersion: _osVersion,
);
await _apply(session);
}
Future<void> clear() async {
_session = null;
await _store.clear();
@@ -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<GoogleSignInOutcome> 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<void> _ensureInitialized() async {
if (_initialized) return;
await _signIn.initialize(
serverClientId: _isAndroid ? _serverClientId : null,
);
_initialized = true;
}
bool get _isAndroid => !kIsWeb && Platform.isAndroid;
}
+1
View File
@@ -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",
+1
View File
@@ -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",
+6
View File
@@ -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:
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -104,6 +104,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get authEmailSignIn => '邮箱登录';
@override
String get authGoogleFailed => 'Google 登录失败,请稍后重试';
@override
String get suspense => '悬疑';
+1
View File
@@ -32,6 +32,7 @@
"authContinueGoogle": "使用 Google 继续",
"authPhoneSignIn": "手机号登录",
"authEmailSignIn": "邮箱登录",
"authGoogleFailed": "Google 登录失败,请稍后重试",
"suspense": "悬疑",
"romance": "都市爱情",
"actionGenre": "动作",
+91 -24
View File
@@ -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<AuthPage> {
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<AuthPage> {
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<AuthPage> {
showCenteredToast(context, failureMessage);
}
/// Google 登录:先取 Google idToken,再交给后端换发本应用令牌。
///
/// 用户取消不提示错误;平台不支持或授权失败提示重试。
Future<void> _signInWithGoogle() async {
if (_logic.busy) return;
final l10n = AppLocalizations.of(context)!;
unawaited(context.read<Haptics>().lightImpact());
FocusScope.of(context).unfocus();
setState(() {
_logic.busy = true;
_logic.errorMessage = null;
});
final auth = context.read<AuthController>();
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<AuthPage> {
),
),
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<AuthPage> {
),
);
Future<void> _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<Color>(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,