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:
hailin 2026-01-12 09:39:23 -08:00
parent a89f4c829d
commit add405aa65
13 changed files with 541 additions and 83 deletions

View File

@ -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": []

View File

@ -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);

View File

@ -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] ?? '服务器错误';

View File

@ -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),

View File

@ -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,

View File

@ -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';
}
}
}

View File

@ -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,
);
}
}

View File

@ -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());
}
}
}

View File

@ -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];
}

View File

@ -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,
});
}

View File

@ -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,10 +486,11 @@ class ContributionPage extends ConsumerWidget {
text: '$value ',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: _orange),
),
TextSpan(
text: unit,
style: const TextStyle(fontSize: 12, color: _grayText),
),
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),
//

View File

@ -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,
);
},
);

View File

@ -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>(