diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 4248772..5780402 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -28,6 +28,7 @@ import 'drama_detail_logic.dart'; import 'widgets/drama_detail_bottom_bar.dart'; import 'widgets/drama_detail_header.dart'; import 'widgets/drama_detail_sections.dart'; +import 'widgets/drama_episode_states.dart'; const String _menuFavorite = 'favorite'; const String _menuDownload = 'download'; @@ -93,17 +94,23 @@ class _DramaDetailPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; - if (logic.loading) return _loading(l10n); - if (logic.failed) return _failed(l10n); final drama = logic.drama; - if (drama.episodes.isEmpty) return _empty(l10n, drama); + final hasCardContent = + drama.title.trim().isNotEmpty || drama.posterUrl.trim().isNotEmpty; + if (!hasCardContent && logic.loading) return _loading(l10n); + if (!hasCardContent && logic.failed) return _failed(l10n); + if (drama.episodes.isEmpty && !logic.loading && !logic.failed) { + return _empty(l10n, drama); + } final saved = context.watch().isSaved(drama.id); final continueIndex = logic.continueIndex; + final episodesReady = drama.detailLoaded && drama.episodes.isNotEmpty; return Scaffold( backgroundColor: AppColors.ink, bottomNavigationBar: DramaDetailBottomBar( l10n: l10n, - continueIndex: continueIndex, + continueIndex: episodesReady ? continueIndex : null, + enabled: episodesReady, onPlay: () => logic.openPlayer(context, continueIndex ?? 0), resume: _resume(l10n), ), @@ -134,20 +141,32 @@ class _DramaDetailPageState extends State { ), if (_tagValues(drama).isNotEmpty) SliverToBoxAdapter(child: DramaTags(tags: _tagValues(drama))), - SliverToBoxAdapter( - child: DramaEpisodeGrid( - l10n: l10n, - drama: drama, - continueIndex: continueIndex, - onOpen: (index) => logic.openPlayer(context, index), - ), - ), + SliverToBoxAdapter(child: _episodes(l10n, drama, continueIndex)), const SliverToBoxAdapter(child: SizedBox(height: 24)), ], ), ); } + Widget _episodes(AppLocalizations l10n, Drama drama, int? continueIndex) { + if (!drama.detailLoaded && logic.failed) { + return DramaEpisodesLoadError( + l10n: l10n, + message: logic.failureMessage, + onRetry: _load, + ); + } + if (!drama.detailLoaded && logic.loading && drama.episodes.isEmpty) { + return DramaEpisodesLoading(l10n: l10n, episodeCount: drama.episodeCount); + } + return DramaEpisodeGrid( + l10n: l10n, + drama: drama, + continueIndex: continueIndex, + onOpen: (index) => logic.openPlayer(context, index), + ); + } + Widget _loading(AppLocalizations l10n) => _statePage( localizedDramaTitle(l10n, widget.drama), const Center(child: CircularProgressIndicator()), diff --git a/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart b/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart index e2ae9ab..e2852e2 100644 --- a/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart +++ b/lib/pages/drama_detail/widgets/drama_detail_bottom_bar.dart @@ -16,12 +16,14 @@ class DramaDetailBottomBar extends StatelessWidget { required this.l10n, required this.continueIndex, required this.onPlay, + required this.enabled, this.resume, }); final AppLocalizations l10n; final int? continueIndex; final VoidCallback onPlay; + final bool enabled; final Widget? resume; @override @@ -44,10 +46,12 @@ class DramaDetailBottomBar extends StatelessWidget { height: _ctaHeight, child: FilledButton.icon( key: const Key('detail-cta'), - onPressed: onPlay, + onPressed: enabled ? onPlay : null, style: FilledButton.styleFrom( backgroundColor: AppColors.blue, foregroundColor: AppColors.onPrimary, + disabledBackgroundColor: AppColors.raised, + disabledForegroundColor: AppColors.muted, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(_ctaRadius), ), diff --git a/lib/pages/drama_detail/widgets/drama_episode_states.dart b/lib/pages/drama_detail/widgets/drama_episode_states.dart new file mode 100644 index 0000000..de7787d --- /dev/null +++ b/lib/pages/drama_detail/widgets/drama_episode_states.dart @@ -0,0 +1,181 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/formatting/error_text.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +const int _skeletonCount = 6; +const int _gridColumns = 6; +const double _gridSpacing = 8; +const double _radius = 10; +const double _horizontal = 14; +const double _sectionTop = 16; +const double _minOpacity = 0.45; +const double _maxOpacity = 0.8; +const Duration _breathDuration = Duration(milliseconds: 900); + +/// 详情接口返回前在选集区展示六个呼吸骨架格。 +class DramaEpisodesLoading extends StatefulWidget { + const DramaEpisodesLoading({ + super.key, + required this.l10n, + required this.episodeCount, + }); + + final AppLocalizations l10n; + final int episodeCount; + + @override + State createState() => _DramaEpisodesLoadingState(); +} + +class _DramaEpisodesLoadingState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _opacity; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: _breathDuration) + ..repeat(reverse: true); + _opacity = Tween( + begin: _minOpacity, + end: _maxOpacity, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + key: const Key('detail-episodes-section'), + padding: const EdgeInsets.fromLTRB( + _horizontal, + _sectionTop, + _horizontal, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _SectionTitle(title: widget.l10n.episodes), + const Spacer(), + if (widget.episodeCount > 0) + Text( + widget.l10n.episodeCountLabel(widget.episodeCount), + style: const TextStyle(color: AppColors.muted, fontSize: 11), + ), + ], + ), + const SizedBox(height: 8), + GridView.builder( + key: const Key('detail-episodes-loading'), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: _skeletonCount, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _gridColumns, + mainAxisSpacing: _gridSpacing, + crossAxisSpacing: _gridSpacing, + ), + itemBuilder: (context, index) => FadeTransition( + opacity: _opacity, + child: Container( + decoration: BoxDecoration( + color: AppColors.raised, + borderRadius: BorderRadius.circular(_radius), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// 详情接口失败时只替换选集区,保留上方卡片与简介。 +class DramaEpisodesLoadError extends StatelessWidget { + const DramaEpisodesLoadError({ + super.key, + required this.l10n, + required this.message, + required this.onRetry, + }); + + final AppLocalizations l10n; + final String? message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Padding( + key: const Key('detail-episodes-section'), + padding: const EdgeInsets.fromLTRB( + _horizontal, + _sectionTop, + _horizontal, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle(title: l10n.episodes), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + Text( + errorText(l10n, message, l10n.detailError), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted, fontSize: 13), + ), + const SizedBox(height: 10), + TextButton( + key: const Key('detail-episodes-retry'), + onPressed: onRetry, + child: Text(l10n.retry), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _SectionTitle extends StatelessWidget { + const _SectionTitle({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Text( + title, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ); + } +} diff --git a/test/drama_detail/drama_detail_first_frame_test.dart b/test/drama_detail/drama_detail_first_frame_test.dart new file mode 100644 index 0000000..8ab2f78 --- /dev/null +++ b/test/drama_detail/drama_detail_first_frame_test.dart @@ -0,0 +1,173 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +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/data/services/favorite_controller.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart'; + +import '../support/fake_catalog.dart'; + +void main() { + testWidgets('should_show_card_content_before_detail_returns', (tester) async { + final pending = Completer(); + final card = _cardDrama(); + final catalog = _ScriptedCatalog( + dramas: [card], + loadDetail: () => pending.future, + ); + + await _pumpDetail(tester, card: card, catalog: catalog, settle: false); + + expect(find.byKey(const Key('detail-title')), findsOneWidget); + expect(find.byKey(const Key('detail-poster')), findsOneWidget); + expect(find.byKey(const Key('detail-stats')), findsOneWidget); + expect(find.byKey(const Key('detail-episodes-loading')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect( + tester + .widget(find.byKey(const Key('detail-cta'))) + .onPressed, + isNull, + ); + + pending.complete(_detailDrama(card)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('detail-episodes-loading')), findsNothing); + expect(find.byKey(const Key('detail-episode-chips')), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('detail-cta'))) + .onPressed, + isNotNull, + ); + }); + + testWidgets('should_keep_card_content_when_detail_load_fails', ( + tester, + ) async { + final card = _cardDrama(); + final catalog = _ScriptedCatalog( + dramas: [card], + loadDetail: () async => throw const ApiException(500, 'detail failed'), + ); + + await _pumpDetail(tester, card: card, catalog: catalog); + + expect(find.byKey(const Key('detail-title')), findsOneWidget); + expect(find.byKey(const Key('detail-poster')), findsOneWidget); + expect(find.byKey(const Key('detail-stats')), findsOneWidget); + expect(find.byKey(const Key('detail-episodes-retry')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect( + tester + .widget(find.byKey(const Key('detail-cta'))) + .onPressed, + isNull, + ); + }); +} + +Future _pumpDetail( + WidgetTester tester, { + required Drama card, + required CatalogRepository catalog, + bool settle = true, +}) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final favorites = FavoriteController( + InMemoryFavoriteRepository(cards: [card]), + ); + await favorites.load(); + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value( + value: InMemoryWatchHistoryRepository(), + ), + Provider.value(value: FakePlaybackRepository()), + ChangeNotifierProvider.value(value: favorites), + ChangeNotifierProvider( + create: (_) => CatalogController(catalog)..load(), + ), + ], + child: MaterialApp( + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: DramaDetailPage(drama: card), + ), + ), + ); + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + } +} + +Drama _cardDrama() => const Drama( + id: 'detail-k53', + title: 'Detail K53', + synopsis: 'A card synopsis is available before the detail request finishes.', + posterUrl: '/files/detail-k53.png', + category: 'Romance', + genre: 'Urban', + language: 'id', + totalEpisodes: 8, + viewCount: 120000, + favoriteCount: 3200, + likeCount: 5600, +); + +Drama _detailDrama(Drama card) => Drama( + id: card.id, + title: card.title, + synopsis: card.synopsis, + posterUrl: card.posterUrl, + category: card.category, + genre: card.genre, + language: card.language, + totalEpisodes: card.totalEpisodes, + viewCount: card.viewCount, + favoriteCount: card.favoriteCount, + likeCount: card.likeCount, + detailLoaded: true, + episodes: [ + for (var number = 1; number <= card.totalEpisodes; number++) + Episode( + id: '${card.id}-$number', + number: number, + title: 'Episode $number', + isFree: number == 1, + videoUrl: number == 1 ? '/files/${card.id}-1.mp4' : '', + locked: number > 1, + ), + ], +); + +class _ScriptedCatalog extends FakeCatalogRepository { + _ScriptedCatalog({required List dramas, required this.loadDetail}) + : super(dramas: dramas); + + final Future Function() loadDetail; + + @override + Future fetchDrama(String dramaId) => loadDetail(); +}