diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index a3fb544..759f7bb 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -78,6 +78,7 @@ abstract final class AppColors { /// 中性深灰卡片与控件表面。 static const contentCard = Color(0xFF16161A); static const contentSurface = Color(0xFF1C1C1E); + static const skeletonSurface = Color(0xFF141414); static const contentChip = Color(0xFF2C2C2E); static const contentBorder = Color(0x1AFFFFFF); static const filterChipSelected = Color(0xFF21090E); diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index c127122..b4f4733 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -110,6 +110,11 @@ abstract final class AppDimens { static const double cardBadgeFont = 9; static const double cardCoverTitleGap = 6; static const double cardTitleTagGap = 6; + static const double skeletonBarHeight = 10; + static const double skeletonBarRadius = 4; + static const double skeletonBarGap = 4; + static const double skeletonTitleBarGap = 3; + static const double skeletonBodyExtent = 34; static const double cardCoverAspectRatio = 3 / 4; static const double homeCardBodyExtent = 84; static const double homePrimaryTabFont = 18; diff --git a/lib/core/widgets/drama_grid_skeleton.dart b/lib/core/widgets/drama_grid_skeleton.dart new file mode 100644 index 0000000..9fbc07c --- /dev/null +++ b/lib/core/widgets/drama_grid_skeleton.dart @@ -0,0 +1,96 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; +import '../theme/app_dimens.dart'; + +class DramaGridSkeleton extends StatelessWidget { + const DramaGridSkeleton({ + super.key, + required this.columns, + required this.horizontalPadding, + required this.crossAxisSpacing, + required this.mainAxisSpacing, + this.itemCount = 9, + this.physics = const NeverScrollableScrollPhysics(), + }); + + final int columns; + final double horizontalPadding; + final double crossAxisSpacing; + final double mainAxisSpacing; + final int itemCount; + final ScrollPhysics physics; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final itemWidth = + (constraints.maxWidth - + horizontalPadding * 2 - + crossAxisSpacing * (columns - 1)) / + columns; + final itemExtent = + itemWidth / AppDimens.cardCoverAspectRatio + + AppDimens.skeletonBodyExtent; + return GridView.builder( + physics: physics, + padding: EdgeInsets.fromLTRB( + horizontalPadding, + AppDimens.spaceMd, + horizontalPadding, + AppDimens.spaceXl, + ), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + crossAxisSpacing: crossAxisSpacing, + mainAxisSpacing: mainAxisSpacing, + mainAxisExtent: itemExtent, + ), + itemCount: itemCount, + itemBuilder: (context, index) => Column( + key: Key('drama-grid-skeleton-$index'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: AppDimens.cardCoverAspectRatio, + child: Container( + decoration: BoxDecoration( + color: AppColors.skeletonSurface, + borderRadius: BorderRadius.circular( + AppDimens.homeCardRadius, + ), + ), + ), + ), + const SizedBox(height: AppDimens.cardCoverTitleGap), + const _SkeletonBar(), + const SizedBox(height: AppDimens.skeletonTitleBarGap), + const FractionallySizedBox( + widthFactor: 0.62, + child: _SkeletonBar(), + ), + ], + ), + ); + }, + ); + } +} + +class _SkeletonBar extends StatelessWidget { + const _SkeletonBar(); + + @override + Widget build(BuildContext context) { + return Container( + height: AppDimens.skeletonBarHeight, + decoration: BoxDecoration( + color: AppColors.skeletonSurface, + borderRadius: BorderRadius.circular(AppDimens.skeletonBarRadius), + ), + ); + } +} diff --git a/lib/pages/discover/search_page.dart b/lib/pages/discover/search_page.dart index cf086ef..c5754da 100644 --- a/lib/pages/discover/search_page.dart +++ b/lib/pages/discover/search_page.dart @@ -12,6 +12,7 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; +import '../../core/widgets/drama_grid_skeleton.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../player/open_player.dart'; @@ -201,15 +202,14 @@ class _SearchPageState extends State { return _resultsGrid(); } if (_logic.searchLoading) { - return _stateScroll( - const SizedBox.square( - key: Key('search-loading'), - dimension: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.textPrimary, - ), - ), + return const DramaGridSkeleton( + key: Key('search-loading-skeleton'), + columns: 2, + horizontalPadding: AppDimens.feedPageHorizontal, + crossAxisSpacing: AppDimens.searchResultGap, + mainAxisSpacing: AppDimens.searchResultRowGap, + itemCount: 6, + physics: AppPullToRefresh.physics, ); } if (_logic.searchError != null) { diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index 7e5dc6d..daa8fcc 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -12,10 +12,11 @@ import '../player/open_player.dart'; import '../watch_records/watch_records_page.dart'; class HomeLogic extends ChangeNotifier { - HomeLogic(List dramas) : currentDrama = dramas.first; + HomeLogic(List dramas) + : currentDrama = dramas.isEmpty ? null : dramas.first; final scaffoldKey = GlobalKey(); - Drama currentDrama; + Drama? currentDrama; int tab = 0; int resumeRequest = 0; int backRequest = 0; @@ -44,13 +45,15 @@ class HomeLogic extends ChangeNotifier { } Future openCurrentSeries(BuildContext context) async { + final drama = currentDrama; + if (drama == null) return; detailOpen = true; notify(); await WidgetsBinding.instance.endOfFrame; if (!context.mounted) return; try { await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => DramaDetailPage(drama: currentDrama)), + MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)), ); } finally { if (context.mounted) { diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 4cc7719..497e73d 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -9,7 +9,6 @@ import 'package:provider/provider.dart'; import '../../core/config/app_config.dart'; import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/catalog_controller.dart'; -import '../../core/data/services/catalog_retry.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/wallet_controller.dart'; @@ -18,9 +17,6 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/app_root_tab.dart'; -import '../../core/widgets/catalog_endpoint_editor.dart'; -import '../../core/widgets/empty_state.dart'; -import '../../l10n/app_localizations.dart'; import '../discover/search_page.dart'; import '../following/following_page.dart'; import '../filter/filter_page.dart'; @@ -32,7 +28,6 @@ import '../wallet/wallet_page.dart'; import 'home_binding.dart'; import 'home_logic.dart'; import 'widgets/home_feed_grid.dart'; -import 'widgets/home_failure_view.dart'; import 'widgets/home_grid_header.dart'; class HomePage extends StatefulWidget { @@ -85,58 +80,15 @@ class _HomePageState extends State with WidgetsBindingObserver { } } - Future _retryHome(CatalogController catalog) { - return retryCatalogLoad(reload: catalog.load); - } - - Future _editHomeEndpoint(CatalogController catalog) { - return showCatalogEndpointEditor(context, reload: catalog.load); - } - @override Widget build(BuildContext context) { final catalog = context.watch(); - final l10n = AppLocalizations.of(context)!; - return Theme( - data: AppTheme.dark(), - child: _buildHome(context, catalog, l10n), - ); + return Theme(data: AppTheme.dark(), child: _buildHome(context, catalog)); } - Widget _buildHome( - BuildContext context, - CatalogController catalog, - AppLocalizations l10n, - ) { - if (catalog.loading) { - return _retryLoading(); - } - if (catalog.error != null) { - return Scaffold( - backgroundColor: AppColors.transparent, - body: HomeFailureView( - error: catalog.debugError ?? catalog.error, - onRetry: () => unawaited(_retryHome(catalog)), - onEditEndpoint: () => unawaited(_editHomeEndpoint(catalog)), - ), - ); - } - if (catalog.dramas.isEmpty) { - return Scaffold( - backgroundColor: AppColors.transparent, - body: EmptyState( - key: const Key('home-empty'), - icon: Icons.movie_outlined, - title: l10n.noStories, - subtitle: l10n.emptyHint, - actionLabel: l10n.retry, - onAction: catalog.load, - ), - ); - } - + Widget _buildHome(BuildContext context, CatalogController catalog) { final homeFeed = _ensureHomeFeed(catalog); - if (!_recommendSeeded) { + if (!_recommendSeeded && catalog.dramas.isNotEmpty) { _recommendSeeded = true; homeFeed.seedRecommend(catalog.dramas); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -155,16 +107,6 @@ class _HomePageState extends State with WidgetsBindingObserver { await homeFeed.prefetchChannels(); } } - - Widget _retryLoading() { - return AnnotatedRegion( - value: SystemUiOverlayStyle.light, - child: const Scaffold( - backgroundColor: AppColors.splashInk, - body: Center(child: CircularProgressIndicator(color: AppColors.blue)), - ), - ); - } } /// 四个根页面共享同一路由并保持各自状态。 diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index 5c7cb54..8f99c3e 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -8,12 +8,14 @@ import 'package:provider/provider.dart'; import '../../../core/data/models/content_category.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/services/auth_controller.dart'; +import '../../../core/data/services/catalog_controller.dart'; import '../../../core/data/services/category_controller.dart'; import '../../../core/data/services/home_feed_controller.dart'; import '../../../core/data/services/wallet_controller.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/app_pull_to_refresh.dart'; +import '../../../core/widgets/drama_grid_skeleton.dart'; import '../../../core/widgets/empty_state.dart'; import '../../../core/widgets/paged_list_footer.dart'; import '../../../l10n/app_localizations.dart'; @@ -75,6 +77,7 @@ class _HomeFeedGridState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final categories = context.watch().categories; + final catalog = context.watch(); final auth = context.watch(); final wallet = context.watch(); final shortcuts = _categoryShortcuts(categories, l10n); @@ -123,6 +126,7 @@ class _HomeFeedGridState extends State { context, l10n, tab: HomePrimaryTab.values[index], + catalog: catalog, shortcuts: shortcuts, ), ), @@ -154,21 +158,36 @@ class _HomeFeedGridState extends State { BuildContext context, AppLocalizations l10n, { required HomePrimaryTab tab, + required CatalogController catalog, required List shortcuts, }) { + if (catalog.error != null && widget.feedController.dramas.isEmpty) { + return _catalogErrorState(l10n, catalog); + } + if ((!catalog.settled || catalog.loading) && + widget.feedController.dramas.isEmpty) { + return const DramaGridSkeleton( + key: Key('home-grid-skeleton'), + columns: 3, + horizontalPadding: AppDimens.feedPageHorizontal, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + ); + } if (tab == HomePrimaryTab.ranking) { return const RankingPage(embedded: true); } - if (widget.feedController.loadingFirstPage) { - return const Center( - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - } final tag = _selectedTags[tab]; final dramas = _dramasFor(tab, tag, shortcuts); + if (widget.feedController.loadingFirstPage && dramas.isEmpty) { + return const DramaGridSkeleton( + key: Key('home-grid-skeleton'), + columns: 3, + horizontalPadding: AppDimens.feedPageHorizontal, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + ); + } return LayoutBuilder( builder: (context, constraints) { final itemWidth = @@ -237,6 +256,24 @@ class _HomeFeedGridState extends State { ); } + Widget _catalogErrorState(AppLocalizations l10n, CatalogController catalog) { + final theme = Theme.of(context); + return Theme( + data: theme.copyWith( + colorScheme: theme.colorScheme.copyWith( + onSurface: AppColors.textTertiary, + ), + ), + child: EmptyState( + key: const Key('home-grid-error'), + icon: Icons.cloud_off_rounded, + title: l10n.catalogError, + actionLabel: l10n.retry, + onAction: catalog.load, + ), + ); + } + Future _refresh() async { await widget.feedController.refreshCurrent(); } diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 81527fc..e56b9b9 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -127,15 +127,7 @@ class _RecommendPageState extends State { if (dramas.isEmpty) { final l10n = AppLocalizations.of(context)!; if (widget.feedController.loadingFirstPage) { - return const ColoredBox( - color: AppColors.mediaBlack, - child: Center( - child: CircularProgressIndicator( - key: Key('recommend-feed-loading'), - color: AppColors.muted, - ), - ), - ); + return const _RecommendFeedSkeleton(); } return ColoredBox( color: AppColors.mediaBlack, @@ -259,6 +251,68 @@ class _RecommendPageState extends State { } } +class _RecommendFeedSkeleton extends StatelessWidget { + const _RecommendFeedSkeleton(); + + @override + Widget build(BuildContext context) { + return const ColoredBox( + key: Key('recommend-feed-skeleton'), + color: AppColors.mediaBlack, + child: Stack( + children: [ + Center( + child: SizedBox( + width: 132, + height: 176, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.skeletonSurface, + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + ), + ), + Positioned( + left: 14, + right: 72, + bottom: 42, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SkeletonLine(width: 190, height: 18), + SizedBox(height: 10), + _SkeletonLine(width: 140, height: 12), + SizedBox(height: 8), + _SkeletonLine(width: 220, height: 10), + ], + ), + ), + ], + ), + ); + } +} + +class _SkeletonLine extends StatelessWidget { + const _SkeletonLine({required this.width, required this.height}); + + final double width; + final double height; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: AppColors.skeletonSurface, + borderRadius: BorderRadius.circular(6), + ), + ); + } +} + class _KeepAliveTab extends StatefulWidget { const _KeepAliveTab({super.key, required this.child}); diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index 44b4dce..2f58977 100644 --- a/test/home/home_logic_test.dart +++ b/test/home/home_logic_test.dart @@ -23,15 +23,17 @@ void main() { test('should_make_first_catalog_entry_current_when_catalog_loads', () { final logic = HomeLogic(dramas); - expect(logic.currentDrama.id, 'signal'); + expect(logic.currentDrama?.id, 'signal'); expect(logic.tab, 0); expect(logic.drawerOpen, isFalse); expect(logic.videoImmersive, isFalse); logic.dispose(); }); - test('should_not_build_shell_when_catalog_is_empty', () { - expect(() => HomeLogic(const []), throwsStateError); + test('should_allow_empty_catalog_for_startup_skeleton', () { + final logic = HomeLogic(const []); + expect(logic.currentDrama, isNull); + logic.dispose(); }); test('should_not_raise_request_counter_when_only_tab_switches', () { @@ -109,7 +111,7 @@ void main() { final logic = HomeLogic(dramas); logic.onDramaChanged(dramas[1]); - expect(logic.currentDrama.id, 'city'); + expect(logic.currentDrama?.id, 'city'); logic.onImmersiveChanged(true); expect(logic.videoImmersive, isTrue); @@ -143,7 +145,7 @@ void main() { expect(immediateNotifications, 0); expect(tester.takeException(), isNull); - expect(logic.currentDrama.id, 'city'); + expect(logic.currentDrama?.id, 'city'); await tester.pump(); expect(notifications, 1); expect(tester.takeException(), isNull); diff --git a/test/home/home_startup_splash_test.dart b/test/home/home_startup_splash_test.dart index b507d05..3cf81d4 100644 --- a/test/home/home_startup_splash_test.dart +++ b/test/home/home_startup_splash_test.dart @@ -4,10 +4,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; -import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; - import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/config/app_config.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; @@ -17,10 +13,8 @@ import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; -import 'package:fruitshorts/core/data/services/catalog_controller.dart'; -import 'package:fruitshorts/core/theme/app_theme.dart'; -import 'package:fruitshorts/l10n/app_localizations.dart'; -import 'package:fruitshorts/pages/home/home_view.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; @@ -42,80 +36,63 @@ void main() { AppConfig.ready = Future.value(); }); - testWidgets('should_show_loading_without_flutter_splash_before_settles', ( + testWidgets('should_show_home_shell_and_grid_skeleton_while_catalog_loads', ( tester, ) async { - final controller = CatalogController(_EmptyCatalogRepository()) - ..loading = true - ..settled = false; - addTearDown(controller.dispose); - - await _pumpHomePage(tester, controller); - - expect(find.byType(CircularProgressIndicator), findsOneWidget); - expect(find.byKey(const Key('startup-splash')), findsNothing); - }); - - testWidgets('should_show_spinner_when_retry_loads_after_catalog_settled', ( - tester, - ) async { - final controller = CatalogController(_EmptyCatalogRepository()) - ..loading = true - ..settled = true; - addTearDown(controller.dispose); - - await _pumpHomePage(tester, controller); - - expect(find.byType(CircularProgressIndicator), findsOneWidget); - expect(find.byKey(const Key('startup-splash')), findsNothing); - }); - - testWidgets('should_enter_home_after_loading_when_data_is_ready', ( - tester, - ) async { - AppConfig.ready = Future.value(); - final catalog = _PendingCatalogRepository(); - await tester.pumpWidget( - LumaDramaApp( - playerFactory: createWidgetPlayer, - catalogRepository: catalog, - categoryRepository: FakeCategoryRepository(catalog: catalog), - watchHistoryRepository: InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), - playbackRepository: FakePlaybackRepository(), - commentRepository: FakeCommentRepository(), - interactionRepository: FakeInteractionRepository(), - noticeRepository: FakeNoticeRepository(), - haptics: FakeHaptics(), - ), - ); + final repository = _PendingCatalogRepository(); + await _pumpApp(tester, repository); await tester.pump(); + expect(find.byKey(const Key('home-tabs')), findsOneWidget); + expect(find.byKey(const Key('home-grid-header')), findsOneWidget); + expect(find.byKey(const Key('home-grid-skeleton')), findsOneWidget); + expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); expect(find.byKey(const Key('startup-splash')), findsNothing); - expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); - catalog.complete(_feed); + repository.complete(_feed); await tester.pumpAndSettle(); - expect(find.byKey(const Key('startup-splash')), findsNothing); + expect(find.byKey(const Key('home-grid-skeleton')), findsNothing); expect(find.byKey(const Key('home-grid')), findsOneWidget); }); + + testWidgets('should_show_catalog_error_inside_grid_without_full_overlay', ( + tester, + ) async { + await _pumpApp(tester, _FailingCatalogRepository()); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-grid-header')), findsOneWidget); + expect(find.byKey(const Key('home-grid-error')), findsOneWidget); + expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('should_show_catalog_empty_state_inside_grid', (tester) async { + await _pumpApp(tester, _EmptyCatalogRepository()); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-grid-header')), findsOneWidget); + expect(find.byKey(const Key('home-grid-empty')), findsOneWidget); + expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); + }); } -Future _pumpHomePage( - WidgetTester tester, - CatalogController controller, -) async { +Future _pumpApp(WidgetTester tester, CatalogRepository repository) async { + final catalog = FakeCatalogRepository(); await tester.pumpWidget( - ChangeNotifierProvider.value( - value: controller, - child: MaterialApp( - theme: AppTheme.light(), - locale: const Locale('en'), - supportedLocales: AppLocalizations.supportedLocales, - localizationsDelegates: AppLocalizations.localizationsDelegates, - home: const HomePage(), - ), + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: repository, + categoryRepository: FakeCategoryRepository(catalog: repository), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), ), ); await tester.pump(); @@ -149,8 +126,6 @@ class _EmptyCatalogRepository implements CatalogRepository { class _PendingCatalogRepository implements CatalogRepository { final Completer _home = Completer(); - List get dramas => _feed.dramas; - void complete(HomeFeed feed) { if (!_home.isCompleted) _home.complete(feed); } @@ -172,3 +147,22 @@ class _PendingCatalogRepository implements CatalogRepository { @override Future fetchDrama(String dramaId) async => _feed.dramas.first; } + +class _FailingCatalogRepository implements CatalogRepository { + @override + Future fetchHomeFeed() async => throw StateError('offline'); + + @override + Future fetchDiscoverFeed() async => throw StateError('offline'); + + @override + Future fetchFeed({ + List types = const [], + String? categoryId, + String? cursor, + int size = 10, + }) async => throw StateError('offline'); + + @override + Future fetchDrama(String dramaId) async => throw StateError('offline'); +}