merge: 合并 K53 详情页首帧修复
This commit is contained in:
commit
23059578d2
4 files changed
+390
-13
No files matched your search
@@ -28,6 +28,7 @@ import 'drama_detail_logic.dart';
|
|||||||
import 'widgets/drama_detail_bottom_bar.dart';
|
import 'widgets/drama_detail_bottom_bar.dart';
|
||||||
import 'widgets/drama_detail_header.dart';
|
import 'widgets/drama_detail_header.dart';
|
||||||
import 'widgets/drama_detail_sections.dart';
|
import 'widgets/drama_detail_sections.dart';
|
||||||
|
import 'widgets/drama_episode_states.dart';
|
||||||
|
|
||||||
const String _menuFavorite = 'favorite';
|
const String _menuFavorite = 'favorite';
|
||||||
const String _menuDownload = 'download';
|
const String _menuDownload = 'download';
|
||||||
@@ -93,17 +94,23 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
|
|
||||||
Widget _page(BuildContext context) {
|
Widget _page(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
if (logic.loading) return _loading(l10n);
|
|
||||||
if (logic.failed) return _failed(l10n);
|
|
||||||
final drama = logic.drama;
|
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<FavoriteController>().isSaved(drama.id);
|
final saved = context.watch<FavoriteController>().isSaved(drama.id);
|
||||||
final continueIndex = logic.continueIndex;
|
final continueIndex = logic.continueIndex;
|
||||||
|
final episodesReady = drama.detailLoaded && drama.episodes.isNotEmpty;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.ink,
|
backgroundColor: AppColors.ink,
|
||||||
bottomNavigationBar: DramaDetailBottomBar(
|
bottomNavigationBar: DramaDetailBottomBar(
|
||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
continueIndex: continueIndex,
|
continueIndex: episodesReady ? continueIndex : null,
|
||||||
|
enabled: episodesReady,
|
||||||
onPlay: () => logic.openPlayer(context, continueIndex ?? 0),
|
onPlay: () => logic.openPlayer(context, continueIndex ?? 0),
|
||||||
resume: _resume(l10n),
|
resume: _resume(l10n),
|
||||||
),
|
),
|
||||||
@@ -134,20 +141,32 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
|||||||
),
|
),
|
||||||
if (_tagValues(drama).isNotEmpty)
|
if (_tagValues(drama).isNotEmpty)
|
||||||
SliverToBoxAdapter(child: DramaTags(tags: _tagValues(drama))),
|
SliverToBoxAdapter(child: DramaTags(tags: _tagValues(drama))),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(child: _episodes(l10n, drama, continueIndex)),
|
||||||
child: DramaEpisodeGrid(
|
|
||||||
l10n: l10n,
|
|
||||||
drama: drama,
|
|
||||||
continueIndex: continueIndex,
|
|
||||||
onOpen: (index) => logic.openPlayer(context, index),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
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(
|
Widget _loading(AppLocalizations l10n) => _statePage(
|
||||||
localizedDramaTitle(l10n, widget.drama),
|
localizedDramaTitle(l10n, widget.drama),
|
||||||
const Center(child: CircularProgressIndicator()),
|
const Center(child: CircularProgressIndicator()),
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ class DramaDetailBottomBar extends StatelessWidget {
|
|||||||
required this.l10n,
|
required this.l10n,
|
||||||
required this.continueIndex,
|
required this.continueIndex,
|
||||||
required this.onPlay,
|
required this.onPlay,
|
||||||
|
required this.enabled,
|
||||||
this.resume,
|
this.resume,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppLocalizations l10n;
|
final AppLocalizations l10n;
|
||||||
final int? continueIndex;
|
final int? continueIndex;
|
||||||
final VoidCallback onPlay;
|
final VoidCallback onPlay;
|
||||||
|
final bool enabled;
|
||||||
final Widget? resume;
|
final Widget? resume;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -44,10 +46,12 @@ class DramaDetailBottomBar extends StatelessWidget {
|
|||||||
height: _ctaHeight,
|
height: _ctaHeight,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
key: const Key('detail-cta'),
|
key: const Key('detail-cta'),
|
||||||
onPressed: onPlay,
|
onPressed: enabled ? onPlay : null,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: AppColors.blue,
|
backgroundColor: AppColors.blue,
|
||||||
foregroundColor: AppColors.onPrimary,
|
foregroundColor: AppColors.onPrimary,
|
||||||
|
disabledBackgroundColor: AppColors.raised,
|
||||||
|
disabledForegroundColor: AppColors.muted,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(_ctaRadius),
|
borderRadius: BorderRadius.circular(_ctaRadius),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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<DramaEpisodesLoading> createState() => _DramaEpisodesLoadingState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DramaEpisodesLoadingState extends State<DramaEpisodesLoading>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
late final Animation<double> _opacity;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(vsync: this, duration: _breathDuration)
|
||||||
|
..repeat(reverse: true);
|
||||||
|
_opacity = Tween<double>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Drama>();
|
||||||
|
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<FilledButton>(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<FilledButton>(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<FilledButton>(find.byKey(const Key('detail-cta')))
|
||||||
|
.onPressed,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<WatchHistoryRepository>.value(
|
||||||
|
value: InMemoryWatchHistoryRepository(),
|
||||||
|
),
|
||||||
|
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
|
||||||
|
ChangeNotifierProvider<FavoriteController>.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<Drama> dramas, required this.loadDetail})
|
||||||
|
: super(dramas: dramas);
|
||||||
|
|
||||||
|
final Future<Drama> Function() loadDetail;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Drama> fetchDrama(String dramaId) => loadDetail();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user