diff --git a/frontend/genex-mobile/lib/core/error/failures.dart b/frontend/genex-mobile/lib/core/error/failures.dart new file mode 100644 index 0000000..3224c0a --- /dev/null +++ b/frontend/genex-mobile/lib/core/error/failures.dart @@ -0,0 +1,44 @@ +// ============================================================ +// Failures — 领域层统一错误类型 +// 所有 Repository/UseCase 抛出的错误均为 Failure 的子类, +// 避免 Presentation 层依赖具体异常类型(DioException 等) +// ============================================================ + +sealed class Failure implements Exception { + final String message; + const Failure(this.message); + + @override + String toString() => '$runtimeType: $message'; +} + +/// 网络/HTTP 层错误(连接失败、超时等) +class NetworkFailure extends Failure { + const NetworkFailure([super.message = '网络错误,请检查连接']); +} + +/// 服务端返回业务错误(HTTP 4xx/5xx + 业务 code) +class ServerFailure extends Failure { + final int? statusCode; + const ServerFailure(super.message, {this.statusCode}); +} + +/// 认证失败(401/403、Token 失效、权限不足) +class AuthFailure extends Failure { + const AuthFailure([super.message = '认证失败,请重新登录']); +} + +/// 本地缓存/存储错误 +class CacheFailure extends Failure { + const CacheFailure([super.message = '本地数据读写失败']); +} + +/// 业务校验错误(输入不合法等) +class ValidationFailure extends Failure { + const ValidationFailure(super.message); +} + +/// 未知错误兜底 +class UnknownFailure extends Failure { + const UnknownFailure([super.message = '未知错误']); +} diff --git a/frontend/genex-mobile/lib/core/usecases/usecase.dart b/frontend/genex-mobile/lib/core/usecases/usecase.dart new file mode 100644 index 0000000..9c4f4ed --- /dev/null +++ b/frontend/genex-mobile/lib/core/usecases/usecase.dart @@ -0,0 +1,17 @@ +// ============================================================ +// UseCase — 用例基类 +// +// 遵循单一职责原则:每个 UseCase 只做一件事。 +// 泛型参数: +// Type — 成功返回的数据类型 +// Params — 入参类型(无参数时使用 NoParams) +// ============================================================ + +abstract class UseCase { + Future call(Params params); +} + +/// 无入参的占位类型 +class NoParams { + const NoParams(); +} diff --git a/frontend/genex-mobile/lib/features/ai_agent/presentation/providers/ai_agent_provider.dart b/frontend/genex-mobile/lib/features/ai_agent/presentation/providers/ai_agent_provider.dart new file mode 100644 index 0000000..2deeee0 --- /dev/null +++ b/frontend/genex-mobile/lib/features/ai_agent/presentation/providers/ai_agent_provider.dart @@ -0,0 +1,68 @@ +// ============================================================ +// AiAgentProvider — AI 智能助手 Riverpod Providers +// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/network/api_client.dart'; + +// ── 对话历史 ────────────────────────────────────────────────── + +final chatHistoryProvider = FutureProvider.autoDispose((ref) async { + final api = ApiClient.instance; + final resp = await api.get('/api/v1/ai/sessions/current/messages'); + return resp.data['data']; +}); + +// ── 发送消息状态 ────────────────────────────────────────────── + +class AiChatState { + final List> messages; + final bool isSending; + final String? error; + + const AiChatState({ + this.messages = const [], + this.isSending = false, + this.error, + }); + + AiChatState copyWith({ + List>? messages, + bool? isSending, + String? error, + bool clearError = false, + }) { + return AiChatState( + messages: messages ?? this.messages, + isSending: isSending ?? this.isSending, + error: clearError ? null : (error ?? this.error), + ); + } +} + +class AiChatNotifier extends Notifier { + @override + AiChatState build() => const AiChatState(); + + Future sendMessage(String content) async { + state = state.copyWith(isSending: true, clearError: true); + try { + final api = ApiClient.instance; + final resp = await api.post('/api/v1/ai/chat', data: {'message': content}); + final reply = resp.data['data'] as Map; + state = state.copyWith( + messages: [ + ...state.messages, + {'role': 'user', 'content': content}, + {'role': 'assistant', 'content': reply['reply'] ?? reply['content'] ?? ''}, + ], + isSending: false, + ); + } catch (e) { + state = state.copyWith(isSending: false, error: e.toString()); + rethrow; + } + } +} + +final aiChatProvider = NotifierProvider(AiChatNotifier.new); diff --git a/frontend/genex-mobile/lib/features/auth/data/repositories/auth_repository_impl.dart b/frontend/genex-mobile/lib/features/auth/data/repositories/auth_repository_impl.dart new file mode 100644 index 0000000..021e4fb --- /dev/null +++ b/frontend/genex-mobile/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -0,0 +1,243 @@ +// ============================================================ +// AuthRepositoryImpl — 认证仓库实现(数据层) +// +// 采用 Strangler Fig 模式:委托给现有的 AuthService 单例, +// 确保 ApiClient token 刷新/过期回调逻辑不重复实现。 +// ============================================================ + +import '../../domain/entities/auth_user.dart'; +import '../../domain/entities/auth_session.dart'; +import '../../domain/repositories/auth_repository.dart'; +import '../../../../core/services/auth_service.dart'; + +class AuthRepositoryImpl implements IAuthRepository { + final AuthService _service; + + AuthRepositoryImpl({AuthService? service}) : _service = service ?? AuthService.instance; + + // ── 辅助:AuthResult → AuthSession ───────────────────────── + + AuthSession _toSession(AuthResult result) { + return AuthSession( + user: AuthUser.fromJson(result.user), + accessToken: result.accessToken, + refreshToken: result.refreshToken, + expiresIn: result.expiresIn, + ); + } + + // ── 会话恢复 ──────────────────────────────────────────────── + + @override + Future restoreSession() async { + final restored = await _service.restoreSession(); + if (!restored) return null; + final result = _service.authState.value; + if (result == null) return null; + return _toSession(result); + } + + // ── 密码登录 ───────────────────────────────────────────────── + + @override + Future loginByPassword({ + required String identifier, + required String password, + String? deviceInfo, + }) async { + final result = await _service.loginByPassword( + identifier: identifier, + password: password, + deviceInfo: deviceInfo, + ); + return _toSession(result); + } + + // ── SMS 验证码登录 ──────────────────────────────────────────── + + @override + Future loginByPhone({ + required String phone, + required String smsCode, + String? deviceInfo, + }) async { + final result = await _service.loginByPhone( + phone: phone, + smsCode: smsCode, + deviceInfo: deviceInfo, + ); + return _toSession(result); + } + + // ── 邮件验证码登录 ──────────────────────────────────────────── + + @override + Future loginByEmail({ + required String email, + required String emailCode, + String? deviceInfo, + }) async { + final result = await _service.loginByEmail(email: email, emailCode: emailCode); + return _toSession(result); + } + + // ── 微信登录 ────────────────────────────────────────────────── + + @override + Future loginByWechat({ + required String code, + String? referralCode, + String? deviceInfo, + }) async { + final result = await _service.loginByWechat( + code: code, + referralCode: referralCode, + deviceInfo: deviceInfo, + ); + return _toSession(result); + } + + // ── 支付宝登录 ──────────────────────────────────────────────── + + @override + Future loginByAlipay({ + required String authCode, + String? referralCode, + String? deviceInfo, + }) async { + final result = await _service.loginByAlipay( + authCode: authCode, + referralCode: referralCode, + deviceInfo: deviceInfo, + ); + return _toSession(result); + } + + // ── Google 登录 ──────────────────────────────────────────────── + + @override + Future loginByGoogle({ + required String idToken, + String? referralCode, + String? deviceInfo, + }) async { + final result = await _service.loginByGoogle( + idToken: idToken, + referralCode: referralCode, + deviceInfo: deviceInfo, + ); + return _toSession(result); + } + + // ── Apple 登录 ──────────────────────────────────────────────── + + @override + Future loginByApple({ + required String identityToken, + String? displayName, + String? referralCode, + String? deviceInfo, + }) async { + final result = await _service.loginByApple( + identityToken: identityToken, + displayName: displayName, + referralCode: referralCode, + deviceInfo: deviceInfo, + ); + return _toSession(result); + } + + // ── 注册 ────────────────────────────────────────────────────── + + @override + Future register({ + required String phone, + required String smsCode, + required String password, + String? nickname, + String? referralCode, + }) async { + final result = await _service.register( + phone: phone, + smsCode: smsCode, + password: password, + nickname: nickname, + referralCode: referralCode, + ); + return _toSession(result); + } + + @override + Future registerByEmail({ + required String email, + required String emailCode, + required String password, + String? nickname, + String? referralCode, + }) async { + final result = await _service.registerByEmail( + email: email, + emailCode: emailCode, + password: password, + nickname: nickname, + referralCode: referralCode, + ); + return _toSession(result); + } + + // ── 登出 ────────────────────────────────────────────────────── + + @override + Future logout() => _service.logout(); + + // ── 验证码 ──────────────────────────────────────────────────── + + @override + Future sendSmsCode(String phone, SmsCodeType type) => + _service.sendSmsCode(phone, type); + + @override + Future sendEmailCode(String email, EmailCodeType type) => + _service.sendEmailCode(email, type); + + // ── 密码管理 ───────────────────────────────────────────────── + + @override + Future resetPassword({ + required String phone, + required String smsCode, + required String newPassword, + }) => + _service.resetPassword(phone: phone, smsCode: smsCode, newPassword: newPassword); + + @override + Future resetPasswordByEmail({ + required String email, + required String emailCode, + required String newPassword, + }) => + _service.resetPasswordByEmail( + email: email, emailCode: emailCode, newPassword: newPassword); + + @override + Future changePassword({ + required String oldPassword, + required String newPassword, + }) => + _service.changePassword(oldPassword: oldPassword, newPassword: newPassword); + + @override + Future changePhone({required String newPhone, required String newSmsCode}) => + _service.changePhone(newPhone: newPhone, newSmsCode: newSmsCode); + + // ── 支付宝工具 ──────────────────────────────────────────────── + + @override + Future getAlipayAuthString() => _service.getAlipayAuthString(); + + // ── 推荐码 ──────────────────────────────────────────────────── + + @override + Future validateReferralCode(String code) => + _service.validateReferralCode(code); +} diff --git a/frontend/genex-mobile/lib/features/auth/domain/entities/auth_session.dart b/frontend/genex-mobile/lib/features/auth/domain/entities/auth_session.dart new file mode 100644 index 0000000..61ae3a7 --- /dev/null +++ b/frontend/genex-mobile/lib/features/auth/domain/entities/auth_session.dart @@ -0,0 +1,33 @@ +// ============================================================ +// AuthSession — 登录会话实体(用户 + 双 Token) +// ============================================================ + +import 'auth_user.dart'; + +class AuthSession { + final AuthUser user; + final String accessToken; + final String refreshToken; + final int expiresIn; + + const AuthSession({ + required this.user, + required this.accessToken, + required this.refreshToken, + required this.expiresIn, + }); + + AuthSession copyWith({ + AuthUser? user, + String? accessToken, + String? refreshToken, + int? expiresIn, + }) { + return AuthSession( + user: user ?? this.user, + accessToken: accessToken ?? this.accessToken, + refreshToken: refreshToken ?? this.refreshToken, + expiresIn: expiresIn ?? this.expiresIn, + ); + } +} diff --git a/frontend/genex-mobile/lib/features/auth/domain/entities/auth_user.dart b/frontend/genex-mobile/lib/features/auth/domain/entities/auth_user.dart new file mode 100644 index 0000000..dcad689 --- /dev/null +++ b/frontend/genex-mobile/lib/features/auth/domain/entities/auth_user.dart @@ -0,0 +1,76 @@ +// ============================================================ +// AuthUser — 认证域用户实体 +// 纯 Dart 类,不依赖任何框架 +// ============================================================ + +class AuthUser { + final String id; + final String? phone; + final String? email; + final String? nickname; + final String? avatar; + final String? kycStatus; + final bool isActive; + + const AuthUser({ + required this.id, + this.phone, + this.email, + this.nickname, + this.avatar, + this.kycStatus, + this.isActive = true, + }); + + factory AuthUser.fromJson(Map json) { + return AuthUser( + id: json['id']?.toString() ?? '', + phone: json['phone'] as String?, + email: json['email'] as String?, + nickname: json['nickname'] as String?, + avatar: json['avatar'] as String?, + kycStatus: json['kycStatus'] as String?, + isActive: json['isActive'] as bool? ?? true, + ); + } + + Map toJson() => { + 'id': id, + if (phone != null) 'phone': phone, + if (email != null) 'email': email, + if (nickname != null) 'nickname': nickname, + if (avatar != null) 'avatar': avatar, + if (kycStatus != null) 'kycStatus': kycStatus, + 'isActive': isActive, + }; + + AuthUser copyWith({ + String? id, + String? phone, + String? email, + String? nickname, + String? avatar, + String? kycStatus, + bool? isActive, + }) { + return AuthUser( + id: id ?? this.id, + phone: phone ?? this.phone, + email: email ?? this.email, + nickname: nickname ?? this.nickname, + avatar: avatar ?? this.avatar, + kycStatus: kycStatus ?? this.kycStatus, + isActive: isActive ?? this.isActive, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || (other is AuthUser && other.id == id); + + @override + int get hashCode => id.hashCode; + + @override + String toString() => 'AuthUser(id=$id, phone=$phone, email=$email)'; +} diff --git a/frontend/genex-mobile/lib/features/auth/domain/repositories/auth_repository.dart b/frontend/genex-mobile/lib/features/auth/domain/repositories/auth_repository.dart new file mode 100644 index 0000000..2d240a8 --- /dev/null +++ b/frontend/genex-mobile/lib/features/auth/domain/repositories/auth_repository.dart @@ -0,0 +1,116 @@ +// ============================================================ +// IAuthRepository — 认证仓库接口(域层) +// +// 实现位于 data/ 层,域层不依赖任何具体实现。 +// ============================================================ + +import '../entities/auth_session.dart'; +import '../../../../core/services/auth_service.dart' show SmsCodeType, EmailCodeType; + +export '../../../../core/services/auth_service.dart' show SmsCodeType, EmailCodeType; + +abstract class IAuthRepository { + // ── 会话恢复 ──────────────────────────────────────────────── + /// 从安全存储恢复上次会话;返回 null 表示无会话 + Future restoreSession(); + + // ── 密码登录 ───────────────────────────────────────────────── + Future loginByPassword({ + required String identifier, + required String password, + String? deviceInfo, + }); + + // ── SMS 验证码登录 ──────────────────────────────────────────── + Future loginByPhone({ + required String phone, + required String smsCode, + String? deviceInfo, + }); + + // ── 邮件验证码登录 ──────────────────────────────────────────── + Future loginByEmail({ + required String email, + required String emailCode, + String? deviceInfo, + }); + + // ── 第三方登录 ──────────────────────────────────────────────── + Future loginByWechat({ + required String code, + String? referralCode, + String? deviceInfo, + }); + + Future loginByAlipay({ + required String authCode, + String? referralCode, + String? deviceInfo, + }); + + Future loginByGoogle({ + required String idToken, + String? referralCode, + String? deviceInfo, + }); + + Future loginByApple({ + required String identityToken, + String? displayName, + String? referralCode, + String? deviceInfo, + }); + + // ── 注册 ────────────────────────────────────────────────────── + Future register({ + required String phone, + required String smsCode, + required String password, + String? nickname, + String? referralCode, + }); + + Future registerByEmail({ + required String email, + required String emailCode, + required String password, + String? nickname, + String? referralCode, + }); + + // ── 登出 ────────────────────────────────────────────────────── + Future logout(); + + // ── 验证码 ──────────────────────────────────────────────────── + Future sendSmsCode(String phone, SmsCodeType type); + Future sendEmailCode(String email, EmailCodeType type); + + // ── 密码管理 ───────────────────────────────────────────────── + Future resetPassword({ + required String phone, + required String smsCode, + required String newPassword, + }); + + Future resetPasswordByEmail({ + required String email, + required String emailCode, + required String newPassword, + }); + + Future changePassword({ + required String oldPassword, + required String newPassword, + }); + + Future changePhone({ + required String newPhone, + required String newSmsCode, + }); + + // ── 支付宝工具 ──────────────────────────────────────────────── + Future getAlipayAuthString(); + + // ── 推荐码 ──────────────────────────────────────────────────── + Future validateReferralCode(String code); +} diff --git a/frontend/genex-mobile/lib/features/auth/domain/usecases/auth_usecases.dart b/frontend/genex-mobile/lib/features/auth/domain/usecases/auth_usecases.dart new file mode 100644 index 0000000..eb5019d --- /dev/null +++ b/frontend/genex-mobile/lib/features/auth/domain/usecases/auth_usecases.dart @@ -0,0 +1,159 @@ +// ============================================================ +// Auth Misc UseCases — 登出、Session恢复、验证码、密码管理 +// ============================================================ + +import '../entities/auth_session.dart'; +import '../repositories/auth_repository.dart'; + +// ── Session 恢复 ───────────────────────────────────────────── + +class RestoreSessionUseCase { + final IAuthRepository _repository; + const RestoreSessionUseCase(this._repository); + + Future call() => _repository.restoreSession(); +} + +// ── 登出 ────────────────────────────────────────────────────── + +class LogoutUseCase { + final IAuthRepository _repository; + const LogoutUseCase(this._repository); + + Future call() => _repository.logout(); +} + +// ── SMS 验证码 ──────────────────────────────────────────────── + +class SendSmsCodeParams { + final String phone; + final SmsCodeType type; + const SendSmsCodeParams({required this.phone, required this.type}); +} + +class SendSmsCodeUseCase { + final IAuthRepository _repository; + const SendSmsCodeUseCase(this._repository); + + Future call(SendSmsCodeParams params) => + _repository.sendSmsCode(params.phone, params.type); +} + +// ── 邮件验证码 ──────────────────────────────────────────────── + +class SendEmailCodeParams { + final String email; + final EmailCodeType type; + const SendEmailCodeParams({required this.email, required this.type}); +} + +class SendEmailCodeUseCase { + final IAuthRepository _repository; + const SendEmailCodeUseCase(this._repository); + + Future call(SendEmailCodeParams params) => + _repository.sendEmailCode(params.email, params.type); +} + +// ── 重置密码(手机) ────────────────────────────────────────── + +class ResetPasswordParams { + final String phone; + final String smsCode; + final String newPassword; + const ResetPasswordParams({ + required this.phone, + required this.smsCode, + required this.newPassword, + }); +} + +class ResetPasswordUseCase { + final IAuthRepository _repository; + const ResetPasswordUseCase(this._repository); + + Future call(ResetPasswordParams params) => _repository.resetPassword( + phone: params.phone, + smsCode: params.smsCode, + newPassword: params.newPassword, + ); +} + +// ── 重置密码(邮件)────────────────────────────────────────── + +class ResetPasswordByEmailParams { + final String email; + final String emailCode; + final String newPassword; + const ResetPasswordByEmailParams({ + required this.email, + required this.emailCode, + required this.newPassword, + }); +} + +class ResetPasswordByEmailUseCase { + final IAuthRepository _repository; + const ResetPasswordByEmailUseCase(this._repository); + + Future call(ResetPasswordByEmailParams params) => + _repository.resetPasswordByEmail( + email: params.email, + emailCode: params.emailCode, + newPassword: params.newPassword, + ); +} + +// ── 修改密码 ────────────────────────────────────────────────── + +class ChangePasswordParams { + final String oldPassword; + final String newPassword; + const ChangePasswordParams({required this.oldPassword, required this.newPassword}); +} + +class ChangePasswordUseCase { + final IAuthRepository _repository; + const ChangePasswordUseCase(this._repository); + + Future call(ChangePasswordParams params) => _repository.changePassword( + oldPassword: params.oldPassword, + newPassword: params.newPassword, + ); +} + +// ── 换绑手机 ────────────────────────────────────────────────── + +class ChangePhoneParams { + final String newPhone; + final String newSmsCode; + const ChangePhoneParams({required this.newPhone, required this.newSmsCode}); +} + +class ChangePhoneUseCase { + final IAuthRepository _repository; + const ChangePhoneUseCase(this._repository); + + Future call(ChangePhoneParams params) => _repository.changePhone( + newPhone: params.newPhone, + newSmsCode: params.newSmsCode, + ); +} + +// ── 推荐码验证 ──────────────────────────────────────────────── + +class ValidateReferralCodeUseCase { + final IAuthRepository _repository; + const ValidateReferralCodeUseCase(this._repository); + + Future call(String code) => _repository.validateReferralCode(code); +} + +// ── 支付宝授权字符串 ────────────────────────────────────────── + +class GetAlipayAuthStringUseCase { + final IAuthRepository _repository; + const GetAlipayAuthStringUseCase(this._repository); + + Future call() => _repository.getAlipayAuthString(); +} diff --git a/frontend/genex-mobile/lib/features/auth/domain/usecases/login_usecases.dart b/frontend/genex-mobile/lib/features/auth/domain/usecases/login_usecases.dart new file mode 100644 index 0000000..337b965 --- /dev/null +++ b/frontend/genex-mobile/lib/features/auth/domain/usecases/login_usecases.dart @@ -0,0 +1,152 @@ +// ============================================================ +// Login UseCases — 各种登录方式的用例 +// ============================================================ + +import '../entities/auth_session.dart'; +import '../repositories/auth_repository.dart'; + +// ── 密码登录 ──────────────────────────────────────────────── + +class LoginByPasswordParams { + final String identifier; + final String password; + final String? deviceInfo; + const LoginByPasswordParams({ + required this.identifier, + required this.password, + this.deviceInfo, + }); +} + +class LoginByPasswordUseCase { + final IAuthRepository _repository; + const LoginByPasswordUseCase(this._repository); + + Future call(LoginByPasswordParams params) { + return _repository.loginByPassword( + identifier: params.identifier, + password: params.password, + deviceInfo: params.deviceInfo, + ); + } +} + +// ── SMS 验证码登录 ──────────────────────────────────────────── + +class LoginByPhoneParams { + final String phone; + final String smsCode; + final String? deviceInfo; + const LoginByPhoneParams({ + required this.phone, + required this.smsCode, + this.deviceInfo, + }); +} + +class LoginByPhoneUseCase { + final IAuthRepository _repository; + const LoginByPhoneUseCase(this._repository); + + Future call(LoginByPhoneParams params) { + return _repository.loginByPhone( + phone: params.phone, + smsCode: params.smsCode, + deviceInfo: params.deviceInfo, + ); + } +} + +// ── 邮件验证码登录 ──────────────────────────────────────────── + +class LoginByEmailParams { + final String email; + final String emailCode; + const LoginByEmailParams({required this.email, required this.emailCode}); +} + +class LoginByEmailUseCase { + final IAuthRepository _repository; + const LoginByEmailUseCase(this._repository); + + Future call(LoginByEmailParams params) { + return _repository.loginByEmail(email: params.email, emailCode: params.emailCode); + } +} + +// ── 微信登录 ────────────────────────────────────────────────── + +class LoginByWechatParams { + final String code; + final String? referralCode; + const LoginByWechatParams({required this.code, this.referralCode}); +} + +class LoginByWechatUseCase { + final IAuthRepository _repository; + const LoginByWechatUseCase(this._repository); + + Future call(LoginByWechatParams params) { + return _repository.loginByWechat(code: params.code, referralCode: params.referralCode); + } +} + +// ── 支付宝登录 ──────────────────────────────────────────────── + +class LoginByAlipayParams { + final String authCode; + final String? referralCode; + const LoginByAlipayParams({required this.authCode, this.referralCode}); +} + +class LoginByAlipayUseCase { + final IAuthRepository _repository; + const LoginByAlipayUseCase(this._repository); + + Future call(LoginByAlipayParams params) { + return _repository.loginByAlipay(authCode: params.authCode, referralCode: params.referralCode); + } +} + +// ── Google 登录 ──────────────────────────────────────────────── + +class LoginByGoogleParams { + final String idToken; + final String? referralCode; + const LoginByGoogleParams({required this.idToken, this.referralCode}); +} + +class LoginByGoogleUseCase { + final IAuthRepository _repository; + const LoginByGoogleUseCase(this._repository); + + Future call(LoginByGoogleParams params) { + return _repository.loginByGoogle(idToken: params.idToken, referralCode: params.referralCode); + } +} + +// ── Apple 登录 ──────────────────────────────────────────────── + +class LoginByAppleParams { + final String identityToken; + final String? displayName; + final String? referralCode; + const LoginByAppleParams({ + required this.identityToken, + this.displayName, + this.referralCode, + }); +} + +class LoginByAppleUseCase { + final IAuthRepository _repository; + const LoginByAppleUseCase(this._repository); + + Future call(LoginByAppleParams params) { + return _repository.loginByApple( + identityToken: params.identityToken, + displayName: params.displayName, + referralCode: params.referralCode, + ); + } +} diff --git a/frontend/genex-mobile/lib/features/auth/domain/usecases/register_usecases.dart b/frontend/genex-mobile/lib/features/auth/domain/usecases/register_usecases.dart new file mode 100644 index 0000000..a161d74 --- /dev/null +++ b/frontend/genex-mobile/lib/features/auth/domain/usecases/register_usecases.dart @@ -0,0 +1,66 @@ +// ============================================================ +// Register UseCases +// ============================================================ + +import '../entities/auth_session.dart'; +import '../repositories/auth_repository.dart'; + +class RegisterParams { + final String phone; + final String smsCode; + final String password; + final String? nickname; + final String? referralCode; + const RegisterParams({ + required this.phone, + required this.smsCode, + required this.password, + this.nickname, + this.referralCode, + }); +} + +class RegisterUseCase { + final IAuthRepository _repository; + const RegisterUseCase(this._repository); + + Future call(RegisterParams params) { + return _repository.register( + phone: params.phone, + smsCode: params.smsCode, + password: params.password, + nickname: params.nickname, + referralCode: params.referralCode, + ); + } +} + +class RegisterByEmailParams { + final String email; + final String emailCode; + final String password; + final String? nickname; + final String? referralCode; + const RegisterByEmailParams({ + required this.email, + required this.emailCode, + required this.password, + this.nickname, + this.referralCode, + }); +} + +class RegisterByEmailUseCase { + final IAuthRepository _repository; + const RegisterByEmailUseCase(this._repository); + + Future call(RegisterByEmailParams params) { + return _repository.registerByEmail( + email: params.email, + emailCode: params.emailCode, + password: params.password, + nickname: params.nickname, + referralCode: params.referralCode, + ); + } +} diff --git a/frontend/genex-mobile/lib/features/auth/presentation/pages/forgot_password_page.dart b/frontend/genex-mobile/lib/features/auth/presentation/pages/forgot_password_page.dart index 26534a2..dd1dbdc 100644 --- a/frontend/genex-mobile/lib/features/auth/presentation/pages/forgot_password_page.dart +++ b/frontend/genex-mobile/lib/features/auth/presentation/pages/forgot_password_page.dart @@ -1,22 +1,24 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dio/dio.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 '../../../../shared/widgets/countdown_button.dart'; -import '../../../../core/services/auth_service.dart'; +import '../../../../core/services/auth_service.dart' show SmsCodeType; import '../../../../app/i18n/app_localizations.dart'; +import '../providers/auth_provider.dart'; /// A1. 忘记密码 - 手机号验证 → 输入验证码 → 设置新密码 → 成功 -class ForgotPasswordPage extends StatefulWidget { +class ForgotPasswordPage extends ConsumerStatefulWidget { const ForgotPasswordPage({super.key}); @override - State createState() => _ForgotPasswordPageState(); + ConsumerState createState() => _ForgotPasswordPageState(); } -class _ForgotPasswordPageState extends State { +class _ForgotPasswordPageState extends ConsumerState { int _step = 0; // 0: 输入账号, 1: 验证码, 2: 新密码, 3: 成功 final _phoneController = TextEditingController(); final _codeController = TextEditingController(); @@ -27,8 +29,6 @@ class _ForgotPasswordPageState extends State { bool _loading = false; String? _errorMessage; - final _authService = AuthService.instance; - @override void dispose() { _phoneController.dispose(); @@ -55,7 +55,7 @@ class _ForgotPasswordPageState extends State { } setState(() => _errorMessage = null); try { - await _authService.sendSmsCode(phone, SmsCodeType.resetPassword); + await ref.read(authProvider.notifier).sendSmsCode(phone, SmsCodeType.resetPassword); setState(() => _step = 1); } on DioException catch (e) { setState(() => _errorMessage = _extractError(e)); @@ -88,7 +88,7 @@ class _ForgotPasswordPageState extends State { setState(() { _loading = true; _errorMessage = null; }); try { - await _authService.resetPassword( + await ref.read(authProvider.notifier).resetPassword( phone: _phoneController.text.trim(), smsCode: _codeController.text.trim(), newPassword: password, @@ -106,7 +106,7 @@ class _ForgotPasswordPageState extends State { /* ── Step 1: 重新发送 ── */ Future _handleResend() async { try { - await _authService.sendSmsCode( + await ref.read(authProvider.notifier).sendSmsCode( _phoneController.text.trim(), SmsCodeType.resetPassword, ); diff --git a/frontend/genex-mobile/lib/features/auth/presentation/pages/login_page.dart b/frontend/genex-mobile/lib/features/auth/presentation/pages/login_page.dart index 30af2f8..aa13db3 100644 --- a/frontend/genex-mobile/lib/features/auth/presentation/pages/login_page.dart +++ b/frontend/genex-mobile/lib/features/auth/presentation/pages/login_page.dart @@ -1,22 +1,24 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dio/dio.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 '../../../../shared/widgets/countdown_button.dart'; -import '../../../../core/services/auth_service.dart'; +import '../../../../core/services/auth_service.dart' show SmsCodeType; import '../../../../app/i18n/app_localizations.dart'; +import '../providers/auth_provider.dart'; /// A1. 登录页 - 密码登录 / 验证码快捷登录 -class LoginPage extends StatefulWidget { +class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @override - State createState() => _LoginPageState(); + ConsumerState createState() => _LoginPageState(); } -class _LoginPageState extends State with SingleTickerProviderStateMixin { +class _LoginPageState extends ConsumerState with SingleTickerProviderStateMixin { late TabController _tabController; final _phoneController = TextEditingController(); final _passwordController = TextEditingController(); @@ -26,8 +28,6 @@ class _LoginPageState extends State with SingleTickerProviderStateMix bool _loading = false; String? _errorMessage; - final _authService = AuthService.instance; - @override void initState() { super.initState(); @@ -75,7 +75,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix setState(() { _loading = true; _errorMessage = null; }); try { - await _authService.loginByPassword( + await ref.read(authProvider.notifier).loginByPassword( identifier: identifier, password: password, ); @@ -98,7 +98,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix } setState(() => _errorMessage = null); try { - await _authService.sendSmsCode(phone, SmsCodeType.login); + await ref.read(authProvider.notifier).sendSmsCode(phone, SmsCodeType.login); } on DioException catch (e) { _showError(_extractError(e)); rethrow; @@ -121,7 +121,7 @@ class _LoginPageState extends State with SingleTickerProviderStateMix setState(() { _loading = true; _errorMessage = null; }); try { - await _authService.loginByPhone(phone: phone, smsCode: code); + await ref.read(authProvider.notifier).loginByPhone(phone: phone, smsCode: code); if (mounted) Navigator.pushReplacementNamed(context, '/main'); } on DioException catch (e) { _showError(_extractError(e)); diff --git a/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart b/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart index f493440..b607a21 100644 --- a/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart +++ b/frontend/genex-mobile/lib/features/auth/presentation/pages/register_page.dart @@ -1,26 +1,28 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dio/dio.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 '../../../../shared/widgets/countdown_button.dart'; -import '../../../../core/services/auth_service.dart'; +import '../../../../core/services/auth_service.dart' show SmsCodeType, EmailCodeType; import '../../../../app/i18n/app_localizations.dart'; +import '../providers/auth_provider.dart'; /// A1. 手机号注册页 /// /// 3步流程: 输入手机号+验证码 → 设置密码 → 完成注册 -class RegisterPage extends StatefulWidget { +class RegisterPage extends ConsumerStatefulWidget { final bool isEmail; const RegisterPage({super.key, this.isEmail = false}); @override - State createState() => _RegisterPageState(); + ConsumerState createState() => _RegisterPageState(); } -class _RegisterPageState extends State { +class _RegisterPageState extends ConsumerState { final _accountController = TextEditingController(); final _codeController = TextEditingController(); final _passwordController = TextEditingController(); @@ -32,8 +34,6 @@ class _RegisterPageState extends State { // null=未验证, true=有效, false=无效 bool? _referralCodeValid; - final _authService = AuthService.instance; - @override void initState() { super.initState(); @@ -55,7 +55,7 @@ class _RegisterPageState extends State { return; } try { - final resp = await _authService.validateReferralCode(code); + final resp = await ref.read(authProvider.notifier).validateReferralCode(code); setState(() => _referralCodeValid = resp); } catch (_) { setState(() => _referralCodeValid = false); @@ -82,9 +82,9 @@ class _RegisterPageState extends State { setState(() => _errorMessage = null); try { if (widget.isEmail) { - await _authService.sendEmailCode(account, EmailCodeType.register); + await ref.read(authProvider.notifier).sendEmailCode(account, EmailCodeType.register); } else { - await _authService.sendSmsCode(account, SmsCodeType.register); + await ref.read(authProvider.notifier).sendSmsCode(account, SmsCodeType.register); } } on DioException catch (e) { setState(() => _errorMessage = _extractError(e)); @@ -123,14 +123,14 @@ class _RegisterPageState extends State { try { final referralCode = _referralCodeController.text.trim(); if (widget.isEmail) { - await _authService.registerByEmail( + await ref.read(authProvider.notifier).registerByEmail( email: account, emailCode: code, password: password, referralCode: referralCode.isNotEmpty ? referralCode : null, ); } else { - await _authService.register( + await ref.read(authProvider.notifier).register( phone: account, smsCode: code, password: password, diff --git a/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart b/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart index 32bea08..c763e38 100644 --- a/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart +++ b/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart @@ -1,5 +1,6 @@ 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'; @@ -9,7 +10,7 @@ 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'; +import '../providers/auth_provider.dart'; /// A1. 欢迎页 - 品牌展示 + 注册/登录入口 /// @@ -32,14 +33,14 @@ import '../../../../core/services/auth_service.dart'; /// 支付宝: 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 StatefulWidget { +class WelcomePage extends ConsumerStatefulWidget { const WelcomePage({super.key}); @override - State createState() => _WelcomePageState(); + ConsumerState createState() => _WelcomePageState(); } -class _WelcomePageState extends State { +class _WelcomePageState extends ConsumerState { bool _wechatLoading = false; bool _alipayLoading = false; bool _googleLoading = false; @@ -105,7 +106,7 @@ class _WelcomePageState extends State { setState(() => _wechatLoading = false); if ((resp.errCode ?? -1) != 0 || resp.code == null) return; try { - await AuthService.instance.loginByWechat(code: resp.code!); + await ref.read(authProvider.notifier).loginByWechat(code: resp.code!); if (mounted) Navigator.pushReplacementNamed(context, '/main'); } catch (e) { if (mounted) { @@ -164,7 +165,7 @@ class _WelcomePageState extends State { // 2. 传给 Tobias().auth(authString) 拉起支付宝 App // 3. 用户授权后 result['result'] 中含 auth_code(同旧流程) // 4. auth_code → POST /auth/alipay(不变) - final authString = await AuthService.instance.getAlipayAuthString(); + final authString = await ref.read(authProvider.notifier).getAlipayAuthString(); final result = await _tobias.auth(authString); final status = result['resultStatus']?.toString() ?? ''; @@ -184,7 +185,7 @@ class _WelcomePageState extends State { } // 将 auth_code 发送后端,后端完成 token 换取 + 用户信息获取 - await AuthService.instance.loginByAlipay(authCode: authCode); + await ref.read(authProvider.notifier).loginByAlipay(authCode: authCode); if (mounted) Navigator.pushReplacementNamed(context, '/main'); } catch (e) { if (mounted) { @@ -247,7 +248,7 @@ class _WelcomePageState extends State { if (idToken == null) throw Exception('未获取到 ID Token'); // 将 idToken 发送后端验证(Google tokeninfo API 或 JWKS 本地验证) - await AuthService.instance.loginByGoogle(idToken: idToken); + await ref.read(authProvider.notifier).loginByGoogle(idToken: idToken); if (mounted) Navigator.pushReplacementNamed(context, '/main'); } catch (e) { if (mounted) { @@ -309,7 +310,7 @@ class _WelcomePageState extends State { // - 后端用 Apple JWKS 验证 identityToken 签名 // - 提取 sub 作为用户唯一标识(后续登录同一用户) // - 首次注册时用 email + displayName 初始化用户资料 - await AuthService.instance.loginByApple( + await ref.read(authProvider.notifier).loginByApple( identityToken: identityToken, displayName: displayName.isNotEmpty ? displayName : null, ); diff --git a/frontend/genex-mobile/lib/features/auth/presentation/providers/auth_provider.dart b/frontend/genex-mobile/lib/features/auth/presentation/providers/auth_provider.dart new file mode 100644 index 0000000..390b05e --- /dev/null +++ b/frontend/genex-mobile/lib/features/auth/presentation/providers/auth_provider.dart @@ -0,0 +1,327 @@ +// ============================================================ +// AuthProvider — 认证状态 Riverpod Provider +// +// 管理 App 全局登录状态,替代原有的 ValueNotifier 模式。 +// 所有页面通过 ref.watch(authStateProvider) 感知认证状态变化。 +// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../domain/entities/auth_user.dart'; +import '../../domain/entities/auth_session.dart'; +import '../../domain/repositories/auth_repository.dart'; +import '../../data/repositories/auth_repository_impl.dart'; + +// ── Repository Provider ─────────────────────────────────────── + +final authRepositoryProvider = Provider((ref) { + return AuthRepositoryImpl(); +}); + +// ── Auth State ──────────────────────────────────────────────── + +class AuthState { + final AuthUser? user; + final String? accessToken; + final String? refreshToken; + final bool isLoading; + final String? error; + + const AuthState({ + this.user, + this.accessToken, + this.refreshToken, + this.isLoading = false, + this.error, + }); + + bool get isAuthenticated => user != null && accessToken != null; + + const AuthState.initial() : this(isLoading: false); + + AuthState copyWith({ + AuthUser? user, + String? accessToken, + String? refreshToken, + bool? isLoading, + String? error, + bool clearUser = false, + bool clearError = false, + }) { + return AuthState( + user: clearUser ? null : (user ?? this.user), + accessToken: clearUser ? null : (accessToken ?? this.accessToken), + refreshToken: clearUser ? null : (refreshToken ?? this.refreshToken), + isLoading: isLoading ?? this.isLoading, + error: clearError ? null : (error ?? this.error), + ); + } +} + +// ── Auth Notifier ───────────────────────────────────────────── + +class AuthNotifier extends Notifier { + @override + AuthState build() => const AuthState.initial(); + + IAuthRepository get _repo => ref.read(authRepositoryProvider); + + /// 从 AuthSession 更新状态 + void _setSession(AuthSession session) { + state = AuthState( + user: session.user, + accessToken: session.accessToken, + refreshToken: session.refreshToken, + ); + } + + /// Session 过期时由 ApiClient 回调触发(配合 main.dart 中的监听) + void onSessionExpired() { + state = const AuthState.initial(); + } + + // ── 会话恢复 ─────────────────────────────────────────────── + + Future restoreSession() async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final session = await _repo.restoreSession(); + if (session != null) { + _setSession(session); + return true; + } + state = state.copyWith(isLoading: false, clearUser: true); + return false; + } catch (e) { + state = state.copyWith(isLoading: false, clearUser: true); + return false; + } + } + + // ── 密码登录 ────────────────────────────────────────────── + + Future loginByPassword({ + required String identifier, + required String password, + String? deviceInfo, + }) async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final session = await _repo.loginByPassword( + identifier: identifier, + password: password, + deviceInfo: deviceInfo, + ); + _setSession(session); + } catch (e) { + state = state.copyWith(isLoading: false, error: _extractMessage(e)); + rethrow; + } + } + + // ── SMS 验证码登录 ──────────────────────────────────────── + + Future loginByPhone({ + required String phone, + required String smsCode, + String? deviceInfo, + }) async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final session = await _repo.loginByPhone( + phone: phone, + smsCode: smsCode, + deviceInfo: deviceInfo, + ); + _setSession(session); + } catch (e) { + state = state.copyWith(isLoading: false, error: _extractMessage(e)); + rethrow; + } + } + + // ── 邮件验证码登录 ──────────────────────────────────────── + + Future loginByEmail({required String email, required String emailCode}) async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final session = await _repo.loginByEmail(email: email, emailCode: emailCode); + _setSession(session); + } catch (e) { + state = state.copyWith(isLoading: false, error: _extractMessage(e)); + rethrow; + } + } + + // ── 第三方登录 ──────────────────────────────────────────── + + Future loginByWechat({required String code, String? referralCode}) async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final session = await _repo.loginByWechat(code: code, referralCode: referralCode); + _setSession(session); + } catch (e) { + state = state.copyWith(isLoading: false, error: _extractMessage(e)); + rethrow; + } + } + + Future loginByAlipay({required String authCode, String? referralCode}) async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final session = await _repo.loginByAlipay(authCode: authCode, referralCode: referralCode); + _setSession(session); + } catch (e) { + state = state.copyWith(isLoading: false, error: _extractMessage(e)); + rethrow; + } + } + + Future loginByGoogle({required String idToken, String? referralCode}) async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final session = await _repo.loginByGoogle(idToken: idToken, referralCode: referralCode); + _setSession(session); + } catch (e) { + state = state.copyWith(isLoading: false, error: _extractMessage(e)); + rethrow; + } + } + + Future loginByApple({ + required String identityToken, + String? displayName, + String? referralCode, + }) async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final session = await _repo.loginByApple( + identityToken: identityToken, + displayName: displayName, + referralCode: referralCode, + ); + _setSession(session); + } catch (e) { + state = state.copyWith(isLoading: false, error: _extractMessage(e)); + rethrow; + } + } + + // ── 注册 ────────────────────────────────────────────────── + + Future register({ + required String phone, + required String smsCode, + required String password, + String? nickname, + String? referralCode, + }) async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final session = await _repo.register( + phone: phone, + smsCode: smsCode, + password: password, + nickname: nickname, + referralCode: referralCode, + ); + _setSession(session); + } catch (e) { + state = state.copyWith(isLoading: false, error: _extractMessage(e)); + rethrow; + } + } + + Future registerByEmail({ + required String email, + required String emailCode, + required String password, + String? nickname, + String? referralCode, + }) async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final session = await _repo.registerByEmail( + email: email, + emailCode: emailCode, + password: password, + nickname: nickname, + referralCode: referralCode, + ); + _setSession(session); + } catch (e) { + state = state.copyWith(isLoading: false, error: _extractMessage(e)); + rethrow; + } + } + + // ── 登出 ────────────────────────────────────────────────── + + Future logout() async { + await _repo.logout(); + state = const AuthState.initial(); + } + + // ── 验证码 ──────────────────────────────────────────────── + + Future sendSmsCode(String phone, SmsCodeType type) => + _repo.sendSmsCode(phone, type); + + Future sendEmailCode(String email, EmailCodeType type) => + _repo.sendEmailCode(email, type); + + // ── 推荐码验证 ──────────────────────────────────────────── + + Future validateReferralCode(String code) => + _repo.validateReferralCode(code); + + // ── 密码管理 ────────────────────────────────────────────── + + Future resetPassword({ + required String phone, + required String smsCode, + required String newPassword, + }) => + _repo.resetPassword(phone: phone, smsCode: smsCode, newPassword: newPassword); + + Future resetPasswordByEmail({ + required String email, + required String emailCode, + required String newPassword, + }) => + _repo.resetPasswordByEmail( + email: email, + emailCode: emailCode, + newPassword: newPassword, + ); + + Future changePassword({ + required String oldPassword, + required String newPassword, + }) => + _repo.changePassword(oldPassword: oldPassword, newPassword: newPassword); + + Future getAlipayAuthString() => _repo.getAlipayAuthString(); + + // ── 私有工具 ────────────────────────────────────────────── + + String _extractMessage(Object e) { + final str = e.toString(); + // DioException 格式: "DioException [...]: message" + final match = RegExp(r'message["\s:]+([^"}\n]+)').firstMatch(str); + return match?.group(1)?.trim() ?? str; + } +} + +// ── Provider 导出 ───────────────────────────────────────────── + +final authProvider = NotifierProvider(AuthNotifier.new); + +/// 便捷选择器:当前登录用户(null = 未登录) +final currentUserProvider = Provider((ref) { + return ref.watch(authProvider).user; +}); + +/// 便捷选择器:是否已登录 +final isAuthenticatedProvider = Provider((ref) { + return ref.watch(authProvider).isAuthenticated; +}); diff --git a/frontend/genex-mobile/lib/features/coupons/presentation/pages/my_coupons_page.dart b/frontend/genex-mobile/lib/features/coupons/presentation/pages/my_coupons_page.dart index ed1fda9..d4a04e2 100644 --- a/frontend/genex-mobile/lib/features/coupons/presentation/pages/my_coupons_page.dart +++ b/frontend/genex-mobile/lib/features/coupons/presentation/pages/my_coupons_page.dart @@ -71,6 +71,7 @@ class _MyCouponsPageState extends State // Example: show empty state for expired tab if (filter == CouponStatus.expired) { return EmptyState.noCoupons( + context, onBrowse: () { // noop - market tab accessible from bottom nav }, @@ -164,7 +165,7 @@ class _MyCouponCard extends StatelessWidget { Text('${context.t('walletCoupons.faceValue')} \$${faceValue.toStringAsFixed(0)}', style: AppTypography.bodySmall), const SizedBox(width: 8), - _statusWidget, + _buildStatusWidget(context), ], ), ], @@ -206,16 +207,16 @@ class _MyCouponCard extends StatelessWidget { ); } - Widget get _statusWidget { + Widget _buildStatusWidget(BuildContext context) { switch (status) { case CouponStatus.active: - return StatusTags.active(); + return StatusTags.active(context); case CouponStatus.pending: - return StatusTags.pending(); + return StatusTags.pending(context); case CouponStatus.expired: - return StatusTags.expired(); + return StatusTags.expired(context); case CouponStatus.used: - return StatusTags.used(); + return StatusTags.used(context); } } diff --git a/frontend/genex-mobile/lib/features/coupons/presentation/providers/coupon_provider.dart b/frontend/genex-mobile/lib/features/coupons/presentation/providers/coupon_provider.dart new file mode 100644 index 0000000..0330c3b --- /dev/null +++ b/frontend/genex-mobile/lib/features/coupons/presentation/providers/coupon_provider.dart @@ -0,0 +1,48 @@ +// ============================================================ +// CouponProvider — 券功能 Riverpod Providers +// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/services/coupon_service.dart'; + +/// CouponApiService Provider(DI 入口) +final couponServiceProvider = Provider((ref) { + return CouponApiService(); +}); + +// ── Featured Coupons ────────────────────────────────────────── + +final featuredCouponsProvider = FutureProvider.autoDispose((ref) async { + return ref.read(couponServiceProvider).getFeaturedCoupons(); +}); + +// ── My Holdings ─────────────────────────────────────────────── + +class HoldingsParams { + final String? status; + final int page; + final int limit; + const HoldingsParams({this.status, this.page = 1, this.limit = 20}); +} + +final myHoldingsProvider = + FutureProvider.autoDispose.family((ref, params) async { + return ref.read(couponServiceProvider).getMyHoldings( + status: params.status, + page: params.page, + limit: params.limit, + ); +}); + +// ── Holdings Summary ────────────────────────────────────────── + +final holdingsSummaryProvider = FutureProvider.autoDispose((ref) async { + return ref.read(couponServiceProvider).getHoldingsSummary(); +}); + +// ── Coupon Detail ───────────────────────────────────────────── + +final couponDetailProvider = + FutureProvider.autoDispose.family((ref, couponId) async { + return ref.read(couponServiceProvider).getCouponDetail(couponId); +}); diff --git a/frontend/genex-mobile/lib/features/issuer/presentation/providers/issuer_provider.dart b/frontend/genex-mobile/lib/features/issuer/presentation/providers/issuer_provider.dart new file mode 100644 index 0000000..0a5ca4a --- /dev/null +++ b/frontend/genex-mobile/lib/features/issuer/presentation/providers/issuer_provider.dart @@ -0,0 +1,18 @@ +// ============================================================ +// IssuerProvider — 发行方 Riverpod Providers +// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/network/api_client.dart'; + +final issuerInfoProvider = FutureProvider.autoDispose((ref) async { + final api = ApiClient.instance; + final resp = await api.get('/api/v1/issuers/me'); + return resp.data['data'] as Map?; +}); + +final issuerCouponsProvider = FutureProvider.autoDispose((ref) async { + final api = ApiClient.instance; + final resp = await api.get('/api/v1/issuers/me/coupons'); + return resp.data['data']; +}); diff --git a/frontend/genex-mobile/lib/features/merchant/presentation/providers/merchant_provider.dart b/frontend/genex-mobile/lib/features/merchant/presentation/providers/merchant_provider.dart new file mode 100644 index 0000000..7ea8f14 --- /dev/null +++ b/frontend/genex-mobile/lib/features/merchant/presentation/providers/merchant_provider.dart @@ -0,0 +1,18 @@ +// ============================================================ +// MerchantProvider — 商家 Riverpod Providers +// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/network/api_client.dart'; + +final merchantInfoProvider = FutureProvider.autoDispose((ref) async { + final api = ApiClient.instance; + final resp = await api.get('/api/v1/merchants/me'); + return resp.data['data'] as Map?; +}); + +final merchantRedemptionsProvider = FutureProvider.autoDispose((ref) async { + final api = ApiClient.instance; + final resp = await api.get('/api/v1/merchants/me/redemptions'); + return resp.data['data']; +}); diff --git a/frontend/genex-mobile/lib/features/message/presentation/providers/message_provider.dart b/frontend/genex-mobile/lib/features/message/presentation/providers/message_provider.dart new file mode 100644 index 0000000..0156d1b --- /dev/null +++ b/frontend/genex-mobile/lib/features/message/presentation/providers/message_provider.dart @@ -0,0 +1,30 @@ +// ============================================================ +// MessageProvider — 通知/消息 Riverpod Providers +// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/services/notification_service.dart'; + +final notificationServiceProvider = Provider((ref) { + return NotificationService(); +}); + +// ── 通知列表 ────────────────────────────────────────────────── + +final notificationsProvider = + FutureProvider.autoDispose.family( + (ref, type) async { + return ref.read(notificationServiceProvider).getNotifications(type: type); +}); + +// ── 未读数量 ────────────────────────────────────────────────── + +final unreadCountProvider = FutureProvider.autoDispose((ref) async { + return ref.read(notificationServiceProvider).getUnreadCount(); +}); + +// ── 公告列表 ────────────────────────────────────────────────── + +final announcementsProvider = FutureProvider.autoDispose((ref) async { + return ref.read(notificationServiceProvider).getAnnouncements(); +}); diff --git a/frontend/genex-mobile/lib/features/profile/presentation/providers/profile_provider.dart b/frontend/genex-mobile/lib/features/profile/presentation/providers/profile_provider.dart new file mode 100644 index 0000000..101001e --- /dev/null +++ b/frontend/genex-mobile/lib/features/profile/presentation/providers/profile_provider.dart @@ -0,0 +1,30 @@ +// ============================================================ +// ProfileProvider — 用户资料 Riverpod Providers +// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/network/api_client.dart'; + +// ── 用户资料 ────────────────────────────────────────────────── + +final userProfileProvider = FutureProvider.autoDispose((ref) async { + final api = ApiClient.instance; + final resp = await api.get('/api/v1/users/me'); + return resp.data['data'] as Map?; +}); + +// ── KYC 状态 ────────────────────────────────────────────────── + +final kycStatusProvider = FutureProvider.autoDispose((ref) async { + final api = ApiClient.instance; + final resp = await api.get('/api/v1/users/kyc/status'); + return resp.data['data'] as Map?; +}); + +// ── 支付方式 ────────────────────────────────────────────────── + +final paymentMethodsProvider = FutureProvider.autoDispose((ref) async { + final api = ApiClient.instance; + final resp = await api.get('/api/v1/users/payment-methods'); + return resp.data['data']; +}); diff --git a/frontend/genex-mobile/lib/features/profile/presentation/providers/referral_provider.dart b/frontend/genex-mobile/lib/features/profile/presentation/providers/referral_provider.dart new file mode 100644 index 0000000..cfcd76a --- /dev/null +++ b/frontend/genex-mobile/lib/features/profile/presentation/providers/referral_provider.dart @@ -0,0 +1,22 @@ +// ============================================================ +// ReferralProvider — 推荐/邀请系统 Riverpod Providers +// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/services/referral_service.dart'; + +final referralServiceProvider = Provider((ref) { + return ReferralService.instance; +}); + +/// 当前用户推荐信息(自动缓存 5 分钟) +final myReferralInfoProvider = FutureProvider.autoDispose((ref) async { + return ref.read(referralServiceProvider).getMyInfo(); +}); + +/// 直接推荐列表 +final directReferralsProvider = + FutureProvider.autoDispose.family>, int>( + (ref, offset) async { + return ref.read(referralServiceProvider).getDirectReferrals(offset: offset); +}); diff --git a/frontend/genex-mobile/lib/features/redeem/presentation/providers/redeem_provider.dart b/frontend/genex-mobile/lib/features/redeem/presentation/providers/redeem_provider.dart new file mode 100644 index 0000000..0a1ee56 --- /dev/null +++ b/frontend/genex-mobile/lib/features/redeem/presentation/providers/redeem_provider.dart @@ -0,0 +1,46 @@ +// ============================================================ +// RedeemProvider — 核销 Riverpod Providers +// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/network/api_client.dart'; + +class RedeemState { + final bool isLoading; + final Map? result; + final String? error; + + const RedeemState({this.isLoading = false, this.result, this.error}); + + RedeemState copyWith({ + bool? isLoading, + Map? result, + String? error, + bool clearError = false, + }) { + return RedeemState( + isLoading: isLoading ?? this.isLoading, + result: result ?? this.result, + error: clearError ? null : (error ?? this.error), + ); + } +} + +class RedeemNotifier extends Notifier { + @override + RedeemState build() => const RedeemState(); + + Future redeemCoupon(String holdingId) async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final api = ApiClient.instance; + final resp = await api.post('/api/v1/redemptions', data: {'holdingId': holdingId}); + state = state.copyWith(isLoading: false, result: resp.data['data'] as Map?); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + rethrow; + } + } +} + +final redeemProvider = NotifierProvider(RedeemNotifier.new); diff --git a/frontend/genex-mobile/lib/features/support/presentation/providers/support_provider.dart b/frontend/genex-mobile/lib/features/support/presentation/providers/support_provider.dart new file mode 100644 index 0000000..2df489b --- /dev/null +++ b/frontend/genex-mobile/lib/features/support/presentation/providers/support_provider.dart @@ -0,0 +1,12 @@ +// ============================================================ +// SupportProvider — 客服/工单 Riverpod Providers +// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/network/api_client.dart'; + +final supportTicketsProvider = FutureProvider.autoDispose((ref) async { + final api = ApiClient.instance; + final resp = await api.get('/api/v1/support/tickets'); + return resp.data['data']; +}); diff --git a/frontend/genex-mobile/lib/features/trading/presentation/providers/trading_provider.dart b/frontend/genex-mobile/lib/features/trading/presentation/providers/trading_provider.dart new file mode 100644 index 0000000..9b35df0 --- /dev/null +++ b/frontend/genex-mobile/lib/features/trading/presentation/providers/trading_provider.dart @@ -0,0 +1,31 @@ +// ============================================================ +// TradingProvider — 交易 Riverpod Providers +// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/network/api_client.dart'; + +// ── 市场列表 ────────────────────────────────────────────────── + +final marketListProvider = FutureProvider.autoDispose((ref) async { + final api = ApiClient.instance; + final resp = await api.get('/api/v1/trading/markets'); + return resp.data['data']; +}); + +// ── 订单簿 ──────────────────────────────────────────────────── + +final orderBookProvider = + FutureProvider.autoDispose.family((ref, marketId) async { + final api = ApiClient.instance; + final resp = await api.get('/api/v1/trading/orderbook/$marketId'); + return resp.data['data']; +}); + +// ── 我的挂单 ────────────────────────────────────────────────── + +final myOpenOrdersProvider = FutureProvider.autoDispose((ref) async { + final api = ApiClient.instance; + final resp = await api.get('/api/v1/trading/orders', queryParameters: {'status': 'OPEN'}); + return resp.data['data']; +}); diff --git a/frontend/genex-mobile/lib/features/transfer/presentation/providers/transfer_provider.dart b/frontend/genex-mobile/lib/features/transfer/presentation/providers/transfer_provider.dart new file mode 100644 index 0000000..0463ade --- /dev/null +++ b/frontend/genex-mobile/lib/features/transfer/presentation/providers/transfer_provider.dart @@ -0,0 +1,49 @@ +// ============================================================ +// TransferProvider — 转赠 Riverpod Providers +// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/network/api_client.dart'; + +class TransferState { + final bool isLoading; + final bool isSuccess; + final String? error; + + const TransferState({this.isLoading = false, this.isSuccess = false, this.error}); + + TransferState copyWith({bool? isLoading, bool? isSuccess, String? error, bool clearError = false}) { + return TransferState( + isLoading: isLoading ?? this.isLoading, + isSuccess: isSuccess ?? this.isSuccess, + error: clearError ? null : (error ?? this.error), + ); + } +} + +class TransferNotifier extends Notifier { + @override + TransferState build() => const TransferState(); + + Future transfer({ + required String holdingId, + required String recipientPhone, + int quantity = 1, + }) async { + state = state.copyWith(isLoading: true, clearError: true, isSuccess: false); + try { + final api = ApiClient.instance; + await api.post('/api/v1/transfers', data: { + 'holdingId': holdingId, + 'recipientPhone': recipientPhone, + 'quantity': quantity, + }); + state = state.copyWith(isLoading: false, isSuccess: true); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + rethrow; + } + } +} + +final transferProvider = NotifierProvider(TransferNotifier.new); diff --git a/frontend/genex-mobile/lib/features/wallet/presentation/providers/wallet_provider.dart b/frontend/genex-mobile/lib/features/wallet/presentation/providers/wallet_provider.dart new file mode 100644 index 0000000..181d340 --- /dev/null +++ b/frontend/genex-mobile/lib/features/wallet/presentation/providers/wallet_provider.dart @@ -0,0 +1,40 @@ +// ============================================================ +// WalletProvider — 钱包 Riverpod Providers +// +// 钱包 API 直接通过 ApiClient 调用,无单独 Service 类。 +// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/network/api_client.dart'; + +final apiClientProvider = Provider((ref) { + return ApiClient.instance; +}); + +// ── 钱包余额 ────────────────────────────────────────────────── + +final walletBalanceProvider = FutureProvider.autoDispose((ref) async { + final api = ref.read(apiClientProvider); + final resp = await api.get('/api/v1/wallet/balance'); + return resp.data['data'] as Map?; +}); + +// ── 交易记录 ────────────────────────────────────────────────── + +class TransactionParams { + final int page; + final int pageSize; + final String? type; + const TransactionParams({this.page = 1, this.pageSize = 20, this.type}); +} + +final transactionRecordsProvider = + FutureProvider.autoDispose.family((ref, params) async { + final api = ref.read(apiClientProvider); + final resp = await api.get('/api/v1/wallet/transactions', queryParameters: { + 'page': params.page, + 'pageSize': params.pageSize, + if (params.type != null) 'type': params.type, + }); + return resp.data['data']; +}); diff --git a/frontend/genex-mobile/lib/main.dart b/frontend/genex-mobile/lib/main.dart index c6d6790..6c2f9c9 100644 --- a/frontend/genex-mobile/lib/main.dart +++ b/frontend/genex-mobile/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fluwx/fluwx.dart'; import 'app/theme/app_theme.dart'; import 'app/main_shell.dart'; @@ -14,6 +15,7 @@ import 'features/auth/presentation/pages/login_page.dart'; import 'features/auth/presentation/pages/welcome_page.dart'; import 'features/auth/presentation/pages/register_page.dart'; import 'features/auth/presentation/pages/forgot_password_page.dart'; +import 'features/auth/presentation/providers/auth_provider.dart'; import 'features/coupons/presentation/pages/coupon_detail_page.dart'; import 'features/coupons/presentation/pages/order_confirm_page.dart'; import 'features/coupons/presentation/pages/payment_page.dart'; @@ -44,27 +46,13 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); // ── 微信 SDK 初始化 (fluwx 5.x) ────────────────────────────────────── - // WECHAT_APP_ID 在构建时通过 --dart-define 注入,例如: - // flutter build apk --dart-define=WECHAT_APP_ID=wx0000000000000000 - // flutter build ipa --dart-define=WECHAT_APP_ID=wx0000000000000000 - // - // 未传入 WECHAT_APP_ID 时(本地开发 / CI 未配置),跳过初始化, - // WelcomePage 中点击微信按钮会提示「微信未安装」(isWeChatInstalled=false)。 - // - // universalLink: iOS Universal Links 地址,需在微信开放平台填写并配置 - // apple-app-site-association 文件(路径: https://www.gogenex.com/wechat/apple-app-site-association) - // 详见: https://developers.weixin.qq.com/doc/oplatform/Mobile_App/Universal_Links/Universal_Links.html - // - // fluwx 5.x 迁移说明: - // 旧版 (3.x): 顶层函数 registerWxApi(appId: ..., universalLink: ...) - // 新版 (5.x): 实例方法 Fluwx().registerApi(appId: ..., universalLink: ...) - // 注册只需执行一次(app 启动时),Fluwx 底层 MethodChannel 为单例, - // 后续在 WelcomePage 中新建 Fluwx() 实例可共享同一注册状态。 const wechatAppId = String.fromEnvironment('WECHAT_APP_ID', defaultValue: ''); if (wechatAppId.isNotEmpty) { - await Fluwx().registerApi(appId: wechatAppId, universalLink: 'https://www.gogenex.com/wechat/'); + await Fluwx().registerApi( + appId: wechatAppId, + universalLink: 'https://www.gogenex.com/wechat/', + ); } - // ───────────────────────────────────────────────────────────────────── // 初始化升级服务(走 Nginx 反向代理 → Kong 网关) UpdateService().initialize(UpdateConfig.selfHosted( @@ -81,64 +69,73 @@ Future main() async { // 恢复用户语言偏好(无选择时跟随系统语言) await LocaleManager.init(); - // 从安全存储恢复上次登录的 Token(若存在则自动进入 /main) - await AuthService.instance.restoreSession(); + // 从安全存储恢复上次登录的 Token + // ProviderScope 外提前恢复,确保 initialRoute 能正确判断 + final isLoggedIn = await AuthService.instance.restoreSession(); - runApp(const GenexConsumerApp()); + runApp( + ProviderScope( + child: GenexConsumerApp(initiallyLoggedIn: isLoggedIn), + ), + ); } /// Genex Mobile - 券的生命周期管理平台 -/// -/// 持仓/交易所/消息/个人中心 -/// 持有/接收/转赠/交易/核销数字券 -/// -/// 国际化:首次启动跟随系统语言,用户可在设置中切换 -class GenexConsumerApp extends StatefulWidget { - const GenexConsumerApp({super.key}); +class GenexConsumerApp extends ConsumerStatefulWidget { + final bool initiallyLoggedIn; + + const GenexConsumerApp({super.key, required this.initiallyLoggedIn}); @override - State createState() => _GenexConsumerAppState(); + ConsumerState createState() => _GenexConsumerAppState(); } -class _GenexConsumerAppState extends State { - // Navigator Key — 用于在无 BuildContext 时执行命令式导航(如 Session 过期后跳 /) +class _GenexConsumerAppState extends ConsumerState { final _navigatorKey = GlobalKey(); - // 上一帧的登录状态,用于检测 Session 过期(已登录 → 未登录) - bool _wasLoggedIn = false; - @override void initState() { super.initState(); LocaleManager.userLocale.addListener(_onLocaleChanged); - _wasLoggedIn = AuthService.instance.isLoggedIn; - AuthService.instance.authState.addListener(_onAuthStateChanged); + + // 监听 AuthService ValueNotifier → 同步到 Riverpod authProvider + // ApiClient 的 Token 刷新/过期回调仍走 AuthService,此处桥接到 Riverpod + AuthService.instance.authState.addListener(_onLegacyAuthChanged); + + // 启动时已登录,同步初始状态到 Riverpod authProvider + if (widget.initiallyLoggedIn) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(authProvider.notifier).restoreSession(); + }); + } } @override void dispose() { LocaleManager.userLocale.removeListener(_onLocaleChanged); - AuthService.instance.authState.removeListener(_onAuthStateChanged); + AuthService.instance.authState.removeListener(_onLegacyAuthChanged); super.dispose(); } - void _onLocaleChanged() { - setState(() {}); - } + void _onLocaleChanged() => setState(() {}); - /// Token 过期时由 ApiClient 拦截器触发 AuthService._clearAuth(), - /// 进而将 authState 置 null,此监听器感知变化后导航回欢迎页。 - void _onAuthStateChanged() { - final isLoggedIn = AuthService.instance.isLoggedIn; - if (_wasLoggedIn && !isLoggedIn) { - // Session 过期(非主动登出):清空路由栈,回到欢迎页 + /// AuthService ValueNotifier 变化时同步到 Riverpod authProvider + void _onLegacyAuthChanged() { + if (!mounted) return; + final legacyResult = AuthService.instance.authState.value; + final riverpodState = ref.read(authProvider); + + if (legacyResult == null && riverpodState.isAuthenticated) { + // Token 双重过期:AuthService 已清空,通知 Riverpod 同步 + ref.read(authProvider.notifier).onSessionExpired(); _navigatorKey.currentState?.pushNamedAndRemoveUntil('/', (_) => false); } - _wasLoggedIn = isLoggedIn; } @override Widget build(BuildContext context) { + final isAuthenticated = ref.watch(isAuthenticatedProvider); + return MaterialApp( title: 'Genex', theme: AppTheme.light, @@ -164,8 +161,8 @@ class _GenexConsumerAppState extends State { return LocaleManager.userLocale.value; }, - // 启动路由:已有保存的 Token → 直接进主界面;否则显示欢迎页 - initialRoute: AuthService.instance.isLoggedIn ? '/main' : '/', + // 启动路由:已有保存的 Token → 直接进主界面;否则欢迎页 + initialRoute: (widget.initiallyLoggedIn || isAuthenticated) ? '/main' : '/', onGenerateRoute: _generateRoute, ); } diff --git a/frontend/genex-mobile/pubspec.yaml b/frontend/genex-mobile/pubspec.yaml index bcf0a59..46f98d8 100644 --- a/frontend/genex-mobile/pubspec.yaml +++ b/frontend/genex-mobile/pubspec.yaml @@ -30,6 +30,8 @@ dependencies: tobias: ^5.0.0 google_sign_in: ^6.2.1 sign_in_with_apple: ^6.1.0 + flutter_riverpod: ^2.5.1 + fpdart: ^1.1.0 dev_dependencies: flutter_test: