536 lines
22 KiB
Dart
536 lines
22 KiB
Dart
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: 微信 + 支付宝 + Google(3 个按钮)
|
||
/// iOS: 微信 + 支付宝 + Google + Apple(4 个按钮)
|
||
///
|
||
/// ── 各登录方式申请前提 ──────────────────────────────────
|
||
/// 微信: 微信开放平台企业认证 (¥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
|
||
/// 支付宝: 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']);
|
||
|
||
// 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();
|
||
|
||
@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 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 用 RS256(RSA-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),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|