merge: 开屏后直接展示首页骨架
This commit is contained in:
commit
7804809d83
10 files changed
+300
-166
No files matched your search
@@ -78,6 +78,7 @@ abstract final class AppColors {
|
|||||||
/// 中性深灰卡片与控件表面。
|
/// 中性深灰卡片与控件表面。
|
||||||
static const contentCard = Color(0xFF16161A);
|
static const contentCard = Color(0xFF16161A);
|
||||||
static const contentSurface = Color(0xFF1C1C1E);
|
static const contentSurface = Color(0xFF1C1C1E);
|
||||||
|
static const skeletonSurface = Color(0xFF141414);
|
||||||
static const contentChip = Color(0xFF2C2C2E);
|
static const contentChip = Color(0xFF2C2C2E);
|
||||||
static const contentBorder = Color(0x1AFFFFFF);
|
static const contentBorder = Color(0x1AFFFFFF);
|
||||||
static const filterChipSelected = Color(0xFF21090E);
|
static const filterChipSelected = Color(0xFF21090E);
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ abstract final class AppDimens {
|
|||||||
static const double cardBadgeFont = 9;
|
static const double cardBadgeFont = 9;
|
||||||
static const double cardCoverTitleGap = 6;
|
static const double cardCoverTitleGap = 6;
|
||||||
static const double cardTitleTagGap = 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 cardCoverAspectRatio = 3 / 4;
|
||||||
static const double homeCardBodyExtent = 84;
|
static const double homeCardBodyExtent = 84;
|
||||||
static const double homePrimaryTabFont = 18;
|
static const double homePrimaryTabFont = 18;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import '../../core/theme/app_colors.dart';
|
|||||||
import '../../core/theme/app_dimens.dart';
|
import '../../core/theme/app_dimens.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../core/widgets/app_pull_to_refresh.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/empty_state.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../player/open_player.dart';
|
import '../player/open_player.dart';
|
||||||
@@ -201,15 +202,14 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
return _resultsGrid();
|
return _resultsGrid();
|
||||||
}
|
}
|
||||||
if (_logic.searchLoading) {
|
if (_logic.searchLoading) {
|
||||||
return _stateScroll(
|
return const DramaGridSkeleton(
|
||||||
const SizedBox.square(
|
key: Key('search-loading-skeleton'),
|
||||||
key: Key('search-loading'),
|
columns: 2,
|
||||||
dimension: 20,
|
horizontalPadding: AppDimens.feedPageHorizontal,
|
||||||
child: CircularProgressIndicator(
|
crossAxisSpacing: AppDimens.searchResultGap,
|
||||||
strokeWidth: 2,
|
mainAxisSpacing: AppDimens.searchResultRowGap,
|
||||||
color: AppColors.textPrimary,
|
itemCount: 6,
|
||||||
),
|
physics: AppPullToRefresh.physics,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (_logic.searchError != null) {
|
if (_logic.searchError != null) {
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import '../player/open_player.dart';
|
|||||||
import '../watch_records/watch_records_page.dart';
|
import '../watch_records/watch_records_page.dart';
|
||||||
|
|
||||||
class HomeLogic extends ChangeNotifier {
|
class HomeLogic extends ChangeNotifier {
|
||||||
HomeLogic(List<Drama> dramas) : currentDrama = dramas.first;
|
HomeLogic(List<Drama> dramas)
|
||||||
|
: currentDrama = dramas.isEmpty ? null : dramas.first;
|
||||||
|
|
||||||
final scaffoldKey = GlobalKey<ScaffoldState>();
|
final scaffoldKey = GlobalKey<ScaffoldState>();
|
||||||
Drama currentDrama;
|
Drama? currentDrama;
|
||||||
int tab = 0;
|
int tab = 0;
|
||||||
int resumeRequest = 0;
|
int resumeRequest = 0;
|
||||||
int backRequest = 0;
|
int backRequest = 0;
|
||||||
@@ -44,13 +45,15 @@ class HomeLogic extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> openCurrentSeries(BuildContext context) async {
|
Future<void> openCurrentSeries(BuildContext context) async {
|
||||||
|
final drama = currentDrama;
|
||||||
|
if (drama == null) return;
|
||||||
detailOpen = true;
|
detailOpen = true;
|
||||||
notify();
|
notify();
|
||||||
await WidgetsBinding.instance.endOfFrame;
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
try {
|
try {
|
||||||
await Navigator.of(context).push<void>(
|
await Navigator.of(context).push<void>(
|
||||||
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: currentDrama)),
|
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../core/config/app_config.dart';
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/data/services/home_feed_controller.dart';
|
import '../../core/data/services/home_feed_controller.dart';
|
||||||
import '../../core/data/services/catalog_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/category_controller.dart';
|
||||||
import '../../core/data/services/favorite_controller.dart';
|
import '../../core/data/services/favorite_controller.dart';
|
||||||
import '../../core/data/services/wallet_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/theme/app_theme.dart';
|
||||||
import '../../core/widgets/auth_gate.dart';
|
import '../../core/widgets/auth_gate.dart';
|
||||||
import '../../core/widgets/app_root_tab.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 '../discover/search_page.dart';
|
||||||
import '../following/following_page.dart';
|
import '../following/following_page.dart';
|
||||||
import '../filter/filter_page.dart';
|
import '../filter/filter_page.dart';
|
||||||
@@ -32,7 +28,6 @@ import '../wallet/wallet_page.dart';
|
|||||||
import 'home_binding.dart';
|
import 'home_binding.dart';
|
||||||
import 'home_logic.dart';
|
import 'home_logic.dart';
|
||||||
import 'widgets/home_feed_grid.dart';
|
import 'widgets/home_feed_grid.dart';
|
||||||
import 'widgets/home_failure_view.dart';
|
|
||||||
import 'widgets/home_grid_header.dart';
|
import 'widgets/home_grid_header.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
@@ -85,58 +80,15 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _retryHome(CatalogController catalog) {
|
|
||||||
return retryCatalogLoad(reload: catalog.load);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _editHomeEndpoint(CatalogController catalog) {
|
|
||||||
return showCatalogEndpointEditor(context, reload: catalog.load);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final catalog = context.watch<CatalogController>();
|
final catalog = context.watch<CatalogController>();
|
||||||
final l10n = AppLocalizations.of(context)!;
|
return Theme(data: AppTheme.dark(), child: _buildHome(context, catalog));
|
||||||
return Theme(
|
|
||||||
data: AppTheme.dark(),
|
|
||||||
child: _buildHome(context, catalog, l10n),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHome(
|
Widget _buildHome(BuildContext context, CatalogController catalog) {
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final homeFeed = _ensureHomeFeed(catalog);
|
final homeFeed = _ensureHomeFeed(catalog);
|
||||||
if (!_recommendSeeded) {
|
if (!_recommendSeeded && catalog.dramas.isNotEmpty) {
|
||||||
_recommendSeeded = true;
|
_recommendSeeded = true;
|
||||||
homeFeed.seedRecommend(catalog.dramas);
|
homeFeed.seedRecommend(catalog.dramas);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -155,16 +107,6 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
|||||||
await homeFeed.prefetchChannels();
|
await homeFeed.prefetchChannels();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _retryLoading() {
|
|
||||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
|
||||||
value: SystemUiOverlayStyle.light,
|
|
||||||
child: const Scaffold(
|
|
||||||
backgroundColor: AppColors.splashInk,
|
|
||||||
body: Center(child: CircularProgressIndicator(color: AppColors.blue)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 四个根页面共享同一路由并保持各自状态。
|
/// 四个根页面共享同一路由并保持各自状态。
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../../core/data/models/content_category.dart';
|
import '../../../core/data/models/content_category.dart';
|
||||||
import '../../../core/data/models/drama.dart';
|
import '../../../core/data/models/drama.dart';
|
||||||
import '../../../core/data/services/auth_controller.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/category_controller.dart';
|
||||||
import '../../../core/data/services/home_feed_controller.dart';
|
import '../../../core/data/services/home_feed_controller.dart';
|
||||||
import '../../../core/data/services/wallet_controller.dart';
|
import '../../../core/data/services/wallet_controller.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/widgets/app_pull_to_refresh.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/empty_state.dart';
|
||||||
import '../../../core/widgets/paged_list_footer.dart';
|
import '../../../core/widgets/paged_list_footer.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
@@ -75,6 +77,7 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final categories = context.watch<CategoryController>().categories;
|
final categories = context.watch<CategoryController>().categories;
|
||||||
|
final catalog = context.watch<CatalogController>();
|
||||||
final auth = context.watch<AuthController>();
|
final auth = context.watch<AuthController>();
|
||||||
final wallet = context.watch<WalletController>();
|
final wallet = context.watch<WalletController>();
|
||||||
final shortcuts = _categoryShortcuts(categories, l10n);
|
final shortcuts = _categoryShortcuts(categories, l10n);
|
||||||
@@ -123,6 +126,7 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
|||||||
context,
|
context,
|
||||||
l10n,
|
l10n,
|
||||||
tab: HomePrimaryTab.values[index],
|
tab: HomePrimaryTab.values[index],
|
||||||
|
catalog: catalog,
|
||||||
shortcuts: shortcuts,
|
shortcuts: shortcuts,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -154,21 +158,36 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
AppLocalizations l10n, {
|
AppLocalizations l10n, {
|
||||||
required HomePrimaryTab tab,
|
required HomePrimaryTab tab,
|
||||||
|
required CatalogController catalog,
|
||||||
required List<HomeGridCategoryShortcut> shortcuts,
|
required List<HomeGridCategoryShortcut> 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) {
|
if (tab == HomePrimaryTab.ranking) {
|
||||||
return const RankingPage(embedded: true);
|
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 tag = _selectedTags[tab];
|
||||||
final dramas = _dramasFor(tab, tag, shortcuts);
|
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(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final itemWidth =
|
final itemWidth =
|
||||||
@@ -237,6 +256,24 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<void> _refresh() async {
|
Future<void> _refresh() async {
|
||||||
await widget.feedController.refreshCurrent();
|
await widget.feedController.refreshCurrent();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,15 +127,7 @@ class _RecommendPageState extends State<RecommendPage> {
|
|||||||
if (dramas.isEmpty) {
|
if (dramas.isEmpty) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
if (widget.feedController.loadingFirstPage) {
|
if (widget.feedController.loadingFirstPage) {
|
||||||
return const ColoredBox(
|
return const _RecommendFeedSkeleton();
|
||||||
color: AppColors.mediaBlack,
|
|
||||||
child: Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
key: Key('recommend-feed-loading'),
|
|
||||||
color: AppColors.muted,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: AppColors.mediaBlack,
|
color: AppColors.mediaBlack,
|
||||||
@@ -259,6 +251,68 @@ class _RecommendPageState extends State<RecommendPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class _KeepAliveTab extends StatefulWidget {
|
||||||
const _KeepAliveTab({super.key, required this.child});
|
const _KeepAliveTab({super.key, required this.child});
|
||||||
|
|
||||||
|
|||||||
@@ -23,15 +23,17 @@ void main() {
|
|||||||
test('should_make_first_catalog_entry_current_when_catalog_loads', () {
|
test('should_make_first_catalog_entry_current_when_catalog_loads', () {
|
||||||
final logic = HomeLogic(dramas);
|
final logic = HomeLogic(dramas);
|
||||||
|
|
||||||
expect(logic.currentDrama.id, 'signal');
|
expect(logic.currentDrama?.id, 'signal');
|
||||||
expect(logic.tab, 0);
|
expect(logic.tab, 0);
|
||||||
expect(logic.drawerOpen, isFalse);
|
expect(logic.drawerOpen, isFalse);
|
||||||
expect(logic.videoImmersive, isFalse);
|
expect(logic.videoImmersive, isFalse);
|
||||||
logic.dispose();
|
logic.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should_not_build_shell_when_catalog_is_empty', () {
|
test('should_allow_empty_catalog_for_startup_skeleton', () {
|
||||||
expect(() => HomeLogic(const []), throwsStateError);
|
final logic = HomeLogic(const []);
|
||||||
|
expect(logic.currentDrama, isNull);
|
||||||
|
logic.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should_not_raise_request_counter_when_only_tab_switches', () {
|
test('should_not_raise_request_counter_when_only_tab_switches', () {
|
||||||
@@ -109,7 +111,7 @@ void main() {
|
|||||||
final logic = HomeLogic(dramas);
|
final logic = HomeLogic(dramas);
|
||||||
|
|
||||||
logic.onDramaChanged(dramas[1]);
|
logic.onDramaChanged(dramas[1]);
|
||||||
expect(logic.currentDrama.id, 'city');
|
expect(logic.currentDrama?.id, 'city');
|
||||||
|
|
||||||
logic.onImmersiveChanged(true);
|
logic.onImmersiveChanged(true);
|
||||||
expect(logic.videoImmersive, isTrue);
|
expect(logic.videoImmersive, isTrue);
|
||||||
@@ -143,7 +145,7 @@ void main() {
|
|||||||
|
|
||||||
expect(immediateNotifications, 0);
|
expect(immediateNotifications, 0);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
expect(logic.currentDrama.id, 'city');
|
expect(logic.currentDrama?.id, 'city');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(notifications, 1);
|
expect(notifications, 1);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
|
|||||||
@@ -4,10 +4,6 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/app.dart';
|
||||||
import 'package:fruitshorts/core/config/app_config.dart';
|
import 'package:fruitshorts/core/config/app_config.dart';
|
||||||
import 'package:fruitshorts/core/data/models/drama.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/favorite_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/repositories/playback_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/repositories/watch_history_repository.dart';
|
||||||
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
|
||||||
import 'package:fruitshorts/pages/home/home_view.dart';
|
|
||||||
|
|
||||||
import '../support/fake_catalog.dart';
|
import '../support/fake_catalog.dart';
|
||||||
import '../support/fake_category.dart';
|
import '../support/fake_category.dart';
|
||||||
@@ -42,80 +36,63 @@ void main() {
|
|||||||
AppConfig.ready = Future<void>.value();
|
AppConfig.ready = Future<void>.value();
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_show_loading_without_flutter_splash_before_settles', (
|
testWidgets('should_show_home_shell_and_grid_skeleton_while_catalog_loads', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final controller = CatalogController(_EmptyCatalogRepository())
|
final repository = _PendingCatalogRepository();
|
||||||
..loading = true
|
await _pumpApp(tester, repository);
|
||||||
..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<void>.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(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await tester.pump();
|
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.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();
|
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);
|
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<void> _pumpHomePage(
|
Future<void> _pumpApp(WidgetTester tester, CatalogRepository repository) async {
|
||||||
WidgetTester tester,
|
final catalog = FakeCatalogRepository();
|
||||||
CatalogController controller,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
ChangeNotifierProvider<CatalogController>.value(
|
LumaDramaApp(
|
||||||
value: controller,
|
playerFactory: createWidgetPlayer,
|
||||||
child: MaterialApp(
|
catalogRepository: repository,
|
||||||
theme: AppTheme.light(),
|
categoryRepository: FakeCategoryRepository(catalog: repository),
|
||||||
locale: const Locale('en'),
|
watchHistoryRepository: InMemoryWatchHistoryRepository(),
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
playbackRepository: FakePlaybackRepository(),
|
||||||
home: const HomePage(),
|
commentRepository: FakeCommentRepository(),
|
||||||
),
|
interactionRepository: FakeInteractionRepository(),
|
||||||
|
noticeRepository: FakeNoticeRepository(),
|
||||||
|
haptics: FakeHaptics(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@@ -149,8 +126,6 @@ class _EmptyCatalogRepository implements CatalogRepository {
|
|||||||
class _PendingCatalogRepository implements CatalogRepository {
|
class _PendingCatalogRepository implements CatalogRepository {
|
||||||
final Completer<HomeFeed> _home = Completer<HomeFeed>();
|
final Completer<HomeFeed> _home = Completer<HomeFeed>();
|
||||||
|
|
||||||
List<Drama> get dramas => _feed.dramas;
|
|
||||||
|
|
||||||
void complete(HomeFeed feed) {
|
void complete(HomeFeed feed) {
|
||||||
if (!_home.isCompleted) _home.complete(feed);
|
if (!_home.isCompleted) _home.complete(feed);
|
||||||
}
|
}
|
||||||
@@ -172,3 +147,22 @@ class _PendingCatalogRepository implements CatalogRepository {
|
|||||||
@override
|
@override
|
||||||
Future<Drama> fetchDrama(String dramaId) async => _feed.dramas.first;
|
Future<Drama> fetchDrama(String dramaId) async => _feed.dramas.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FailingCatalogRepository implements CatalogRepository {
|
||||||
|
@override
|
||||||
|
Future<HomeFeed> fetchHomeFeed() async => throw StateError('offline');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HomeFeed> fetchDiscoverFeed() async => throw StateError('offline');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FeedPage> fetchFeed({
|
||||||
|
List<String> types = const [],
|
||||||
|
String? categoryId,
|
||||||
|
String? cursor,
|
||||||
|
int size = 10,
|
||||||
|
}) async => throw StateError('offline');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Drama> fetchDrama(String dramaId) async => throw StateError('offline');
|
||||||
|
}
|
||||||
Reference in new issue
Block a user