feat(profile): 添加懒加载、防抖和失败重试机制

- 添加 VisibilityDetector 实现懒加载,滚动到可见区域才加载数据
- 添加 300ms 防抖机制,防止快速滑动触发大量 API 请求
- 添加失败自动重试(最多3次,指数退避:1s→2s→4s)
- 添加 60 秒定时刷新可见区域数据
- 添加下拉刷新功能
- 添加 Shimmer 骨架屏加载状态
- 添加错误状态 UI 和手动重试按钮
- 创建通用 LazyLoadSection 组件

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-15 20:50:27 -08:00
parent e75b968aeb
commit d29ff0975b
4 changed files with 693 additions and 44 deletions

View File

@ -0,0 +1,277 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:visibility_detector/visibility_detector.dart';
///
enum LoadState {
///
idle,
///
loading,
///
success,
///
error,
}
///
///
///
/// -
/// -
/// - Shimmer
/// -
class LazyLoadSection extends StatefulWidget {
/// VisibilityDetector
final String sectionKey;
/// Future
final Future<void> Function() onLoad;
///
final Widget child;
/// 100
final double skeletonHeight;
///
final Widget? skeleton;
///
final Widget Function(String error, VoidCallback retry)? errorBuilder;
/// 3
final int maxRetries;
/// 退
final int retryDelayMs;
/// 0-1 0.1 10%
final double visibilityThreshold;
///
final bool loadImmediately;
const LazyLoadSection({
super.key,
required this.sectionKey,
required this.onLoad,
required this.child,
this.skeletonHeight = 100,
this.skeleton,
this.errorBuilder,
this.maxRetries = 3,
this.retryDelayMs = 1000,
this.visibilityThreshold = 0.1,
this.loadImmediately = false,
});
@override
State<LazyLoadSection> createState() => _LazyLoadSectionState();
}
class _LazyLoadSectionState extends State<LazyLoadSection> {
LoadState _state = LoadState.idle;
String _errorMessage = '';
int _retryCount = 0;
bool _hasBeenVisible = false;
@override
void initState() {
super.initState();
if (widget.loadImmediately) {
_loadData();
}
}
///
Future<void> _loadData() async {
if (_state == LoadState.loading) return;
setState(() {
_state = LoadState.loading;
_errorMessage = '';
});
try {
await widget.onLoad();
if (mounted) {
setState(() {
_state = LoadState.success;
_retryCount = 0;
});
}
} catch (e) {
debugPrint('[LazyLoadSection] ${widget.sectionKey} 加载失败: $e');
if (mounted) {
setState(() {
_state = LoadState.error;
_errorMessage = e.toString().replaceAll('Exception: ', '');
});
//
_scheduleAutoRetry();
}
}
}
/// 退
void _scheduleAutoRetry() {
if (_retryCount >= widget.maxRetries) {
debugPrint('[LazyLoadSection] ${widget.sectionKey} 已达最大重试次数');
return;
}
final delay = widget.retryDelayMs * (1 << _retryCount); // 退
debugPrint('[LazyLoadSection] ${widget.sectionKey} 将在 ${delay}ms 后重试 (第 ${_retryCount + 1} 次)');
Future.delayed(Duration(milliseconds: delay), () {
if (mounted && _state == LoadState.error) {
_retryCount++;
_loadData();
}
});
}
///
void _manualRetry() {
_retryCount = 0;
_loadData();
}
///
void _onVisibilityChanged(VisibilityInfo info) {
if (_hasBeenVisible) return;
if (info.visibleFraction >= widget.visibilityThreshold) {
_hasBeenVisible = true;
if (_state == LoadState.idle) {
_loadData();
}
}
}
@override
Widget build(BuildContext context) {
return VisibilityDetector(
key: Key('lazy_${widget.sectionKey}'),
onVisibilityChanged: _onVisibilityChanged,
child: _buildContent(),
);
}
Widget _buildContent() {
switch (_state) {
case LoadState.idle:
case LoadState.loading:
return _buildSkeleton();
case LoadState.error:
return _buildError();
case LoadState.success:
return widget.child;
}
}
///
Widget _buildSkeleton() {
if (widget.skeleton != null) {
return widget.skeleton!;
}
return Shimmer.fromColors(
baseColor: const Color(0xFFE0E0E0),
highlightColor: const Color(0xFFF5F5F5),
child: Container(
height: widget.skeletonHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
),
);
}
///
Widget _buildError() {
if (widget.errorBuilder != null) {
return widget.errorBuilder!(_errorMessage, _manualRetry);
}
return Container(
height: widget.skeletonHeight,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFFFFCC80),
width: 1,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Color(0xFFE65100),
size: 24,
),
const SizedBox(height: 8),
Text(
_retryCount >= widget.maxRetries
? '加载失败,点击重试'
: '加载失败,正在重试...',
style: const TextStyle(
fontSize: 12,
color: Color(0xFFE65100),
),
textAlign: TextAlign.center,
),
if (_retryCount >= widget.maxRetries) ...[
const SizedBox(height: 8),
GestureDetector(
onTap: _manualRetry,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFD4AF37),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'重试',
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
],
],
),
);
}
}
///
class LazyLoadRefreshWrapper extends StatelessWidget {
final Widget child;
final Future<void> Function() onRefresh;
const LazyLoadRefreshWrapper({
super.key,
required this.child,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: onRefresh,
color: const Color(0xFFD4AF37),
backgroundColor: Colors.white,
child: child,
);
}
}

View File

@ -8,6 +8,8 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:shimmer/shimmer.dart';
import 'package:visibility_detector/visibility_detector.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../core/services/referral_service.dart';
import '../../../../core/services/reward_service.dart';
@ -125,20 +127,52 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
//
int _unreadNotificationCount = 0;
// ========== + ==========
//
bool _isLoadingUserData = true;
bool _isLoadingReferral = true;
bool _isLoadingAuthorization = true;
String? _userDataError;
String? _referralError;
String? _authorizationError;
//
int _userDataRetryCount = 0;
int _referralRetryCount = 0;
int _authorizationRetryCount = 0;
int _walletRetryCount = 0;
static const int _maxRetries = 3;
static const int _retryDelayMs = 1000;
//
bool _hasLoadedReferral = false;
bool _hasLoadedAuthorization = false;
bool _hasLoadedWallet = false;
//
Timer? _refreshTimer;
static const int _autoRefreshIntervalSeconds = 60; // 60
bool _isReferralVisible = false;
bool _isAuthorizationVisible = false;
bool _isWalletVisible = false;
//
Timer? _referralDebounceTimer;
Timer? _authorizationDebounceTimer;
Timer? _walletDebounceTimer;
static const int _debounceDelayMs = 300; // 300ms
@override
void initState() {
super.initState();
//
//
_checkLocalAvatarSync();
_loadUserData();
_loadAppInfo();
//
_loadReferralData();
_loadAuthorizationData();
//
_loadWalletData();
//
//
_loadUnreadNotificationCount();
//
_startAutoRefreshTimer();
}
///
@ -290,7 +324,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}
/// (from referral-service)
Future<void> _loadReferralData() async {
/// [isRefresh]
Future<void> _loadReferralData({bool isRefresh = false}) async {
if (!isRefresh) {
setState(() {
_isLoadingReferral = true;
_referralError = null;
});
}
try {
debugPrint('[ProfilePage] 开始加载推荐数据...');
final referralService = ref.read(referralServiceProvider);
@ -308,6 +350,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (mounted) {
setState(() {
_isLoadingReferral = false;
_referralError = null;
_referralRetryCount = 0;
_directReferralCount = referralInfo.directReferralCount;
_totalTeamCount = referralInfo.totalTeamCount;
_personalPlantingCount = referralInfo.personalPlantingCount;
@ -326,12 +371,32 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
} catch (e, stackTrace) {
debugPrint('[ProfilePage] 加载推荐数据失败: $e');
debugPrint('[ProfilePage] 堆栈: $stackTrace');
//
if (mounted) {
setState(() {
_isLoadingReferral = false;
_referralError = '加载失败';
});
//
_scheduleRetry(
section: '推荐数据',
retryCount: _referralRetryCount,
loadFunction: () => _loadReferralData(),
updateRetryCount: (count) => _referralRetryCount = count,
);
}
}
}
/// (from authorization-service)
Future<void> _loadAuthorizationData() async {
/// [isRefresh]
Future<void> _loadAuthorizationData({bool isRefresh = false}) async {
if (!isRefresh) {
setState(() {
_isLoadingAuthorization = true;
_authorizationError = null;
});
}
try {
debugPrint('[ProfilePage] 开始加载授权数据...');
final authorizationService = ref.read(authorizationServiceProvider);
@ -363,6 +428,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (mounted) {
setState(() {
_isLoadingAuthorization = false;
_authorizationError = null;
_authorizationRetryCount = 0;
_community = summary.communityName ?? '--';
_authCityCompany = summary.authCityCompanyName ?? '--';
_cityCompany = summary.cityCompanyName ?? '--';
@ -439,7 +507,19 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
} catch (e, stackTrace) {
debugPrint('[ProfilePage] 加载授权数据失败: $e');
debugPrint('[ProfilePage] 堆栈: $stackTrace');
//
if (mounted) {
setState(() {
_isLoadingAuthorization = false;
_authorizationError = '加载失败';
});
//
_scheduleRetry(
section: '授权数据',
retryCount: _authorizationRetryCount,
loadFunction: () => _loadAuthorizationData(),
updateRetryCount: (count) => _authorizationRetryCount = count,
);
}
}
}
@ -474,14 +554,17 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}
/// ( reward-service )
Future<void> _loadWalletData() async {
/// [isRefresh]
Future<void> _loadWalletData({bool isRefresh = false}) async {
try {
debugPrint('[ProfilePage] ========== 加载收益数据 ==========');
debugPrint('[ProfilePage] mounted: $mounted');
setState(() {
_isLoadingWallet = true;
_walletError = null;
});
debugPrint('[ProfilePage] mounted: $mounted, isRefresh: $isRefresh');
if (!isRefresh) {
setState(() {
_isLoadingWallet = true;
_walletError = null;
});
}
debugPrint('[ProfilePage] 获取 rewardServiceProvider...');
final rewardService = ref.read(rewardServiceProvider);
@ -519,6 +602,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
_remainingSeconds = summary.pendingRemainingSeconds;
_pendingRewards = pendingRewards;
_isLoadingWallet = false;
_walletError = null;
_walletRetryCount = 0;
});
debugPrint('[ProfilePage] UI 状态更新完成');
@ -546,8 +631,15 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
if (mounted) {
setState(() {
_isLoadingWallet = false;
_walletError = '加载失败,点击重试';
_walletError = '加载失败';
});
//
_scheduleRetry(
section: '收益数据',
retryCount: _walletRetryCount,
loadFunction: () => _loadWalletData(),
updateRetryCount: (count) => _walletRetryCount = count,
);
}
}
}
@ -566,9 +658,147 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
@override
void dispose() {
_timer?.cancel();
_refreshTimer?.cancel();
//
_referralDebounceTimer?.cancel();
_authorizationDebounceTimer?.cancel();
_walletDebounceTimer?.cancel();
super.dispose();
}
/// 60
void _startAutoRefreshTimer() {
_refreshTimer = Timer.periodic(
const Duration(seconds: _autoRefreshIntervalSeconds),
(_) => _refreshVisibleSections(),
);
}
///
Future<void> _refreshVisibleSections() async {
debugPrint('[ProfilePage] 定时刷新可见区域...');
if (_isWalletVisible && !_isLoadingWallet) {
debugPrint('[ProfilePage] 刷新收益数据');
_loadWalletData(isRefresh: true);
}
if (_isReferralVisible && !_isLoadingReferral) {
debugPrint('[ProfilePage] 刷新推荐数据');
_loadReferralData(isRefresh: true);
}
if (_isAuthorizationVisible && !_isLoadingAuthorization) {
debugPrint('[ProfilePage] 刷新授权数据');
_loadAuthorizationData(isRefresh: true);
}
}
/// -
Future<void> _onRefresh() async {
debugPrint('[ProfilePage] 下拉刷新 - 刷新所有数据');
//
_userDataRetryCount = 0;
_referralRetryCount = 0;
_authorizationRetryCount = 0;
_walletRetryCount = 0;
//
await Future.wait([
_loadUserData(),
if (_hasLoadedReferral) _loadReferralData(isRefresh: true),
if (_hasLoadedAuthorization) _loadAuthorizationData(isRefresh: true),
if (_hasLoadedWallet) _loadWalletData(isRefresh: true),
_loadUnreadNotificationCount(),
]);
}
///
void _onReferralVisibilityChanged(VisibilityInfo info) {
final isVisible = info.visibleFraction > 0.1;
_isReferralVisible = isVisible;
//
_referralDebounceTimer?.cancel();
//
if (isVisible && !_hasLoadedReferral && !_isLoadingReferral) {
_referralDebounceTimer = Timer(
const Duration(milliseconds: _debounceDelayMs),
() {
if (mounted && _isReferralVisible && !_hasLoadedReferral) {
_hasLoadedReferral = true;
_loadReferralData();
}
},
);
}
}
///
void _onAuthorizationVisibilityChanged(VisibilityInfo info) {
final isVisible = info.visibleFraction > 0.1;
_isAuthorizationVisible = isVisible;
//
_authorizationDebounceTimer?.cancel();
//
if (isVisible && !_hasLoadedAuthorization && !_isLoadingAuthorization) {
_authorizationDebounceTimer = Timer(
const Duration(milliseconds: _debounceDelayMs),
() {
if (mounted && _isAuthorizationVisible && !_hasLoadedAuthorization) {
_hasLoadedAuthorization = true;
_loadAuthorizationData();
}
},
);
}
}
///
void _onWalletVisibilityChanged(VisibilityInfo info) {
final isVisible = info.visibleFraction > 0.1;
_isWalletVisible = isVisible;
//
_walletDebounceTimer?.cancel();
//
if (isVisible && !_hasLoadedWallet && !_isLoadingWallet) {
_walletDebounceTimer = Timer(
const Duration(milliseconds: _debounceDelayMs),
() {
if (mounted && _isWalletVisible && !_hasLoadedWallet) {
_hasLoadedWallet = true;
_loadWalletData();
}
},
);
}
}
/// 退
void _scheduleRetry({
required String section,
required int retryCount,
required Future<void> Function() loadFunction,
required void Function(int) updateRetryCount,
}) {
if (retryCount >= _maxRetries) {
debugPrint('[ProfilePage] $section 已达最大重试次数');
return;
}
final delay = _retryDelayMs * (1 << retryCount); // 退
debugPrint('[ProfilePage] $section 将在 ${delay}ms 后重试 (第 ${retryCount + 1} 次)');
Future.delayed(Duration(milliseconds: delay), () {
if (mounted) {
updateRetryCount(retryCount + 1);
loadFunction();
}
});
}
///
void _startCountdown() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
@ -747,6 +977,85 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
);
}
///
Widget _buildSkeleton({double height = 100, double? width}) {
return Shimmer.fromColors(
baseColor: const Color(0xFFE0E0E0),
highlightColor: const Color(0xFFF5F5F5),
child: Container(
height: height,
width: width,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
),
);
}
///
Widget _buildErrorRetry({
required String error,
required VoidCallback onRetry,
required int retryCount,
double height = 100,
}) {
return Container(
height: height,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFFFFCC80),
width: 1,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Color(0xFFE65100),
size: 24,
),
const SizedBox(height: 8),
Text(
retryCount >= _maxRetries
? '加载失败,点击重试'
: '加载失败,正在重试...',
style: const TextStyle(
fontSize: 12,
color: Color(0xFFE65100),
),
textAlign: TextAlign.center,
),
if (retryCount >= _maxRetries) ...[
const SizedBox(height: 8),
GestureDetector(
onTap: onRetry,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFD4AF37),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'重试',
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
],
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -765,31 +1074,49 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
),
child: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
_buildPageHeader(),
const SizedBox(height: 16),
//
_buildUserHeader(),
const SizedBox(height: 16),
//
_buildReferralInfoCard(),
const SizedBox(height: 16),
// /
_buildCommunityLabel(),
const SizedBox(height: 8),
//
_buildPlantingButton(),
const SizedBox(height: 16),
//
_buildMainContentCard(),
const SizedBox(height: 24),
],
child: RefreshIndicator(
onRefresh: _onRefresh,
color: const Color(0xFFD4AF37),
backgroundColor: Colors.white,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
_buildPageHeader(),
const SizedBox(height: 16),
//
_buildUserHeader(),
const SizedBox(height: 16),
//
VisibilityDetector(
key: const Key('referral_section'),
onVisibilityChanged: _onReferralVisibilityChanged,
child: _buildReferralInfoCard(),
),
const SizedBox(height: 16),
// /
VisibilityDetector(
key: const Key('authorization_section'),
onVisibilityChanged: _onAuthorizationVisibilityChanged,
child: _buildCommunityLabel(),
),
const SizedBox(height: 8),
//
_buildPlantingButton(),
const SizedBox(height: 16),
//
VisibilityDetector(
key: const Key('wallet_section'),
onVisibilityChanged: _onWalletVisibilityChanged,
child: _buildMainContentCard(),
),
const SizedBox(height: 24),
],
),
),
),
),
@ -1074,8 +1401,26 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
);
}
///
///
Widget _buildReferralInfoCard() {
//
if (_isLoadingAuthorization && !_hasLoadedAuthorization) {
return _buildSkeleton(height: 180);
}
//
if (_authorizationError != null && _authorizationRetryCount >= _maxRetries) {
return _buildErrorRetry(
error: _authorizationError!,
onRetry: () {
_authorizationRetryCount = 0;
_loadAuthorizationData();
},
retryCount: _authorizationRetryCount,
height: 180,
);
}
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
@ -1214,6 +1559,24 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
///
Widget _buildMainContentCard() {
// -
if (_isLoadingWallet && !_hasLoadedWallet) {
return _buildSkeleton(height: 400);
}
// -
if (_walletError != null && _walletRetryCount >= _maxRetries) {
return _buildErrorRetry(
error: _walletError!,
onRetry: () {
_walletRetryCount = 0;
_loadWalletData();
},
retryCount: _walletRetryCount,
height: 400,
);
}
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),

View File

@ -1586,6 +1586,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
visibility_detector:
dependency: "direct main"
description:
name: visibility_detector
sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420
url: "https://pub.dev"
source: hosted
version: "0.4.0+2"
vm_service:
dependency: transitive
description:

View File

@ -39,6 +39,7 @@ dependencies:
flutter_svg: ^2.0.10+1
cached_network_image: ^3.3.1
shimmer: ^3.0.0
visibility_detector: ^0.4.0+2
lottie: ^3.1.0
qr_flutter: ^4.1.0
mobile_scanner: ^5.1.1