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.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 createState() => _WelcomePageState(); } class _WelcomePageState extends State { 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 _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 _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 检查支付宝是否安装 // aliPayAuth(appId, scope) → Future 发起 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 配置要求: // Future _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 _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 _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), ], ), ); } }