419 lines
15 KiB
Dart
419 lines
15 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
|
||
/// iOS: 微信 + 支付宝 + Google + 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;
|
||
|
||
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 包使用方法:
|
||
// isAliPayInstalled() → Future<bool>
|
||
// aliPay(String orderStr) — 用于支付
|
||
// 但 OAuth 授权用的是 Alipay.authCode(appId, scope) 返回 auth_code
|
||
//
|
||
// tobias v3.x API: aliPayAuth(appId, scope) → Future<Map>
|
||
// 返回 {'resultStatus': '9000', 'memo': '', 'result': '...(含 auth_code)...'}
|
||
// resultStatus: 9000=成功, 6001=取消, 4000=错误
|
||
|
||
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 {
|
||
// appId 与后端 ALIPAY_APP_ID 一致
|
||
const alipayAppId = String.fromEnvironment('ALIPAY_APP_ID', defaultValue: '');
|
||
final result = await aliPayAuth(alipayAppId, 'auth_user');
|
||
|
||
final status = result['resultStatus']?.toString() ?? '';
|
||
if (status != '9000') {
|
||
// 用户取消或错误
|
||
return;
|
||
}
|
||
|
||
// 从 result 字符串中解析 auth_code
|
||
// result 格式: "auth_code=AP_xxxxxxxx&scope=auth_user&state=..."
|
||
final resultStr = result['result']?.toString() ?? '';
|
||
final authCode = _parseParam(resultStr, 'auth_code');
|
||
if (authCode == null || authCode.isEmpty) {
|
||
throw Exception('未获取到 auth_code');
|
||
}
|
||
|
||
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 格式字符串中提取参数值
|
||
String? _parseParam(String str, String key) {
|
||
final uri = Uri(query: str);
|
||
return uri.queryParameters[key];
|
||
}
|
||
|
||
// ── Google ─────────────────────────────────────────────────────────────────
|
||
|
||
Future<void> _onGoogleTap() async {
|
||
setState(() => _googleLoading = true);
|
||
try {
|
||
final account = await _googleSignIn.signIn();
|
||
if (account == null) return; // 用户取消
|
||
|
||
final auth = await account.authentication;
|
||
final idToken = auth.idToken;
|
||
if (idToken == null) throw Exception('未获取到 ID Token');
|
||
|
||
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)────────────────────────────────────────────────────────
|
||
|
||
Future<void> _onAppleTap() async {
|
||
setState(() => _appleLoading = true);
|
||
try {
|
||
final credential = await SignInWithApple.getAppleIDCredential(
|
||
scopes: [
|
||
AppleIDAuthorizationScopes.email,
|
||
AppleIDAuthorizationScopes.fullName,
|
||
],
|
||
);
|
||
|
||
final identityToken = credential.identityToken;
|
||
if (identityToken == null) throw Exception('未获取到 Identity Token');
|
||
|
||
// 拼接用户显示名(Apple 首次登录时提供,之后为 null)
|
||
final displayName = [
|
||
credential.givenName,
|
||
credential.familyName,
|
||
].where((s) => s != null && s.isNotEmpty).join(' ');
|
||
|
||
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),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|