feat(mining-app): add estimated earnings and contribution stats API

- Add ContributionStats entity and model for network-wide statistics
- Add /api/v2/contribution/stats endpoint
- Implement estimatedEarningsProvider to calculate daily earnings
- Formula: (user contribution / total contribution) × daily allocation
- Update contribution page to display real estimated earnings
- Add debug logs for contribution records API

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-14 08:37:30 -08:00
parent 6bcb4af028
commit ed9f817fae
8 changed files with 217 additions and 12 deletions

View File

@ -50,6 +50,7 @@ class ApiEndpoints {
static const String transferHistory = '/api/v2/trading/transfers/history';
// Contribution Service 2.0 (Kong路由: /api/v2/contribution -> /api/v1/contributions)
static const String contributionStats = '/api/v2/contribution/stats';
static String contribution(String accountSequence) =>
'/api/v2/contribution/accounts/$accountSequence';
static String contributionRecords(String accountSequence) =>

View File

@ -1,5 +1,6 @@
import '../../models/contribution_model.dart';
import '../../models/contribution_record_model.dart';
import '../../models/contribution_stats_model.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/api_endpoints.dart';
import '../../../core/error/exceptions.dart';
@ -14,6 +15,7 @@ abstract class ContributionRemoteDataSource {
int page = 1,
int pageSize = 50,
});
Future<ContributionStatsModel> getContributionStats();
}
class ContributionRemoteDataSourceImpl implements ContributionRemoteDataSource {
@ -60,6 +62,16 @@ class ContributionRemoteDataSourceImpl implements ContributionRemoteDataSource {
}
}
@override
Future<ContributionStatsModel> getContributionStats() async {
try {
final response = await client.get(ApiEndpoints.contributionStats);
return ContributionStatsModel.fromJson(response.data);
} catch (e) {
throw ServerException(e.toString());
}
}
String _sourceTypeToString(ContributionSourceType type) {
switch (type) {
case ContributionSourceType.personal:

View File

@ -0,0 +1,29 @@
import '../../domain/entities/contribution_stats.dart';
class ContributionStatsModel extends ContributionStats {
const ContributionStatsModel({
required super.totalUsers,
required super.totalAccounts,
required super.accountsWithContribution,
required super.totalAdoptions,
required super.totalContribution,
required super.personalContribution,
required super.teamLevelContribution,
required super.teamBonusContribution,
});
factory ContributionStatsModel.fromJson(Map<String, dynamic> json) {
final contributionByType = json['contributionByType'] as Map<String, dynamic>? ?? {};
return ContributionStatsModel(
totalUsers: json['totalUsers'] ?? 0,
totalAccounts: json['totalAccounts'] ?? 0,
accountsWithContribution: json['accountsWithContribution'] ?? 0,
totalAdoptions: json['totalAdoptions'] ?? 0,
totalContribution: json['totalContribution']?.toString() ?? '0',
personalContribution: contributionByType['personal']?.toString() ?? '0',
teamLevelContribution: contributionByType['teamLevel']?.toString() ?? '0',
teamBonusContribution: contributionByType['teamBonus']?.toString() ?? '0',
);
}
}

View File

@ -1,6 +1,7 @@
import 'package:dartz/dartz.dart';
import '../../domain/entities/contribution.dart';
import '../../domain/entities/contribution_record.dart';
import '../../domain/entities/contribution_stats.dart';
import '../../domain/repositories/contribution_repository.dart';
import '../../core/error/exceptions.dart';
import '../../core/error/failures.dart';
@ -46,4 +47,16 @@ class ContributionRepositoryImpl implements ContributionRepository {
return Left(const NetworkFailure());
}
}
@override
Future<Either<Failure, ContributionStats>> getContributionStats() async {
try {
final result = await remoteDataSource.getContributionStats();
return Right(result);
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on NetworkException {
return Left(const NetworkFailure());
}
}
}

View File

@ -0,0 +1,51 @@
import 'package:equatable/equatable.dart';
///
class ContributionStats extends Equatable {
///
final int totalUsers;
///
final int totalAccounts;
///
final int accountsWithContribution;
///
final int totalAdoptions;
///
final String totalContribution;
///
final String personalContribution;
///
final String teamLevelContribution;
///
final String teamBonusContribution;
const ContributionStats({
required this.totalUsers,
required this.totalAccounts,
required this.accountsWithContribution,
required this.totalAdoptions,
required this.totalContribution,
required this.personalContribution,
required this.teamLevelContribution,
required this.teamBonusContribution,
});
@override
List<Object?> get props => [
totalUsers,
totalAccounts,
accountsWithContribution,
totalAdoptions,
totalContribution,
personalContribution,
teamLevelContribution,
teamBonusContribution,
];
}

View File

@ -2,6 +2,7 @@ import 'package:dartz/dartz.dart';
import '../../core/error/failures.dart';
import '../entities/contribution.dart';
import '../entities/contribution_record.dart';
import '../entities/contribution_stats.dart';
abstract class ContributionRepository {
Future<Either<Failure, Contribution>> getUserContribution(String accountSequence);
@ -12,4 +13,5 @@ abstract class ContributionRepository {
int page = 1,
int pageSize = 50,
});
Future<Either<Failure, ContributionStats>> getContributionStats();
}

View File

@ -32,11 +32,16 @@ class ContributionPage extends ConsumerWidget {
);
final recordsAsync = ref.watch(contributionRecordsProvider(recordsParams));
//
final estimatedEarnings = ref.watch(estimatedEarningsProvider(accountSequence));
final statsAsync = ref.watch(contributionStatsProvider);
// Extract loading state and data from AsyncValue
final isLoading = contributionAsync.isLoading;
final contribution = contributionAsync.valueOrNull;
final hasError = contributionAsync.hasError;
final error = contributionAsync.error;
final isStatsLoading = statsAsync.isLoading;
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
@ -46,6 +51,7 @@ class ContributionPage extends ConsumerWidget {
onRefresh: () async {
ref.invalidate(contributionProvider(accountSequence));
ref.invalidate(contributionRecordsProvider(recordsParams));
ref.invalidate(contributionStatsProvider);
},
child: hasError && contribution == null
? Center(
@ -79,7 +85,7 @@ class ContributionPage extends ConsumerWidget {
_buildThreeColumnStats(contribution, isLoading),
const SizedBox(height: 16),
//
_buildTodayEstimateCard(contribution, isLoading),
_buildTodayEstimateCard(estimatedEarnings, isLoading || isStatsLoading),
const SizedBox(height: 16),
//
_buildContributionDetailCard(context, ref, recordsAsync),
@ -259,13 +265,7 @@ class ContributionPage extends ConsumerWidget {
);
}
Widget _buildTodayEstimateCard(Contribution? contribution, bool isLoading) {
// API
final totalContribution = double.tryParse(contribution?.totalContribution ?? '0') ?? 0;
// 10000
// "--"
final hasContribution = totalContribution > 0;
Widget _buildTodayEstimateCard(EstimatedEarnings earnings, bool isLoading) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
@ -314,9 +314,9 @@ class ContributionPage extends ConsumerWidget {
TextSpan(
children: [
TextSpan(
text: hasContribution ? '计算中' : '--',
style: TextStyle(
fontSize: hasContribution ? 14 : 18,
text: earnings.isValid ? formatAmount(earnings.dailyShares) : '--',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: _green,
),

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/contribution.dart';
import '../../domain/entities/contribution_record.dart';
import '../../domain/entities/contribution_stats.dart';
import '../../domain/usecases/contribution/get_user_contribution.dart';
import '../../domain/repositories/contribution_repository.dart';
import '../../core/di/injection.dart';
@ -67,7 +68,12 @@ class ContributionRecordsParams {
final contributionRecordsProvider = FutureProvider.family<ContributionRecordsPage?, ContributionRecordsParams>(
(ref, params) async {
//
if (params.accountSequence.isEmpty) return null;
if (params.accountSequence.isEmpty) {
print('[ContributionRecordsProvider] accountSequence is empty, skipping request');
return null;
}
print('[ContributionRecordsProvider] Fetching records for ${params.accountSequence}, page=${params.page}, pageSize=${params.pageSize}');
final repository = ref.watch(contributionRepositoryProvider);
final result = await repository.getContributionRecords(
@ -76,6 +82,12 @@ final contributionRecordsProvider = FutureProvider.family<ContributionRecordsPag
pageSize: params.pageSize,
);
print('[ContributionRecordsProvider] Result: ${result.isRight() ? "success" : "failed"}');
result.fold(
(failure) => print('[ContributionRecordsProvider] Error: ${failure.message}'),
(records) => print('[ContributionRecordsProvider] Records count: ${records.data.length}, total: ${records.total}'),
);
// provider
ref.keepAlive();
@ -91,3 +103,88 @@ final contributionRecordsProvider = FutureProvider.family<ContributionRecordsPag
);
},
);
/// Provider
final contributionStatsProvider = FutureProvider<ContributionStats?>((ref) async {
final repository = ref.watch(contributionRepositoryProvider);
final result = await repository.getContributionStats();
// provider
ref.keepAlive();
// 5
final timer = Timer(const Duration(minutes: 5), () {
ref.invalidateSelf();
});
ref.onDispose(() => timer.cancel());
return result.fold(
(failure) {
print('[ContributionStatsProvider] Error: ${failure.message}');
return null;
},
(stats) {
print('[ContributionStatsProvider] Total contribution: ${stats.totalContribution}');
return stats;
},
);
});
///
/// : ( / ) ×
/// = × 86400
class EstimatedEarnings {
final String dailyShares;
final String perSecondShares;
final bool isValid;
const EstimatedEarnings({
required this.dailyShares,
required this.perSecondShares,
required this.isValid,
});
static const zero = EstimatedEarnings(
dailyShares: '0',
perSecondShares: '0',
isValid: false,
);
}
///
/// 1: 100M / (2 × 365) 136,986 /
const double dailyAllocationTotal = 136986.0;
final estimatedEarningsProvider = Provider.family<EstimatedEarnings, String>((ref, accountSequence) {
final contributionAsync = ref.watch(contributionProvider(accountSequence));
final statsAsync = ref.watch(contributionStatsProvider);
final contribution = contributionAsync.valueOrNull;
final stats = statsAsync.valueOrNull;
if (contribution == null || stats == null) {
return EstimatedEarnings.zero;
}
final userContribution = double.tryParse(contribution.totalContribution) ?? 0;
final totalContribution = double.tryParse(stats.totalContribution) ?? 0;
if (totalContribution <= 0 || userContribution <= 0) {
return EstimatedEarnings.zero;
}
//
final ratio = userContribution / totalContribution;
//
final dailyShares = ratio * dailyAllocationTotal;
//
final perSecondShares = dailyShares / 86400;
return EstimatedEarnings(
dailyShares: dailyShares.toStringAsFixed(4),
perSecondShares: perSecondShares.toStringAsFixed(8),
isValid: true,
);
});