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

549 lines
23 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:flutter_riverpod/flutter_riverpod.dart';
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 '../providers/auth_provider.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.authBy(NormalAuth) → WeChatAuthResponse(code) → POST /auth/wechat
/// 支付宝: GET /auth/alipay/auth-string → Tobias().auth(authStr) → 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 ConsumerStatefulWidget {
const WelcomePage({super.key});
@override
ConsumerState<WelcomePage> createState() => _WelcomePageState();
}
class _WelcomePageState extends ConsumerState<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']);
// fluwx 5.x API 迁移说明:
// 旧版 (3.x): 顶层 Stream `weChatResponseEventHandler.distinct().listen(...)`
// 新版 (5.x): 实例方法 `Fluwx().addSubscriber((response) {...})`
//
// Fluwx() 底层 MethodChannel 为单例,新建实例不会重复注册 SDK
// registerApi 已在 main.dart 的 app 启动时完成,此处直接使用。
final _fluwx = Fluwx();
// tobias 5.x 使用实例方式调用tobias 3.x+ 已移除顶层函数 aliPayAuth / isAliPayInstalled
// 同时auth(authString) 需要后端预签名的 authString而非直接传 appId+scope。
final _tobias = Tobias();
@override
void initState() {
super.initState();
// 订阅微信平台所有回调事件(授权/支付/分享等均通过同一 subscriber 派发)
// 通过 `is WeChatAuthResponse` 类型守卫过滤出登录授权响应
_fluwx.addSubscriber((response) {
if (response is WeChatAuthResponse && mounted) {
_handleWechatAuthResp(response);
}
});
}
// ── 微信 ─────────────────────────────────────────────────────────────────
Future<void> _onWechatTap() async {
final installed = await _fluwx.isWeChatInstalled;
if (!installed) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.t('welcome.wechatNotInstalled'))),
);
}
return;
}
setState(() => _wechatLoading = true);
// NormalAuth: 标准微信 OAuth 授权(弹出微信 App 授权页)
// scope: 'snsapi_userinfo' — 获取用户基本信息(需用户手动同意)
// 'snsapi_base' — 静默授权(只获取 openid无需同意页
// state: 任意字符串,原样返回,用于防 CSRF后端可校验一致性
// 旧版: sendWeChatAuth(scope: ..., state: ...)
// 新版: authBy(which: NormalAuth(scope: ..., state: ...))
await _fluwx.authBy(which: NormalAuth(scope: 'snsapi_userinfo', state: 'genex_login'));
// 授权结果异步通过 addSubscriber 回调,不在此处等待
}
// WeChatAuthResponse 字段说明:
// errCode: 0 = 成功, -4 = 用户拒绝, -2 = 用户取消
// code: 一次性授权码5 分钟有效),发送后端换取 access_token + unionid
// 旧版响应类: WXAuthResp新版: WeChatAuthResponse
Future<void> _handleWechatAuthResp(WeChatAuthResponse resp) async {
setState(() => _wechatLoading = false);
if ((resp.errCode ?? -1) != 0 || resp.code == null) return;
try {
await ref.read(authProvider.notifier).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 {
// tobias 5.x: 实例 getter旧版顶层函数 isAliPayInstalled 已移除)
final installed = await _tobias.isAliPayInstalled;
if (!installed) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.t('welcome.alipayNotInstalled'))),
);
}
return;
}
setState(() => _alipayLoading = true);
try {
// tobias 3.x+ 移除了 aliPayAuth(appId, scope) 顶层函数,
// 改为 Tobias().auth(authString)authString 须由后端 RSA2 签名。
// 原因: 安全升级,防止 Alipay 私钥暴露在客户端。
//
// 新流程:
// 1. 从后端 GET /auth/alipay/auth-string 拿到签名后的 authString
// 2. 传给 Tobias().auth(authString) 拉起支付宝 App
// 3. 用户授权后 result['result'] 中含 auth_code同旧流程
// 4. auth_code → POST /auth/alipay不变
final authString = await ref.read(authProvider.notifier).getAlipayAuthString();
final result = await _tobias.auth(authString);
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 ref.read(authProvider.notifier).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 ref.read(authProvider.notifier).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 ref.read(authProvider.notifier).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),
],
),
);
}
}