fix(recommend): 首屏不再兜出重试空态
This commit is contained in:
1 parent
9117bf58b8
commit
d206465ee1
3 files changed
+302
-30
No files matched your search
@@ -33,6 +33,7 @@ class HomeFeedController extends ChangeNotifier {
|
|||||||
String? get cursor => _state(_channel).cursor;
|
String? get cursor => _state(_channel).cursor;
|
||||||
bool get hasMore => _state(_channel).hasMore;
|
bool get hasMore => _state(_channel).hasMore;
|
||||||
bool get loaded => _state(_channel).loaded;
|
bool get loaded => _state(_channel).loaded;
|
||||||
|
bool get isLoading => _state(_channel).loading;
|
||||||
bool get loadingMore => _state(_channel).loadingMore;
|
bool get loadingMore => _state(_channel).loadingMore;
|
||||||
bool get loadingFirstPage => !loaded && _state(_channel).loading;
|
bool get loadingFirstPage => !loaded && _state(_channel).loading;
|
||||||
|
|
||||||
|
|||||||
@@ -49,10 +49,16 @@ class RecommendPage extends StatefulWidget {
|
|||||||
|
|
||||||
class _RecommendPageState extends State<RecommendPage>
|
class _RecommendPageState extends State<RecommendPage>
|
||||||
with AutomaticKeepAliveClientMixin<RecommendPage> {
|
with AutomaticKeepAliveClientMixin<RecommendPage> {
|
||||||
|
static const Duration _recommendRetryDelay = Duration(milliseconds: 1500);
|
||||||
|
static const int _maxRecommendRetries = 3;
|
||||||
|
|
||||||
RecommendInnerTab _tab = RecommendInnerTab.feed;
|
RecommendInnerTab _tab = RecommendInnerTab.feed;
|
||||||
final PageController _pageController = PageController();
|
final PageController _pageController = PageController();
|
||||||
int _scrubCancelRequest = 0;
|
int _scrubCancelRequest = 0;
|
||||||
bool _recommendDataRefreshScheduled = false;
|
bool _recommendDataRefreshScheduled = false;
|
||||||
|
bool _recommendRefreshInFlight = false;
|
||||||
|
int _recommendRetryAttempts = 0;
|
||||||
|
Timer? _recommendRetryTimer;
|
||||||
HomeFeedChannel? _observedFeedChannel;
|
HomeFeedChannel? _observedFeedChannel;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -71,9 +77,13 @@ class _RecommendPageState extends State<RecommendPage>
|
|||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.feedController != widget.feedController) {
|
if (oldWidget.feedController != widget.feedController) {
|
||||||
oldWidget.feedController.removeListener(_handleFeedControllerChanged);
|
oldWidget.feedController.removeListener(_handleFeedControllerChanged);
|
||||||
|
_cancelRecommendRetries();
|
||||||
_observedFeedChannel = widget.feedController.channel;
|
_observedFeedChannel = widget.feedController.channel;
|
||||||
widget.feedController.addListener(_handleFeedControllerChanged);
|
widget.feedController.addListener(_handleFeedControllerChanged);
|
||||||
}
|
}
|
||||||
|
if (oldWidget.active && !widget.active) {
|
||||||
|
_cancelRecommendRetries();
|
||||||
|
}
|
||||||
if (_requiresRecommendDataRefresh(oldWidget)) {
|
if (_requiresRecommendDataRefresh(oldWidget)) {
|
||||||
_scheduleRecommendData();
|
_scheduleRecommendData();
|
||||||
}
|
}
|
||||||
@@ -88,9 +98,32 @@ class _RecommendPageState extends State<RecommendPage>
|
|||||||
|
|
||||||
void _handleFeedControllerChanged() {
|
void _handleFeedControllerChanged() {
|
||||||
final channel = widget.feedController.channel;
|
final channel = widget.feedController.channel;
|
||||||
if (channel == _observedFeedChannel) return;
|
if (channel != _observedFeedChannel) {
|
||||||
_observedFeedChannel = channel;
|
_observedFeedChannel = channel;
|
||||||
if (widget.active) _scheduleRecommendData();
|
_cancelRecommendRetries();
|
||||||
|
if (widget.active) _scheduleRecommendData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_handleRecommendFeedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleRecommendFeedState() {
|
||||||
|
if (!mounted || !widget.active) return;
|
||||||
|
final feed = widget.feedController;
|
||||||
|
if (feed.dramas.isNotEmpty) {
|
||||||
|
_cancelRecommendRetries();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (feed.loaded &&
|
||||||
|
feed.lastError == null &&
|
||||||
|
!feed.isLoading &&
|
||||||
|
!_recommendRefreshInFlight) {
|
||||||
|
_cancelRecommendRetries();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (feed.loaded && feed.lastError != null && !_recommendRefreshInFlight) {
|
||||||
|
_scheduleRecommendRetry();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleRecommendData() {
|
void _scheduleRecommendData() {
|
||||||
@@ -100,8 +133,15 @@ class _RecommendPageState extends State<RecommendPage>
|
|||||||
_recommendDataRefreshScheduled = false;
|
_recommendDataRefreshScheduled = false;
|
||||||
if (!mounted || !widget.active) return;
|
if (!mounted || !widget.active) return;
|
||||||
final feed = widget.feedController;
|
final feed = widget.feedController;
|
||||||
if (!feed.loaded && !feed.loadingFirstPage) {
|
if (feed.dramas.isEmpty || feed.lastError != null) {
|
||||||
unawaited(feed.refreshCurrent());
|
_recommendRetryAttempts = 0;
|
||||||
|
if (!feed.isLoading && !_recommendRefreshInFlight) {
|
||||||
|
if (feed.loaded) {
|
||||||
|
_scheduleRecommendRetry();
|
||||||
|
} else {
|
||||||
|
unawaited(_refreshRecommendData());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
final playlists = _readPlaylists();
|
final playlists = _readPlaylists();
|
||||||
if (playlists != null && !playlists.loaded && !playlists.loading) {
|
if (playlists != null && !playlists.loaded && !playlists.loading) {
|
||||||
@@ -110,9 +150,51 @@ class _RecommendPageState extends State<RecommendPage>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _scheduleRecommendRetry() {
|
||||||
|
if (!mounted ||
|
||||||
|
!widget.active ||
|
||||||
|
_recommendRefreshInFlight ||
|
||||||
|
_recommendRetryTimer != null ||
|
||||||
|
_recommendRetryAttempts >= _maxRecommendRetries) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_recommendRetryTimer = Timer(_recommendRetryDelay, () {
|
||||||
|
_recommendRetryTimer = null;
|
||||||
|
if (!mounted || !widget.active) return;
|
||||||
|
_recommendRetryAttempts += 1;
|
||||||
|
unawaited(_refreshRecommendData());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshRecommendData() async {
|
||||||
|
if (!mounted || !widget.active || _recommendRefreshInFlight) return;
|
||||||
|
setState(() => _recommendRefreshInFlight = true);
|
||||||
|
final succeeded = await widget.feedController.refreshCurrent();
|
||||||
|
if (!mounted) return;
|
||||||
|
_recommendRefreshInFlight = false;
|
||||||
|
if (!widget.active) return;
|
||||||
|
final feed = widget.feedController;
|
||||||
|
if (succeeded || (feed.loaded && feed.lastError == null)) {
|
||||||
|
_cancelRecommendRetries();
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {});
|
||||||
|
_scheduleRecommendRetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelRecommendRetries() {
|
||||||
|
_recommendRetryTimer?.cancel();
|
||||||
|
_recommendRetryTimer = null;
|
||||||
|
_recommendRetryAttempts = 0;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
widget.feedController.removeListener(_handleFeedControllerChanged);
|
widget.feedController.removeListener(_handleFeedControllerChanged);
|
||||||
|
_cancelRecommendRetries();
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -199,39 +281,51 @@ class _RecommendPageState extends State<RecommendPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _recommendFeed(BuildContext context) {
|
Widget _recommendFeed(BuildContext context) {
|
||||||
|
final feed = widget.feedController;
|
||||||
final dramas = widget.feedController.dramas;
|
final dramas = widget.feedController.dramas;
|
||||||
if (dramas.isEmpty) {
|
if (dramas.isNotEmpty) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
return PlayerPage(
|
||||||
if (widget.feedController.loadingFirstPage) {
|
key: const Key('recommend-feed-player'),
|
||||||
return const _RecommendFeedSkeleton();
|
homeMode: true,
|
||||||
}
|
embedded: true,
|
||||||
|
active: widget.active && _tab == RecommendInnerTab.feed,
|
||||||
|
showHomeFeedHeader: false,
|
||||||
|
drama: dramas.first,
|
||||||
|
initialIndex: 0,
|
||||||
|
feedDramas: dramas,
|
||||||
|
feedChannel: widget.feedController.channel,
|
||||||
|
onOpenFullPlayer: (drama, episode) =>
|
||||||
|
_openDrama(drama, episodeId: episode.id),
|
||||||
|
onRefreshFeed: widget.feedController.refreshCurrent,
|
||||||
|
onLoadMore: widget.feedController.loadMore,
|
||||||
|
onImmersiveChanged: widget.onImmersiveChanged,
|
||||||
|
scrubCancelRequest: _scrubCancelRequest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!feed.loaded ||
|
||||||
|
feed.isLoading ||
|
||||||
|
_recommendRefreshInFlight ||
|
||||||
|
_recommendRetryTimer != null) {
|
||||||
|
return const _RecommendFeedSkeleton();
|
||||||
|
}
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
if (feed.lastError != null) {
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: AppColors.mediaBlack,
|
color: AppColors.mediaBlack,
|
||||||
child: EmptyState(
|
child: EmptyState(
|
||||||
key: const Key('recommend-feed-empty'),
|
key: const Key('recommend-feed-empty'),
|
||||||
icon: Icons.movie_outlined,
|
icon: Icons.wifi_off_rounded,
|
||||||
title: l10n.noStories,
|
title: l10n.networkError,
|
||||||
actionLabel: l10n.retry,
|
|
||||||
onAction: () => unawaited(widget.feedController.refreshCurrent()),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return PlayerPage(
|
return ColoredBox(
|
||||||
key: const Key('recommend-feed-player'),
|
color: AppColors.mediaBlack,
|
||||||
homeMode: true,
|
child: EmptyState(
|
||||||
embedded: true,
|
key: const Key('recommend-feed-empty'),
|
||||||
active: widget.active && _tab == RecommendInnerTab.feed,
|
icon: Icons.movie_outlined,
|
||||||
showHomeFeedHeader: false,
|
title: l10n.noStories,
|
||||||
drama: dramas.first,
|
),
|
||||||
initialIndex: 0,
|
|
||||||
feedDramas: dramas,
|
|
||||||
feedChannel: widget.feedController.channel,
|
|
||||||
onOpenFullPlayer: (drama, episode) =>
|
|
||||||
_openDrama(drama, episodeId: episode.id),
|
|
||||||
onRefreshFeed: widget.feedController.refreshCurrent,
|
|
||||||
onLoadMore: widget.feedController.loadMore,
|
|
||||||
onImmersiveChanged: widget.onImmersiveChanged,
|
|
||||||
scrubCancelRequest: _scrubCancelRequest,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ import '../support/fake_catalog.dart';
|
|||||||
import '../support/fake_category.dart';
|
import '../support/fake_category.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
testWidgets('should_show_skeleton_before_feed_is_loaded', (tester) async {
|
||||||
|
await _pumpRecommend(tester, active: false);
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('recommend-feed-skeleton')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('recommend-feed-empty')), findsNothing);
|
||||||
|
expect(find.text('Retry'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('should_refresh_once_when_parent_rebuilds_repeatedly', (
|
testWidgets('should_refresh_once_when_parent_rebuilds_repeatedly', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -52,6 +60,64 @@ void main() {
|
|||||||
expect(fixture.controller.refreshCalls, 2);
|
expect(fixture.controller.refreshCalls, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_auto_retry_three_times_then_show_network_error', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final fixture = await _pumpFailingRecommend(tester);
|
||||||
|
|
||||||
|
expect(fixture.controller.refreshCalls, 1);
|
||||||
|
expect(find.byKey(const Key('recommend-feed-skeleton')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('recommend-feed-empty')), findsNothing);
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < 3; attempt++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 1500));
|
||||||
|
await tester.pump();
|
||||||
|
expect(fixture.controller.refreshCalls, attempt + 2);
|
||||||
|
if (attempt < 2) {
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('recommend-feed-skeleton')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('recommend-feed-empty')), findsNothing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(
|
||||||
|
tester.element(find.byType(RecommendPage)),
|
||||||
|
)!;
|
||||||
|
expect(fixture.controller.refreshCalls, 4);
|
||||||
|
expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('recommend-feed-empty')), findsOneWidget);
|
||||||
|
expect(find.text(l10n.networkError), findsOneWidget);
|
||||||
|
expect(find.text(l10n.retry), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_cancel_pending_auto_retry_when_disposed', (tester) async {
|
||||||
|
final fixture = await _pumpFailingRecommend(tester);
|
||||||
|
expect(fixture.controller.refreshCalls, 1);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink()));
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
|
||||||
|
expect(fixture.controller.refreshCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'should_show_no_stories_without_retry_when_loaded_feed_is_empty',
|
||||||
|
(tester) async {
|
||||||
|
final fixture = await _pumpEmptyRecommend(tester);
|
||||||
|
|
||||||
|
final l10n = AppLocalizations.of(
|
||||||
|
tester.element(find.byType(RecommendPage)),
|
||||||
|
)!;
|
||||||
|
expect(fixture.controller.refreshCalls, 1);
|
||||||
|
expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('recommend-feed-empty')), findsOneWidget);
|
||||||
|
expect(find.text(l10n.noStories), findsOneWidget);
|
||||||
|
expect(find.text(l10n.retry), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('should_not_refresh_after_dispose_when_channel_changes', (
|
testWidgets('should_not_refresh_after_dispose_when_channel_changes', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -96,6 +162,58 @@ Future<_RecommendFixture> _pumpRecommend(
|
|||||||
return fixture;
|
return fixture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<_StatefulRecommendFixture> _pumpFailingRecommend(
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final catalog = FakeCatalogRepository();
|
||||||
|
final controller = _StatefulHomeFeedController(catalog);
|
||||||
|
final categories = CategoryController(
|
||||||
|
FakeCategoryRepository(catalog: catalog),
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
addTearDown(categories.dispose);
|
||||||
|
|
||||||
|
final fixture = _StatefulRecommendFixture(
|
||||||
|
controller: controller,
|
||||||
|
categoryController: categories,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(fixture.app());
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 1));
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_StatefulRecommendFixture> _pumpEmptyRecommend(
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final catalog = FakeCatalogRepository();
|
||||||
|
final controller = _StatefulHomeFeedController(catalog, fail: false);
|
||||||
|
final categories = CategoryController(
|
||||||
|
FakeCategoryRepository(catalog: catalog),
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
addTearDown(categories.dispose);
|
||||||
|
|
||||||
|
final fixture = _StatefulRecommendFixture(
|
||||||
|
controller: controller,
|
||||||
|
categoryController: categories,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(fixture.app());
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 1));
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
class _RecommendFixture {
|
class _RecommendFixture {
|
||||||
const _RecommendFixture({
|
const _RecommendFixture({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
@@ -123,6 +241,32 @@ class _RecommendFixture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _StatefulRecommendFixture {
|
||||||
|
const _StatefulRecommendFixture({
|
||||||
|
required this.controller,
|
||||||
|
required this.categoryController,
|
||||||
|
});
|
||||||
|
|
||||||
|
final _StatefulHomeFeedController controller;
|
||||||
|
final CategoryController categoryController;
|
||||||
|
|
||||||
|
Widget app() {
|
||||||
|
return MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: ChangeNotifierProvider<CategoryController>.value(
|
||||||
|
value: categoryController,
|
||||||
|
child: RecommendPage(
|
||||||
|
feedController: controller,
|
||||||
|
active: true,
|
||||||
|
videoOnly: true,
|
||||||
|
onSearch: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _RecommendHarness extends StatefulWidget {
|
class _RecommendHarness extends StatefulWidget {
|
||||||
const _RecommendHarness({
|
const _RecommendHarness({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -181,3 +325,36 @@ class _RecordingHomeFeedController extends HomeFeedController {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _StatefulHomeFeedController extends HomeFeedController {
|
||||||
|
_StatefulHomeFeedController(super.repository, {this.fail = true});
|
||||||
|
|
||||||
|
final bool fail;
|
||||||
|
int refreshCalls = 0;
|
||||||
|
bool _loaded = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _lastError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get loaded => _loaded;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get lastError => _lastError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> refreshCurrent() async {
|
||||||
|
refreshCalls++;
|
||||||
|
_isLoading = true;
|
||||||
|
_lastError = null;
|
||||||
|
notifyListeners();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
_isLoading = false;
|
||||||
|
_loaded = true;
|
||||||
|
if (fail) _lastError = 'network error';
|
||||||
|
notifyListeners();
|
||||||
|
return !fail;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user