feat(mining-app): fix login bugs and connect contribution page to real API
Login fixes:
- Add AuthEventBus for global 401 error handling with auto-logout
- Add route guards with GoRouter redirect to protect authenticated routes
- Remove setMockUser() security vulnerability and legacy login() dead code
- Remove unused AuthInterceptor class
Contribution page:
- Add ContributionRecord entity and model for records API
- Connect contribution details card to GET /accounts/{id}/records endpoint
- Display real team stats (direct referrals, unlocked levels/tiers)
- Calculate expiration countdown from actual record data
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a89f4c829d
commit
add405aa65
|
|
@ -755,7 +755,8 @@
|
|||
"Bash(ssh -o StrictHostKeyChecking=no ceshi@103.39.231.231 \"ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa ceshi@192.168.1.111 ''cd /home/ceshi/rwadurian/backend/services && git pull''\")",
|
||||
"Bash(set DATABASE_URL=postgresql://user:pass@localhost:5432/db)",
|
||||
"Bash(cmd /c \"set DATABASE_URL=postgresql://user:pass@localhost:5432/db && npx prisma migrate dev --name add_nickname_to_synced_legacy_users --create-only\")",
|
||||
"Bash(dir \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\")"
|
||||
"Bash(dir \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\")",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(mining-app\\): fix login bugs and connect contribution page to real API\n\nLogin fixes:\n- Add AuthEventBus for global 401 error handling with auto-logout\n- Add route guards with GoRouter redirect to protect authenticated routes\n- Remove setMockUser\\(\\) security vulnerability and legacy login\\(\\) dead code\n- Remove unused AuthInterceptor class\n\nContribution page:\n- Add ContributionRecord entity and model for records API\n- Connect contribution details card to GET /accounts/{id}/records endpoint\n- Display real team stats \\(direct referrals, unlocked levels/tiers\\)\n- Calculate expiration countdown from actual record data\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ final getIt = GetIt.instance;
|
|||
Future<void> configureDependencies() async {
|
||||
// Dio
|
||||
final dio = Dio();
|
||||
dio.interceptors.add(AuthInterceptor());
|
||||
dio.interceptors.add(LoggingInterceptor());
|
||||
getIt.registerSingleton<Dio>(dio);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,30 @@
|
|||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../constants/app_constants.dart';
|
||||
import '../error/exceptions.dart';
|
||||
|
||||
/// 全局未授权事件控制器,用于通知 401 错误
|
||||
class AuthEventBus {
|
||||
static final AuthEventBus _instance = AuthEventBus._internal();
|
||||
factory AuthEventBus() => _instance;
|
||||
AuthEventBus._internal();
|
||||
|
||||
final _unauthorizedController = StreamController<void>.broadcast();
|
||||
|
||||
/// 监听未授权事件
|
||||
Stream<void> get onUnauthorized => _unauthorizedController.stream;
|
||||
|
||||
/// 触发未授权事件
|
||||
void emitUnauthorized() {
|
||||
_unauthorizedController.add(null);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_unauthorizedController.close();
|
||||
}
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
final Dio dio;
|
||||
|
||||
|
|
@ -107,6 +129,8 @@ class ApiClient {
|
|||
case DioExceptionType.badResponse:
|
||||
final statusCode = e.response?.statusCode;
|
||||
if (statusCode == 401) {
|
||||
// 触发全局未授权事件,通知应用进行登出处理
|
||||
AuthEventBus().emitUnauthorized();
|
||||
return UnauthorizedException();
|
||||
}
|
||||
final message = e.response?.data?['error']?['message']?[0] ?? '服务器错误';
|
||||
|
|
|
|||
|
|
@ -1,26 +1,6 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
class AuthInterceptor extends Interceptor {
|
||||
String? _token;
|
||||
|
||||
void setToken(String token) {
|
||||
_token = token;
|
||||
}
|
||||
|
||||
void clearToken() {
|
||||
_token = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
if (_token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $_token';
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
|
||||
class LoggingInterceptor extends Interceptor {
|
||||
final _logger = Logger(
|
||||
printer: PrettyPrinter(methodCount: 0),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../presentation/pages/splash/splash_page.dart';
|
||||
|
|
@ -11,11 +13,85 @@ import '../../presentation/pages/trading/trading_page.dart';
|
|||
import '../../presentation/pages/asset/asset_page.dart';
|
||||
import '../../presentation/pages/profile/profile_page.dart';
|
||||
import '../../presentation/widgets/main_shell.dart';
|
||||
import '../../presentation/providers/user_providers.dart';
|
||||
import '../network/api_client.dart';
|
||||
import 'routes.dart';
|
||||
|
||||
/// 不需要登录就能访问的公开路由
|
||||
const _publicRoutes = {
|
||||
Routes.splash,
|
||||
Routes.login,
|
||||
Routes.register,
|
||||
Routes.forgotPassword,
|
||||
};
|
||||
|
||||
/// 路由刷新通知器,用于在登录状态变化时刷新路由
|
||||
class AuthNotifier extends ChangeNotifier {
|
||||
bool _isLoggedIn = false;
|
||||
StreamSubscription<void>? _unauthorizedSubscription;
|
||||
|
||||
AuthNotifier() {
|
||||
// 监听 401 未授权事件
|
||||
_unauthorizedSubscription = AuthEventBus().onUnauthorized.listen((_) {
|
||||
_isLoggedIn = false;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
bool get isLoggedIn => _isLoggedIn;
|
||||
|
||||
void updateLoginState(bool isLoggedIn) {
|
||||
if (_isLoggedIn != isLoggedIn) {
|
||||
_isLoggedIn = isLoggedIn;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unauthorizedSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final authNotifierProvider = ChangeNotifierProvider<AuthNotifier>((ref) {
|
||||
final authNotifier = AuthNotifier();
|
||||
|
||||
// 监听用户登录状态变化
|
||||
ref.listen<bool>(isLoggedInProvider, (previous, next) {
|
||||
authNotifier.updateLoginState(next);
|
||||
});
|
||||
|
||||
return authNotifier;
|
||||
});
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
final authNotifier = ref.watch(authNotifierProvider);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: Routes.splash,
|
||||
refreshListenable: authNotifier,
|
||||
redirect: (context, state) {
|
||||
final isLoggedIn = ref.read(isLoggedInProvider);
|
||||
final currentPath = state.uri.path;
|
||||
|
||||
// splash 页面不做重定向,让它自己处理初始化逻辑
|
||||
if (currentPath == Routes.splash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 未登录且访问受保护路由,重定向到登录页
|
||||
if (!isLoggedIn && !_publicRoutes.contains(currentPath)) {
|
||||
return Routes.login;
|
||||
}
|
||||
|
||||
// 已登录且访问登录页,重定向到首页
|
||||
if (isLoggedIn && currentPath == Routes.login) {
|
||||
return Routes.contribution;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.splash,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import '../../models/contribution_model.dart';
|
||||
import '../../models/contribution_record_model.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_endpoints.dart';
|
||||
import '../../../core/error/exceptions.dart';
|
||||
import '../../../domain/entities/contribution_record.dart';
|
||||
|
||||
abstract class ContributionRemoteDataSource {
|
||||
Future<ContributionModel> getUserContribution(String accountSequence);
|
||||
Future<ContributionRecordsPageModel> getContributionRecords(
|
||||
String accountSequence, {
|
||||
ContributionSourceType? sourceType,
|
||||
bool includeExpired = false,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
});
|
||||
}
|
||||
|
||||
class ContributionRemoteDataSourceImpl implements ContributionRemoteDataSource {
|
||||
|
|
@ -21,4 +30,44 @@ class ContributionRemoteDataSourceImpl implements ContributionRemoteDataSource {
|
|||
throw ServerException(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ContributionRecordsPageModel> getContributionRecords(
|
||||
String accountSequence, {
|
||||
ContributionSourceType? sourceType,
|
||||
bool includeExpired = false,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
'includeExpired': includeExpired,
|
||||
};
|
||||
|
||||
if (sourceType != null) {
|
||||
queryParams['sourceType'] = _sourceTypeToString(sourceType);
|
||||
}
|
||||
|
||||
final response = await client.get(
|
||||
ApiEndpoints.contributionRecords(accountSequence),
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
return ContributionRecordsPageModel.fromJson(response.data);
|
||||
} catch (e) {
|
||||
throw ServerException(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
String _sourceTypeToString(ContributionSourceType type) {
|
||||
switch (type) {
|
||||
case ContributionSourceType.personal:
|
||||
return 'PERSONAL';
|
||||
case ContributionSourceType.teamLevel:
|
||||
return 'TEAM_LEVEL';
|
||||
case ContributionSourceType.teamBonus:
|
||||
return 'TEAM_BONUS';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
import '../../domain/entities/contribution_record.dart';
|
||||
|
||||
class ContributionRecordModel extends ContributionRecord {
|
||||
const ContributionRecordModel({
|
||||
required super.id,
|
||||
required super.sourceType,
|
||||
required super.sourceAdoptionId,
|
||||
super.sourceAccountSequence,
|
||||
required super.treeCount,
|
||||
required super.baseContribution,
|
||||
required super.distributionRate,
|
||||
super.levelDepth,
|
||||
super.bonusTier,
|
||||
required super.finalContribution,
|
||||
required super.effectiveDate,
|
||||
required super.expireDate,
|
||||
required super.isExpired,
|
||||
required super.createdAt,
|
||||
});
|
||||
|
||||
factory ContributionRecordModel.fromJson(Map<String, dynamic> json) {
|
||||
return ContributionRecordModel(
|
||||
id: json['id']?.toString() ?? '',
|
||||
sourceType: _parseSourceType(json['sourceType']),
|
||||
sourceAdoptionId: json['sourceAdoptionId']?.toString() ?? '',
|
||||
sourceAccountSequence: json['sourceAccountSequence']?.toString(),
|
||||
treeCount: json['treeCount'] ?? 0,
|
||||
baseContribution: json['baseContribution']?.toString() ?? '0',
|
||||
distributionRate: json['distributionRate']?.toString() ?? '0',
|
||||
levelDepth: json['levelDepth'],
|
||||
bonusTier: json['bonusTier'],
|
||||
finalContribution: json['finalContribution']?.toString() ?? '0',
|
||||
effectiveDate: DateTime.parse(json['effectiveDate']),
|
||||
expireDate: DateTime.parse(json['expireDate']),
|
||||
isExpired: json['isExpired'] == true,
|
||||
createdAt: DateTime.parse(json['createdAt']),
|
||||
);
|
||||
}
|
||||
|
||||
static ContributionSourceType _parseSourceType(String? type) {
|
||||
switch (type) {
|
||||
case 'PERSONAL':
|
||||
return ContributionSourceType.personal;
|
||||
case 'TEAM_LEVEL':
|
||||
return ContributionSourceType.teamLevel;
|
||||
case 'TEAM_BONUS':
|
||||
return ContributionSourceType.teamBonus;
|
||||
default:
|
||||
return ContributionSourceType.personal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ContributionRecordsPageModel extends ContributionRecordsPage {
|
||||
const ContributionRecordsPageModel({
|
||||
required super.data,
|
||||
required super.total,
|
||||
required super.page,
|
||||
required super.pageSize,
|
||||
});
|
||||
|
||||
factory ContributionRecordsPageModel.fromJson(Map<String, dynamic> json) {
|
||||
final dataList = (json['data'] as List<dynamic>?) ?? [];
|
||||
return ContributionRecordsPageModel(
|
||||
data: dataList.map((e) => ContributionRecordModel.fromJson(e)).toList(),
|
||||
total: json['total'] ?? 0,
|
||||
page: json['page'] ?? 1,
|
||||
pageSize: json['pageSize'] ?? 50,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:dartz/dartz.dart';
|
||||
import '../../domain/entities/contribution.dart';
|
||||
import '../../domain/entities/contribution_record.dart';
|
||||
import '../../domain/repositories/contribution_repository.dart';
|
||||
import '../../core/error/exceptions.dart';
|
||||
import '../../core/error/failures.dart';
|
||||
|
|
@ -21,4 +22,28 @@ class ContributionRepositoryImpl implements ContributionRepository {
|
|||
return Left(const NetworkFailure());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, ContributionRecordsPage>> getContributionRecords(
|
||||
String accountSequence, {
|
||||
ContributionSourceType? sourceType,
|
||||
bool includeExpired = false,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
}) async {
|
||||
try {
|
||||
final result = await remoteDataSource.getContributionRecords(
|
||||
accountSequence,
|
||||
sourceType: sourceType,
|
||||
includeExpired: includeExpired,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
return Right(result);
|
||||
} on ServerException catch (e) {
|
||||
return Left(ServerFailure(e.message));
|
||||
} on NetworkException {
|
||||
return Left(const NetworkFailure());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// 贡献值来源类型
|
||||
enum ContributionSourceType {
|
||||
personal, // 个人 - 认种榴莲树
|
||||
teamLevel, // 团队层级 - 直推/间推奖励
|
||||
teamBonus, // 团队奖励 - 额外奖励
|
||||
}
|
||||
|
||||
/// 贡献值记录
|
||||
class ContributionRecord extends Equatable {
|
||||
final String id;
|
||||
final ContributionSourceType sourceType;
|
||||
final String sourceAdoptionId;
|
||||
final String? sourceAccountSequence;
|
||||
final int treeCount;
|
||||
final String baseContribution;
|
||||
final String distributionRate;
|
||||
final int? levelDepth;
|
||||
final int? bonusTier;
|
||||
final String finalContribution;
|
||||
final DateTime effectiveDate;
|
||||
final DateTime expireDate;
|
||||
final bool isExpired;
|
||||
final DateTime createdAt;
|
||||
|
||||
const ContributionRecord({
|
||||
required this.id,
|
||||
required this.sourceType,
|
||||
required this.sourceAdoptionId,
|
||||
this.sourceAccountSequence,
|
||||
required this.treeCount,
|
||||
required this.baseContribution,
|
||||
required this.distributionRate,
|
||||
this.levelDepth,
|
||||
this.bonusTier,
|
||||
required this.finalContribution,
|
||||
required this.effectiveDate,
|
||||
required this.expireDate,
|
||||
required this.isExpired,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
/// 获取记录的显示标题
|
||||
String get displayTitle {
|
||||
switch (sourceType) {
|
||||
case ContributionSourceType.personal:
|
||||
return '认种榴莲树';
|
||||
case ContributionSourceType.teamLevel:
|
||||
if (levelDepth == 1) {
|
||||
return '直推奖励';
|
||||
}
|
||||
return '团队奖励($levelDepth级)';
|
||||
case ContributionSourceType.teamBonus:
|
||||
return '团队额外奖励';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
sourceType,
|
||||
sourceAdoptionId,
|
||||
sourceAccountSequence,
|
||||
treeCount,
|
||||
baseContribution,
|
||||
distributionRate,
|
||||
levelDepth,
|
||||
bonusTier,
|
||||
finalContribution,
|
||||
effectiveDate,
|
||||
expireDate,
|
||||
isExpired,
|
||||
createdAt,
|
||||
];
|
||||
}
|
||||
|
||||
/// 贡献值记录分页响应
|
||||
class ContributionRecordsPage extends Equatable {
|
||||
final List<ContributionRecord> data;
|
||||
final int total;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
const ContributionRecordsPage({
|
||||
required this.data,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
});
|
||||
|
||||
bool get hasMore => page * pageSize < total;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [data, total, page, pageSize];
|
||||
}
|
||||
|
|
@ -1,7 +1,15 @@
|
|||
import 'package:dartz/dartz.dart';
|
||||
import '../../core/error/failures.dart';
|
||||
import '../entities/contribution.dart';
|
||||
import '../entities/contribution_record.dart';
|
||||
|
||||
abstract class ContributionRepository {
|
||||
Future<Either<Failure, Contribution>> getUserContribution(String accountSequence);
|
||||
Future<Either<Failure, ContributionRecordsPage>> getContributionRecords(
|
||||
String accountSequence, {
|
||||
ContributionSourceType? sourceType,
|
||||
bool includeExpired = false,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/utils/format_utils.dart';
|
||||
import '../../../domain/entities/contribution.dart';
|
||||
import '../../../domain/entities/contribution_record.dart';
|
||||
import '../../providers/user_providers.dart';
|
||||
import '../../providers/contribution_providers.dart';
|
||||
|
||||
|
|
@ -21,6 +24,12 @@ class ContributionPage extends ConsumerWidget {
|
|||
final user = ref.watch(userNotifierProvider);
|
||||
final accountSequence = user.accountSequence ?? '';
|
||||
final contributionAsync = ref.watch(contributionProvider(accountSequence));
|
||||
final recordsParams = ContributionRecordsParams(
|
||||
accountSequence: accountSequence,
|
||||
page: 1,
|
||||
pageSize: 3,
|
||||
);
|
||||
final recordsAsync = ref.watch(contributionRecordsProvider(recordsParams));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
|
|
@ -28,6 +37,7 @@ class ContributionPage extends ConsumerWidget {
|
|||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(contributionProvider(accountSequence));
|
||||
ref.invalidate(contributionRecordsProvider(recordsParams));
|
||||
},
|
||||
child: contributionAsync.when(
|
||||
data: (contribution) {
|
||||
|
|
@ -50,13 +60,13 @@ class ContributionPage extends ConsumerWidget {
|
|||
_buildTodayEstimateCard(contribution),
|
||||
const SizedBox(height: 16),
|
||||
// 贡献值明细
|
||||
_buildContributionDetailCard(contribution),
|
||||
_buildContributionDetailCard(context, ref, recordsAsync),
|
||||
const SizedBox(height: 16),
|
||||
// 团队层级统计
|
||||
_buildTeamStatsCard(contribution),
|
||||
const SizedBox(height: 16),
|
||||
// 贡献值失效倒计时
|
||||
_buildExpirationCard(contribution),
|
||||
_buildExpirationCard(contribution, recordsAsync),
|
||||
const SizedBox(height: 24),
|
||||
]),
|
||||
),
|
||||
|
|
@ -144,7 +154,7 @@ class ContributionPage extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildTotalContributionCard(contribution) {
|
||||
Widget _buildTotalContributionCard(Contribution? contribution) {
|
||||
final total = contribution?.effectiveContribution ?? '0';
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
|
|
@ -189,7 +199,7 @@ class ContributionPage extends ConsumerWidget {
|
|||
Icon(Icons.info_outline, size: 14, color: _grayText.withOpacity(0.7)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'贡献值有效期: 剩余 730 天',
|
||||
'贡献值有效期: 730 天',
|
||||
style: TextStyle(fontSize: 12, color: _grayText.withOpacity(0.9)),
|
||||
),
|
||||
],
|
||||
|
|
@ -200,7 +210,7 @@ class ContributionPage extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildThreeColumnStats(contribution) {
|
||||
Widget _buildThreeColumnStats(Contribution? contribution) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -240,7 +250,13 @@ class ContributionPage extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildTodayEstimateCard(contribution) {
|
||||
Widget _buildTodayEstimateCard(Contribution? contribution) {
|
||||
// 基于贡献值计算预估收益(暂时显示占位符,后续可接入实际计算API)
|
||||
final effectiveContribution = double.tryParse(contribution?.effectiveContribution ?? '0') ?? 0;
|
||||
// 简单估算:假设每日发放总量为 10000 积分股,按贡献值占比分配
|
||||
// 这里先显示"--"表示暂无数据,后续可接入实际计算
|
||||
final hasContribution = effectiveContribution > 0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -279,22 +295,25 @@ class ContributionPage extends ConsumerWidget {
|
|||
// 收益数值
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: const [
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '+156.78 ',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: _green),
|
||||
text: hasContribution ? '计算中' : '--',
|
||||
style: TextStyle(
|
||||
fontSize: hasContribution ? 14 : 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _green,
|
||||
),
|
||||
TextSpan(
|
||||
text: '积分',
|
||||
),
|
||||
const TextSpan(
|
||||
text: ' 积分股',
|
||||
style: TextStyle(fontSize: 12, color: _green),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text('股', style: TextStyle(fontSize: 12, color: _green)),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
@ -302,7 +321,11 @@ class ContributionPage extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildContributionDetailCard(contribution) {
|
||||
Widget _buildContributionDetailCard(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AsyncValue<ContributionRecordsPage?> recordsAsync,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -320,9 +343,11 @@ class ContributionPage extends ConsumerWidget {
|
|||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: _darkText),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {},
|
||||
child: Row(
|
||||
children: const [
|
||||
onTap: () {
|
||||
// TODO: 跳转到完整记录页面
|
||||
},
|
||||
child: const Row(
|
||||
children: [
|
||||
Text('查看全部', style: TextStyle(fontSize: 12, color: _orange)),
|
||||
Icon(Icons.chevron_right, size: 14, color: _orange),
|
||||
],
|
||||
|
|
@ -332,26 +357,68 @@ class ContributionPage extends ConsumerWidget {
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
// 明细列表
|
||||
_buildDetailRow('认种榴莲树', '2024-01-15 14:30', '+22,617.00'),
|
||||
const Divider(height: 24),
|
||||
_buildDetailRow('团队奖励(5级)', '2024-01-15 09:12', '+1,130.85'),
|
||||
const Divider(height: 24),
|
||||
_buildDetailRow('直推奖励', '2024-01-14 18:45', '+565.43'),
|
||||
recordsAsync.when(
|
||||
data: (recordsPage) {
|
||||
if (recordsPage == null || recordsPage.data.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Text(
|
||||
'暂无贡献值记录',
|
||||
style: TextStyle(fontSize: 14, color: _grayText),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: recordsPage.data.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final record = entry.value;
|
||||
return Column(
|
||||
children: [
|
||||
_buildDetailRow(record),
|
||||
if (index < recordsPage.data.length - 1) const Divider(height: 24),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
error: (error, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Text(
|
||||
'加载失败',
|
||||
style: TextStyle(fontSize: 14, color: _grayText.withOpacity(0.7)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String title, String time, String amount) {
|
||||
Widget _buildDetailRow(ContributionRecord record) {
|
||||
final dateFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
final formattedDate = dateFormat.format(record.createdAt);
|
||||
final amount = '+${formatAmount(record.finalContribution)}';
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: _darkText)),
|
||||
Text(
|
||||
record.displayTitle,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: _darkText),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(time, style: const TextStyle(fontSize: 12, color: _grayText)),
|
||||
Text(formattedDate, style: const TextStyle(fontSize: 12, color: _grayText)),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
|
|
@ -362,7 +429,7 @@ class ContributionPage extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildTeamStatsCard(contribution) {
|
||||
Widget _buildTeamStatsCard(Contribution? contribution) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -382,7 +449,7 @@ class ContributionPage extends ConsumerWidget {
|
|||
children: [
|
||||
_buildTeamStatItem('直推人数', '${contribution?.directReferralAdoptedCount ?? 0}', '人'),
|
||||
const SizedBox(width: 16),
|
||||
_buildTeamStatItem('团队总人数', '128', '人'),
|
||||
_buildTeamStatItem('已解锁奖励', '${contribution?.unlockedBonusTiers ?? 0}', '档'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
|
@ -391,7 +458,7 @@ class ContributionPage extends ConsumerWidget {
|
|||
children: [
|
||||
_buildTeamStatItem('已解锁层级', '${contribution?.unlockedLevelDepth ?? 0}', '级'),
|
||||
const SizedBox(width: 16),
|
||||
_buildTeamStatItem('团队认种总数', '456', '棵'),
|
||||
_buildTeamStatItem('是否认种', contribution?.hasAdopted == true ? '是' : '否', ''),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
@ -419,6 +486,7 @@ class ContributionPage extends ConsumerWidget {
|
|||
text: '$value ',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: _orange),
|
||||
),
|
||||
if (unit.isNotEmpty)
|
||||
TextSpan(
|
||||
text: unit,
|
||||
style: const TextStyle(fontSize: 12, color: _grayText),
|
||||
|
|
@ -432,7 +500,39 @@ class ContributionPage extends ConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildExpirationCard(contribution) {
|
||||
Widget _buildExpirationCard(
|
||||
Contribution? contribution,
|
||||
AsyncValue<ContributionRecordsPage?> recordsAsync,
|
||||
) {
|
||||
// 从记录中获取最近的过期日期
|
||||
DateTime? nearestExpireDate;
|
||||
recordsAsync.whenData((recordsPage) {
|
||||
if (recordsPage != null && recordsPage.data.isNotEmpty) {
|
||||
// 找到未过期记录中最近的过期日期
|
||||
final activeRecords = recordsPage.data.where((r) => !r.isExpired).toList();
|
||||
if (activeRecords.isNotEmpty) {
|
||||
activeRecords.sort((a, b) => a.expireDate.compareTo(b.expireDate));
|
||||
nearestExpireDate = activeRecords.first.expireDate;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 计算剩余天数和进度
|
||||
final now = DateTime.now();
|
||||
int daysRemaining = 730; // 默认值
|
||||
double progress = 1.0;
|
||||
String expireDateText = '暂无过期信息';
|
||||
|
||||
if (nearestExpireDate != null) {
|
||||
daysRemaining = nearestExpireDate!.difference(now).inDays;
|
||||
if (daysRemaining < 0) daysRemaining = 0;
|
||||
// 假设总有效期为730天
|
||||
progress = daysRemaining / 730;
|
||||
if (progress > 1) progress = 1;
|
||||
if (progress < 0) progress = 0;
|
||||
expireDateText = '您的贡献值将于 ${DateFormat('yyyy-MM-dd').format(nearestExpireDate!)} 失效';
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -443,8 +543,8 @@ class ContributionPage extends ConsumerWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Row(
|
||||
children: const [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.timer_outlined, color: _orange, size: 24),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
|
|
@ -458,7 +558,7 @@ class ContributionPage extends ConsumerWidget {
|
|||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: LinearProgressIndicator(
|
||||
value: 0.8,
|
||||
value: progress,
|
||||
minHeight: 10,
|
||||
backgroundColor: _bgGray,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(_orange),
|
||||
|
|
@ -466,9 +566,14 @@ class ContributionPage extends ConsumerWidget {
|
|||
),
|
||||
const SizedBox(height: 12),
|
||||
// 说明文字
|
||||
const Text(
|
||||
'您的贡献值将于 2026-01-15 失效',
|
||||
style: TextStyle(fontSize: 12, color: _grayText),
|
||||
Text(
|
||||
expireDateText,
|
||||
style: const TextStyle(fontSize: 12, color: _grayText),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'剩余 $daysRemaining 天',
|
||||
style: const TextStyle(fontSize: 12, color: _orange, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 提示
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../domain/entities/contribution.dart';
|
||||
import '../../domain/entities/contribution_record.dart';
|
||||
import '../../domain/usecases/contribution/get_user_contribution.dart';
|
||||
import '../../domain/repositories/contribution_repository.dart';
|
||||
import '../../core/di/injection.dart';
|
||||
|
||||
final getUserContributionUseCaseProvider = Provider<GetUserContribution>((ref) {
|
||||
return getIt<GetUserContribution>();
|
||||
});
|
||||
|
||||
final contributionRepositoryProvider = Provider<ContributionRepository>((ref) {
|
||||
return getIt<ContributionRepository>();
|
||||
});
|
||||
|
||||
final contributionProvider = FutureProvider.family<Contribution?, String>(
|
||||
(ref, accountSequence) async {
|
||||
final useCase = ref.watch(getUserContributionUseCaseProvider);
|
||||
|
|
@ -17,3 +23,44 @@ final contributionProvider = FutureProvider.family<Contribution?, String>(
|
|||
);
|
||||
},
|
||||
);
|
||||
|
||||
/// 贡献值记录请求参数
|
||||
class ContributionRecordsParams {
|
||||
final String accountSequence;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
const ContributionRecordsParams({
|
||||
required this.accountSequence,
|
||||
this.page = 1,
|
||||
this.pageSize = 10,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ContributionRecordsParams &&
|
||||
runtimeType == other.runtimeType &&
|
||||
accountSequence == other.accountSequence &&
|
||||
page == other.page &&
|
||||
pageSize == other.pageSize;
|
||||
|
||||
@override
|
||||
int get hashCode => accountSequence.hashCode ^ page.hashCode ^ pageSize.hashCode;
|
||||
}
|
||||
|
||||
/// 贡献值记录 Provider
|
||||
final contributionRecordsProvider = FutureProvider.family<ContributionRecordsPage?, ContributionRecordsParams>(
|
||||
(ref, params) async {
|
||||
final repository = ref.watch(contributionRepositoryProvider);
|
||||
final result = await repository.getContributionRecords(
|
||||
params.accountSequence,
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
);
|
||||
return result.fold(
|
||||
(failure) => throw Exception(failure.message),
|
||||
(records) => records,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -214,29 +214,6 @@ class UserNotifier extends StateNotifier<UserState> {
|
|||
}
|
||||
}
|
||||
|
||||
// Legacy method for compatibility
|
||||
void login({
|
||||
required String accountSequence,
|
||||
String? nickname,
|
||||
String? phone,
|
||||
}) {
|
||||
state = state.copyWith(
|
||||
accountSequence: accountSequence,
|
||||
nickname: nickname,
|
||||
phone: phone,
|
||||
isLoggedIn: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy mock method for development
|
||||
void setMockUser() {
|
||||
state = state.copyWith(
|
||||
accountSequence: '1001',
|
||||
nickname: '测试用户',
|
||||
phone: '138****8888',
|
||||
isLoggedIn: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final userNotifierProvider = StateNotifierProvider<UserNotifier, UserState>(
|
||||
|
|
|
|||
Loading…
Reference in New Issue