feat(app): 接入首页轮播 banner
This commit is contained in:
1 parent
061aac6577
commit
7b927c794e
7 files changed
+469
-37
No files matched your search
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/home_feed.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/category_controller.dart';
|
||||
@@ -18,6 +19,7 @@ import '../../core/widgets/remote_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'discover_binding.dart';
|
||||
import 'discover_logic.dart';
|
||||
import 'widgets/feed_banner_carousel.dart';
|
||||
|
||||
part 'widgets/home_drama_grid.dart';
|
||||
|
||||
@@ -38,6 +40,8 @@ class DiscoverPage extends StatefulWidget {
|
||||
this.onAction,
|
||||
this.dramas,
|
||||
this.onDramaSelected,
|
||||
this.banners = const [],
|
||||
this.onBannerSelected,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
@@ -54,6 +58,8 @@ class DiscoverPage extends StatefulWidget {
|
||||
final ValueChanged<DiscoverPageAction>? onAction;
|
||||
final List<Drama>? dramas;
|
||||
final ValueChanged<Drama>? onDramaSelected;
|
||||
final List<FeedBanner> banners;
|
||||
final ValueChanged<FeedBanner>? onBannerSelected;
|
||||
|
||||
@override
|
||||
State<DiscoverPage> createState() => _DiscoverPageState();
|
||||
@@ -147,6 +153,17 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
controller: logic.scroll,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
if (widget.banners.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: FeedBannerCarousel(
|
||||
banners: widget.banners,
|
||||
active: widget.active,
|
||||
onOpen: (banner) => widget.onBannerSelected?.call(banner),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: _search()),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/home_feed.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const double _carouselHeight = 154;
|
||||
const double _indicatorHeight = 24;
|
||||
const double _dotSize = 6;
|
||||
const double _activeDotWidth = 18;
|
||||
const double _viewportFraction = 0.94;
|
||||
const Duration _autoAdvanceInterval = Duration(milliseconds: 4500);
|
||||
const Duration _pageTransition = Duration(milliseconds: 520);
|
||||
|
||||
/// Auto-advancing, touch-pausable carousel for real `/app/home/feed` banners.
|
||||
class FeedBannerCarousel extends StatefulWidget {
|
||||
const FeedBannerCarousel({
|
||||
super.key,
|
||||
required this.banners,
|
||||
required this.onOpen,
|
||||
this.active = true,
|
||||
});
|
||||
|
||||
final List<FeedBanner> banners;
|
||||
final ValueChanged<FeedBanner> onOpen;
|
||||
final bool active;
|
||||
|
||||
@override
|
||||
State<FeedBannerCarousel> createState() => _FeedBannerCarouselState();
|
||||
}
|
||||
|
||||
class _FeedBannerCarouselState extends State<FeedBannerCarousel> {
|
||||
final PageController _controller = PageController(
|
||||
viewportFraction: _viewportFraction,
|
||||
);
|
||||
Timer? _timer;
|
||||
int _index = 0;
|
||||
bool _pressed = false;
|
||||
bool _tickerEnabled = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _syncTimer());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final enabled = TickerMode.valuesOf(context).enabled;
|
||||
if (enabled != _tickerEnabled) {
|
||||
_tickerEnabled = enabled;
|
||||
_syncTimer();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FeedBannerCarousel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.banners.length != widget.banners.length ||
|
||||
oldWidget.active != widget.active) {
|
||||
if (_index >= widget.banners.length) _index = 0;
|
||||
_syncTimer();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
final canAdvance =
|
||||
mounted &&
|
||||
widget.active &&
|
||||
_tickerEnabled &&
|
||||
!_pressed &&
|
||||
widget.banners.length > 1;
|
||||
if (!canAdvance) return;
|
||||
_timer = Timer.periodic(_autoAdvanceInterval, (_) => _advance());
|
||||
}
|
||||
|
||||
void _advance() {
|
||||
if (!_controller.hasClients || widget.banners.length < 2) return;
|
||||
final next = (_index + 1) % widget.banners.length;
|
||||
_controller.animateToPage(
|
||||
next,
|
||||
duration: _pageTransition,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
void _pause() {
|
||||
_pressed = true;
|
||||
_timer?.cancel();
|
||||
}
|
||||
|
||||
void _resume() {
|
||||
_pressed = false;
|
||||
_syncTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.banners.isEmpty) return const SizedBox.shrink();
|
||||
return Listener(
|
||||
key: const Key('feed-banner-carousel'),
|
||||
onPointerDown: (_) => _pause(),
|
||||
onPointerUp: (_) => _resume(),
|
||||
onPointerCancel: (_) => _resume(),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: _carouselHeight,
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: widget.banners.length,
|
||||
onPageChanged: (value) => setState(() => _index = value),
|
||||
itemBuilder: (context, index) => _bannerCard(index),
|
||||
),
|
||||
),
|
||||
SizedBox(height: _indicatorHeight, child: _indicator(context)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bannerCard(int index) {
|
||||
final banner = widget.banners[index];
|
||||
final dramaId = banner.dramaId;
|
||||
final card = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDimens.authCardRadius),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(banner.imageUrl, showFallbackIcon: true),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0x8A0B0C10)],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (dramaId != null)
|
||||
const Positioned(right: 14, bottom: 14, child: _PlayMark()),
|
||||
],
|
||||
),
|
||||
);
|
||||
final page = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5),
|
||||
child: dramaId == null
|
||||
? card
|
||||
: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppDimens.authCardRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
key: Key('feed-banner-$index'),
|
||||
onTap: () => widget.onOpen(banner),
|
||||
child: card,
|
||||
),
|
||||
),
|
||||
);
|
||||
return Semantics(container: true, button: dramaId != null, child: page);
|
||||
}
|
||||
|
||||
Widget _indicator(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return ExcludeSemantics(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var index = 0; index < widget.banners.length; index++)
|
||||
Semantics(
|
||||
label: l10n.carouselSemantics(index + 1, widget.banners.length),
|
||||
selected: index == _index,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
key: Key('feed-banner-dot-$index'),
|
||||
width: index == _index ? _activeDotWidth : _dotSize,
|
||||
height: _dotSize,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: index == _index
|
||||
? AppColors.onPrimary
|
||||
: AppColors.muted,
|
||||
borderRadius: BorderRadius.circular(_dotSize),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayMark extends StatelessWidget {
|
||||
const _PlayMark();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.blue.withValues(alpha: 0.35),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 22,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/download_controller.dart';
|
||||
import '../../core/data/services/wallet_controller.dart';
|
||||
import '../../core/data/repositories/catalog_repository.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../discover/discover_logic.dart';
|
||||
import '../drama_detail/drama_detail_view.dart';
|
||||
import '../mine/mine_logic.dart';
|
||||
@@ -85,6 +87,20 @@ class HomeLogic extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves a banner link that is not in the loaded feed, then opens detail.
|
||||
Future<void> openDramaById(BuildContext context, String dramaId) async {
|
||||
try {
|
||||
final drama = await context.read<CatalogRepository>().fetchDrama(dramaId);
|
||||
if (context.mounted) openDrama(context, drama);
|
||||
} catch (_) {
|
||||
if (!context.mounted) return;
|
||||
final message = AppLocalizations.of(context)!.detailLoadFailed;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playDrama(BuildContext context, Drama drama) async {
|
||||
// 列表卡片只有一集预览,进入播放前补齐完整剧集,否则选集列表是空的。
|
||||
var playable = drama;
|
||||
|
||||
@@ -76,6 +76,7 @@ class HomePage extends StatelessWidget {
|
||||
key: ValueKey(catalog.dramas.first.id),
|
||||
dramas: catalog.dramas,
|
||||
sections: catalog.sections,
|
||||
banners: catalog.banners,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -100,10 +101,16 @@ class HomeFailureView extends StatelessWidget {
|
||||
|
||||
/// The three primary destinations share a single route and keep their state.
|
||||
class _HomeTabs extends StatefulWidget {
|
||||
const _HomeTabs({super.key, required this.dramas, required this.sections});
|
||||
const _HomeTabs({
|
||||
super.key,
|
||||
required this.dramas,
|
||||
required this.sections,
|
||||
required this.banners,
|
||||
});
|
||||
|
||||
final List<Drama> dramas;
|
||||
final List<FeedSection> sections;
|
||||
final List<FeedBanner> banners;
|
||||
|
||||
@override
|
||||
State<_HomeTabs> createState() => _HomeTabsState();
|
||||
@@ -182,6 +189,13 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
onSignIn: () => openAuthPage(context),
|
||||
onCoins: logic.openProfile,
|
||||
onRewards: logic.openProfile,
|
||||
banners: widget.banners,
|
||||
onBannerSelected: (banner) {
|
||||
final dramaId = banner.dramaId;
|
||||
if (dramaId != null) {
|
||||
unawaited(logic.openDramaById(context, dramaId));
|
||||
}
|
||||
},
|
||||
onAction: (action) => logic.discoverAction(context, action),
|
||||
),
|
||||
MyPage(
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.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/pages/drama_detail/drama_detail_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';
|
||||
import '../support/fake_haptics.dart';
|
||||
import '../support/fake_interaction_repositories.dart';
|
||||
import '../support/widget_player.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.withData({});
|
||||
});
|
||||
|
||||
testWidgets('should_open_linked_drama_when_discover_banner_is_tapped', (
|
||||
tester,
|
||||
) async {
|
||||
final catalog = FakeCatalogRepository(
|
||||
banners: const [
|
||||
FeedBanner(
|
||||
id: 'banner-1',
|
||||
imageUrl: '/files/banner.jpg',
|
||||
linkType: 'DRAMA',
|
||||
linkValue: 'the-signal-demo',
|
||||
),
|
||||
],
|
||||
);
|
||||
await _pumpApp(tester, catalog);
|
||||
await tester.tap(find.byKey(const Key('tab-discover')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('feed-banner-carousel')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('feed-banner-0')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(DramaDetailPage), findsOneWidget);
|
||||
expect(find.text('The Signal'), findsWidgets);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpApp(
|
||||
WidgetTester tester,
|
||||
FakeCatalogRepository catalog,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
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(),
|
||||
haptics: FakeHaptics(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/discover/widgets/feed_banner_carousel.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_render_nothing_when_banner_list_is_empty', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, const []);
|
||||
|
||||
expect(find.byKey(const Key('feed-banner-carousel')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_open_linked_drama_when_banner_is_tapped', (tester) async {
|
||||
FeedBanner? opened;
|
||||
await _pump(tester, const [
|
||||
FeedBanner(
|
||||
id: 'banner-1',
|
||||
imageUrl: '/files/banner.jpg',
|
||||
linkType: 'DRAMA',
|
||||
linkValue: 'drama-1',
|
||||
),
|
||||
], onOpen: (banner) => opened = banner);
|
||||
|
||||
expect(find.byKey(const Key('feed-banner-carousel')), findsOneWidget);
|
||||
expect(find.byKey(const Key('feed-banner-0')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('feed-banner-0')));
|
||||
await tester.pump();
|
||||
|
||||
expect(opened?.dramaId, 'drama-1');
|
||||
});
|
||||
|
||||
testWidgets('should_advance_to_next_banner_when_interval_elapses', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester, _banners);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 4600));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_page(tester), 1);
|
||||
});
|
||||
|
||||
testWidgets('should_pause_auto_advance_when_pointer_is_held', (tester) async {
|
||||
await _pump(tester, _banners);
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('feed-banner-carousel'))),
|
||||
);
|
||||
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
expect(_page(tester), 0);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_page(tester), 1);
|
||||
});
|
||||
}
|
||||
|
||||
const _banners = [
|
||||
FeedBanner(id: 'banner-1', imageUrl: '/files/one.jpg'),
|
||||
FeedBanner(id: 'banner-2', imageUrl: '/files/two.jpg'),
|
||||
];
|
||||
|
||||
double _page(WidgetTester tester) =>
|
||||
tester.widget<PageView>(find.byType(PageView)).controller!.page!;
|
||||
|
||||
Future<void> _pump(
|
||||
WidgetTester tester,
|
||||
List<FeedBanner> banners, {
|
||||
ValueChanged<FeedBanner>? onOpen,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: FeedBannerCarousel(banners: banners, onOpen: onOpen ?? (_) {}),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
@@ -4,9 +4,11 @@ import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
|
||||
/// 测试用目录数据:三部分区里的短剧,标题与后端演示数据保持一致。
|
||||
class FakeCatalogRepository implements CatalogRepository {
|
||||
FakeCatalogRepository({List<Drama>? dramas}) : dramas = dramas ?? demoDramas;
|
||||
FakeCatalogRepository({List<Drama>? dramas, this.banners = const []})
|
||||
: dramas = dramas ?? demoDramas;
|
||||
|
||||
final List<Drama> dramas;
|
||||
final List<FeedBanner> banners;
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchHomeFeed() async => _feed();
|
||||
@@ -20,51 +22,38 @@ class FakeCatalogRepository implements CatalogRepository {
|
||||
}
|
||||
|
||||
HomeFeed _feed() => HomeFeed(
|
||||
banners: banners,
|
||||
sections: [FeedSection(code: 'hot', title: '热门推荐', dramas: dramas)],
|
||||
);
|
||||
}
|
||||
|
||||
final List<Drama> demoDramas = [
|
||||
_drama(
|
||||
'the-signal-demo',
|
||||
'The Signal',
|
||||
'Suspense · Mystery',
|
||||
format: 1,
|
||||
),
|
||||
_drama('the-signal-demo', 'The Signal', 'Suspense · Mystery', format: 1),
|
||||
_drama(
|
||||
'city-lights-demo',
|
||||
'City Lights & Velvet Nights',
|
||||
'Urban Romance',
|
||||
format: 8,
|
||||
),
|
||||
_drama(
|
||||
'vengeance-demo',
|
||||
'Vengeance Rising',
|
||||
'Action · Thriller',
|
||||
format: 15,
|
||||
),
|
||||
_drama('vengeance-demo', 'Vengeance Rising', 'Action · Thriller', format: 15),
|
||||
];
|
||||
|
||||
Drama _drama(
|
||||
String id,
|
||||
String title,
|
||||
String genre, {
|
||||
required int format,
|
||||
}) => Drama(
|
||||
id: id,
|
||||
title: title,
|
||||
synopsis: '$title synopsis',
|
||||
posterUrl: '/files/$id.png',
|
||||
genre: genre,
|
||||
detailLoaded: true,
|
||||
episodes: [
|
||||
for (var index = 0; index < 7; index++)
|
||||
Episode(
|
||||
id: '$id-${index + 1}',
|
||||
number: index + 1,
|
||||
title: 'Episode ${index + 1}',
|
||||
isFree: true,
|
||||
videoUrl: 'https://example.test/$format-$index.mp4',
|
||||
),
|
||||
],
|
||||
);
|
||||
Drama _drama(String id, String title, String genre, {required int format}) =>
|
||||
Drama(
|
||||
id: id,
|
||||
title: title,
|
||||
synopsis: '$title synopsis',
|
||||
posterUrl: '/files/$id.png',
|
||||
genre: genre,
|
||||
detailLoaded: true,
|
||||
episodes: [
|
||||
for (var index = 0; index < 7; index++)
|
||||
Episode(
|
||||
id: '$id-${index + 1}',
|
||||
number: index + 1,
|
||||
title: 'Episode ${index + 1}',
|
||||
isFree: true,
|
||||
videoUrl: 'https://example.test/$format-$index.mp4',
|
||||
),
|
||||
],
|
||||
);
|
||||
Reference in new issue
Block a user