gcx/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart

517 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatform;
import 'package:fluwx/fluwx.dart';
import 'package:tobias/tobias.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../app/theme/app_typography.dart';
import '../../../../app/theme/app_spacing.dart';
import '../../../../shared/widgets/genex_button.dart';
import '../../../../app/i18n/app_localizations.dart';
import '../../../../core/services/auth_service.dart';
/// A1. 欢迎页 - 品牌展示 + 注册/登录入口
///
/// ── 社交登录平台支持 ──────────────────────────────────────
/// Android: 微信 + 支付宝 + Google3 个按钮)
/// iOS: 微信 + 支付宝 + Google + Apple4 个按钮)
///
/// ── 各登录方式申请前提 ──────────────────────────────────
/// 微信: 微信开放平台企业认证 (¥300/年) + 创建移动应用
/// 支付宝: 支付宝开放平台 + 创建移动应用 + 开通「获取会员信息」接口
/// Google: Google Cloud Console 创建 OAuth 2.0 Client ID
/// Apple: Apple Developer ($99/年) + 开启 Sign In with Apple 能力
///
/// ── 配置方式(--dart-define 传入 AppID不写死到代码中──
/// flutter build apk --dart-define=ALIPAY_APP_ID=2021003189xxxxxx
/// flutter build apk --dart-define=WECHAT_APP_ID=wx0000000000000000
///
/// ── 登录流程(各平台)────────────────────────────────────
/// 微信: fluwx.sendWeChatAuth → WXAuthResp(code) → POST /auth/wechat
/// 支付宝: tobias.aliPayAuth → Map(result 含 auth_code) → POST /auth/alipay
/// Google: google_sign_in.signIn → GoogleSignInAuthentication.idToken → POST /auth/google
/// Apple: sign_in_with_apple.getAppleIDCredential → identityToken → POST /auth/apple
class WelcomePage extends StatefulWidget {
const WelcomePage({super.key});
@override
State<WelcomePage> createState() => _WelcomePageState();
}
class _WelcomePageState extends State<WelcomePage> {
bool _wechatLoading = false;
bool _alipayLoading = false;
bool _googleLoading = false;
bool _appleLoading = false;
// google_sign_in 实例scopes 决定 idToken 中包含哪些用户数据)
// 'email': 用于后端在首次注册时写入 users 表
// 'profile': 获取用户显示名和头像 URL
final _googleSignIn = GoogleSignIn(scopes: ['email', 'profile']);
@override
void initState() {
super.initState();
// 监听微信授权回调(用户授权后,微信 App 返回 code
weChatResponseEventHandler.distinct().listen((res) {
if (res is WXAuthResp && mounted) {
_handleWechatAuthResp(res);
}
});
}
// ── 微信 ─────────────────────────────────────────────────────────────────
Future<void> _onWechatTap() async {
final installed = await isWeChatInstalled;
if (!installed) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.t('welcome.wechatNotInstalled'))),
);
}
return;
}
setState(() => _wechatLoading = true);
await sendWeChatAuth(scope: 'snsapi_userinfo', state: 'genex_login');
// 授权结果通过 weChatResponseEventHandler 异步回调
}
Future<void> _handleWechatAuthResp(WXAuthResp resp) async {
setState(() => _wechatLoading = false);
if (resp.errCode != 0 || resp.code == null) return;
try {
await AuthService.instance.loginByWechat(code: resp.code!);
if (mounted) Navigator.pushReplacementNamed(context, '/main');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.t('welcome.wechatLoginFailed'))),
);
}
}
}
// ── 支付宝 ─────────────────────────────────────────────────────────────────
//
// tobias 包https://pub.dev/packages/tobias封装了支付宝 SDK。
//
// 关键 API:
// isAliPayInstalled → Future<bool> 检查支付宝是否安装
// aliPayAuth(appId, scope) → Future<Map> 发起 OAuth 授权
//
// aliPayAuth 返回 Map 结构:
// resultStatus: '9000' = 成功, '6001' = 用户取消, '4000' = 失败
// result: query 格式字符串,包含 auth_code、scope、state 等
// 示例: "auth_code=AP_xxxxxxxx&scope=auth_user&state=&charset=UTF-8"
// memo: 错误描述resultStatus != '9000' 时有值)
//
// scope 说明:
// auth_user — 获取用户基本信息nick_name、avatar、user_id需开通「获取会员信息」接口
// auth_base — 仅获取 user_id静默授权不弹授权页功能受限
//
// iOS Info.plist 配置要求:
// CFBundleURLSchemes: ["alipay$(ALIPAY_APP_ID)"] — 支付宝回调 URL Scheme
// LSApplicationQueriesSchemes: ["alipay", "alipays"] — 检查支付宝是否安装
//
// Android AndroidManifest.xml 配置要求:
// <queries><package android:name="com.eg.android.AlipayGphone" /></queries>
Future<void> _onAlipayTap() async {
final installed = await isAliPayInstalled;
if (!installed) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.t('welcome.alipayNotInstalled'))),
);
}
return;
}
setState(() => _alipayLoading = true);
try {
// ALIPAY_APP_ID 通过 --dart-define=ALIPAY_APP_ID=xxx 在编译时注入
// 与后端 .env 中的 ALIPAY_APP_ID 保持一致(同一支付宝移动应用的 AppID
const alipayAppId = String.fromEnvironment('ALIPAY_APP_ID', defaultValue: '');
final result = await aliPayAuth(alipayAppId, 'auth_user');
final status = result['resultStatus']?.toString() ?? '';
if (status != '9000') {
// '6001' = 用户主动取消,静默处理(不显示错误提示)
// '4000' = 授权失败同样静默finally 会清除 loading 状态)
return;
}
// result 字符串为 URL query 格式,例如:
// "auth_code=AP_xxxxxxxxxxxxxxxx&scope=auth_user&state=&charset=UTF-8"
// 用 Uri(query: ...) 解析可安全处理特殊字符
final resultStr = result['result']?.toString() ?? '';
final authCode = _parseParam(resultStr, 'auth_code');
if (authCode == null || authCode.isEmpty) {
throw Exception('未获取到 auth_code');
}
// 将 auth_code 发送后端,后端完成 token 换取 + 用户信息获取
await AuthService.instance.loginByAlipay(authCode: authCode);
if (mounted) Navigator.pushReplacementNamed(context, '/main');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.t('welcome.alipayLoginFailed'))),
);
}
} finally {
if (mounted) setState(() => _alipayLoading = false);
}
}
/// 从 URL Query 格式字符串中提取指定参数的值
///
/// 支付宝 SDK 返回的 result 字符串格式类似 URL query string
/// 借助 Uri(query: ...) 解析可正确处理 URL 编码的特殊字符。
///
/// 示例:
/// input: "auth_code=AP_xxxxxxxx&scope=auth_user&state=&charset=UTF-8"
/// _parseParam(str, 'auth_code') → 'AP_xxxxxxxx'
String? _parseParam(String str, String key) {
final uri = Uri(query: str);
return uri.queryParameters[key];
}
// ── Google ─────────────────────────────────────────────────────────────────
//
// google_sign_in 包https://pub.dev/packages/google_sign_in
//
// 登录流程:
// GoogleSignIn.signIn() → 弹出 Google 账号选择界面
// → 返回 GoogleSignInAccount用户基本信息: email, displayName 等)
// → account.authentication → GoogleSignInAuthentication
// → .idToken: JWT 格式,包含 sub用户ID、email、name、picture
//
// Android 配置要求android/app/build.gradle:
// 不需要额外配置google_sign_in 自动读取 google-services.json
// 但需要在 Google Cloud Console 创建 Android OAuth Client ID需要 SHA-1 指纹)
//
// iOS 配置要求:
// Runner/GoogleService-Info.plist — 从 Firebase 或 Google Cloud Console 下载
// 或在 Info.plist 中配置 GIDClientID
//
// 注意:
// signIn() 在用户已登录时可能直接返回缓存账号(无弹窗)
// 若需要重新弹出选择界面,先调用 _googleSignIn.signOut()
Future<void> _onGoogleTap() async {
setState(() => _googleLoading = true);
try {
// 弹出 Google 账号选择/授权界面
// 返回 null = 用户手动关闭了界面(取消)
final account = await _googleSignIn.signIn();
if (account == null) return; // 用户取消,不显示错误提示
// 获取 OAuth 认证凭据(包含 idToken + accessToken
// idToken 是 JWT 格式,后端可通过 Google 公钥验证其有效性
final auth = await account.authentication;
final idToken = auth.idToken;
if (idToken == null) throw Exception('未获取到 ID Token');
// 将 idToken 发送后端验证Google tokeninfo API 或 JWKS 本地验证)
await AuthService.instance.loginByGoogle(idToken: idToken);
if (mounted) Navigator.pushReplacementNamed(context, '/main');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.t('welcome.googleLoginFailed'))),
);
}
} finally {
if (mounted) setState(() => _googleLoading = false);
}
}
// ── Apple仅 iOS────────────────────────────────────────────────────────
//
// sign_in_with_apple 包https://pub.dev/packages/sign_in_with_apple
//
// iOS 配置要求:
// Xcode → Target → Signing & Capabilities → + Capability → Sign In with Apple
// (对应 apple.provider.ts 中的 APPLE_CLIENT_ID = Bundle ID
//
// 重要的 Apple 限制:
// 1. givenName / familyName 只在用户首次授权时返回,之后为 null
// → 首次注册时需将姓名保存到 users 表,之后无法从 Apple 重新获取
// 2. email 同样只在首次授权时返回
// → 若用户选择「隐藏邮箱」Apple 会生成一个代理邮箱privaterelay.appleid.com
// 3. userIdentifier= identityToken 中的 sub是稳定的唯一 ID
// → 即使用户卸载 App 重装sub 不变;用户可在 Apple ID 设置中撤销授权
//
// AppleIDAuthorizationScopes:
// email — 请求用户邮箱(仅首次授权时返回)
// fullName — 请求用户姓名givenName + familyName仅首次授权时返回
//
// identityToken:
// JWT 格式,由 Apple 用 RS256RSA-SHA256私钥签名
// 后端通过 Apple JWKS 公钥https://appleid.apple.com/auth/keys验证签名
Future<void> _onAppleTap() async {
setState(() => _appleLoading = true);
try {
final credential = await SignInWithApple.getAppleIDCredential(
scopes: [
AppleIDAuthorizationScopes.email, // 首次授权时返回邮箱
AppleIDAuthorizationScopes.fullName, // 首次授权时返回姓名
],
);
// identityToken 是 JWT后端用于验证用户身份的核心凭据
final identityToken = credential.identityToken;
if (identityToken == null) throw Exception('未获取到 Identity Token');
// 用户显示名:仅首次授权时有值,之后 givenName/familyName 为 null
// 拼接 "名 姓",过滤空值,确保不传空字符串到后端
final displayName = [
credential.givenName,
credential.familyName,
].where((s) => s != null && s.isNotEmpty).join(' ');
// identityToken + displayName 发后端:
// - 后端用 Apple JWKS 验证 identityToken 签名
// - 提取 sub 作为用户唯一标识(后续登录同一用户)
// - 首次注册时用 email + displayName 初始化用户资料
await AuthService.instance.loginByApple(
identityToken: identityToken,
displayName: displayName.isNotEmpty ? displayName : null,
);
if (mounted) Navigator.pushReplacementNamed(context, '/main');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.t('welcome.appleLoginFailed'))),
);
}
} finally {
if (mounted) setState(() => _appleLoading = false);
}
}
// ── Build ─────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const Spacer(flex: 2),
// Brand Logo
ClipRRect(
borderRadius: AppSpacing.borderRadiusXl,
child: Image.asset(
'assets/images/logo_icon.png',
width: 80,
height: 80,
),
),
const SizedBox(height: 12),
// Brand Name — 遵循 lockup 设计: "GEN" 深色 + "EX" 渐变
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'GEN',
style: AppTypography.displayLarge.copyWith(
color: const Color(0xFF1A103A),
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
),
),
ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
colors: [Color(0xFF9B8FFF), Color(0xFFB8ADFF)],
).createShader(bounds),
child: Text(
'EX',
style: AppTypography.displayLarge.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
),
),
),
],
),
const SizedBox(height: 6),
// Slogan
Text(
context.t('welcome.slogan'),
style: AppTypography.bodyLarge.copyWith(
color: AppColors.textSecondary,
),
),
const Spacer(flex: 3),
// Phone Register
GenexButton(
label: context.t('welcome.phoneRegister'),
icon: Icons.phone_android_rounded,
onPressed: () {
Navigator.pushNamed(context, '/register');
},
),
const SizedBox(height: 12),
// Email Register
GenexButton(
label: context.t('welcome.emailRegister'),
icon: Icons.email_outlined,
variant: GenexButtonVariant.outline,
onPressed: () {
Navigator.pushNamed(context, '/register', arguments: {'isEmail': true});
},
),
const SizedBox(height: 24),
// Social Login Divider
Row(
children: [
const Expanded(child: Divider(color: AppColors.border)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(context.t('welcome.otherLogin'), style: AppTypography.caption),
),
const Expanded(child: Divider(color: AppColors.border)),
],
),
const SizedBox(height: 16),
// Social Login Buttons
// Android: 微信 + 支付宝 + Google
// iOS: 微信 + 支付宝 + Google + Apple
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_SocialLoginButton(
icon: Icons.wechat,
label: context.t('welcome.wechat'),
color: const Color(0xFF07C160),
loading: _wechatLoading,
onTap: _onWechatTap,
),
const SizedBox(width: 20),
_SocialLoginButton(
// 支付宝官方蓝色
icon: Icons.account_balance_wallet_outlined,
label: context.t('welcome.alipay'),
color: const Color(0xFF1677FF),
loading: _alipayLoading,
onTap: _onAlipayTap,
),
const SizedBox(width: 20),
_SocialLoginButton(
icon: Icons.g_mobiledata_rounded,
label: 'Google',
loading: _googleLoading,
onTap: _onGoogleTap,
),
if (isIos) ...[
const SizedBox(width: 20),
_SocialLoginButton(
icon: Icons.apple_rounded,
label: 'Apple',
loading: _appleLoading,
onTap: _onAppleTap,
),
],
],
),
const SizedBox(height: 32),
// Already have account
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(context.t('welcome.hasAccount'), style: AppTypography.bodyMedium.copyWith(
color: AppColors.textSecondary,
)),
GestureDetector(
onTap: () {
Navigator.pushNamed(context, '/login');
},
child: Text(context.t('welcome.login'), style: AppTypography.labelMedium.copyWith(
color: AppColors.primary,
)),
),
],
),
const SizedBox(height: 16),
// Terms
Text(
context.t('welcome.agreement'),
style: AppTypography.caption.copyWith(fontSize: 10),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
],
),
),
),
);
}
}
class _SocialLoginButton extends StatelessWidget {
final IconData icon;
final String label;
final Color? color;
final VoidCallback onTap;
final bool loading;
const _SocialLoginButton({
required this.icon,
required this.label,
required this.onTap,
this.color,
this.loading = false,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: loading ? null : onTap,
child: Column(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: color != null ? color!.withValues(alpha: 0.1) : AppColors.gray50,
shape: BoxShape.circle,
border: Border.all(color: color?.withValues(alpha: 0.3) ?? AppColors.border),
),
child: loading
? Padding(
padding: const EdgeInsets.all(14),
child: CircularProgressIndicator(
strokeWidth: 2,
color: color ?? AppColors.textPrimary,
),
)
: Icon(icon, size: 28, color: color ?? AppColors.textPrimary),
),
const SizedBox(height: 6),
Text(label, style: AppTypography.caption),
],
),
);
}
}