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:
1 parent
7b90bd59b9
commit
67670d46b1
18 files changed
+335
-55
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user