346 lines
10 KiB
Dart
346 lines
10 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.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/catalog_controller.dart';
|
|
import '../../core/theme/app_colors.dart';
|
|
import '../../core/theme/app_dimens.dart';
|
|
import '../../core/widgets/empty_state.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
import '../discover/widgets/feed_banner_carousel.dart';
|
|
import '../mine/widgets/continue_watching_card.dart';
|
|
import 'home_browse_logic.dart';
|
|
import 'widgets/home_browse_header.dart';
|
|
import 'widgets/home_drama_grid.dart';
|
|
|
|
/// 浏览型首页:频道、Hero、继续观看与剧目目录。
|
|
class HomeBrowseView extends StatefulWidget {
|
|
const HomeBrowseView({
|
|
super.key,
|
|
required this.active,
|
|
required this.onSearch,
|
|
required this.onProfile,
|
|
required this.onTheater,
|
|
required this.onHistory,
|
|
required this.onOpenDrama,
|
|
required this.onOpenContinue,
|
|
});
|
|
|
|
final bool active;
|
|
final VoidCallback onSearch;
|
|
final VoidCallback onProfile;
|
|
final VoidCallback onTheater;
|
|
final VoidCallback onHistory;
|
|
final Future<void> Function(Drama drama) onOpenDrama;
|
|
final Future<void> Function(Drama drama, WatchProgress record) onOpenContinue;
|
|
|
|
@override
|
|
State<HomeBrowseView> createState() => _HomeBrowseViewState();
|
|
}
|
|
|
|
class _HomeBrowseViewState extends State<HomeBrowseView> {
|
|
late final HomeBrowseLogic logic;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
logic = HomeBrowseLogic(history: context.read<WatchHistoryRepository>());
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant HomeBrowseView oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
logic.syncPage(active: widget.active, wasActive: oldWidget.active);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
logic.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: logic,
|
|
builder: (context, _) => _page(context),
|
|
);
|
|
}
|
|
|
|
Widget _page(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final catalog = context.watch<CatalogController>();
|
|
return SafeArea(
|
|
bottom: false,
|
|
child: Column(
|
|
children: [
|
|
HomeBrowseHeader(
|
|
onSearch: widget.onSearch,
|
|
onProfile: widget.onProfile,
|
|
),
|
|
Expanded(child: _body(l10n, catalog)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _body(AppLocalizations l10n, CatalogController catalog) {
|
|
return RefreshIndicator(
|
|
key: const Key('home-refresh'),
|
|
color: AppColors.blue,
|
|
backgroundColor: AppColors.card,
|
|
onRefresh: () async {
|
|
await Future.wait([catalog.load(), logic.refreshRecent()]);
|
|
},
|
|
child: ListView(
|
|
key: const Key('home-browse-page'),
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
|
|
children: _sections(l10n, catalog),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _sections(AppLocalizations l10n, CatalogController catalog) {
|
|
final source = catalog.dramas;
|
|
final dramas = _channelDramas(source, logic.channel);
|
|
final continueItem = _continueItem(logic.recent, catalog.dramas);
|
|
final banners = catalog.banners.isEmpty
|
|
? _fallbackBanners(source)
|
|
: catalog.banners;
|
|
return [
|
|
_channels(l10n),
|
|
if (banners.isNotEmpty) _hero(banners, source),
|
|
if (continueItem != null) ...[
|
|
_continueHeader(l10n),
|
|
ContinueWatchingCard(
|
|
cardKey: const Key('home-continue-watching'),
|
|
playButtonKey: Key('home-continue-play-${continueItem.drama.id}'),
|
|
progressKey: Key('home-continue-progress-${continueItem.drama.id}'),
|
|
showRemaining: true,
|
|
drama: continueItem.drama,
|
|
record: continueItem.record,
|
|
onResume: () => unawaited(
|
|
widget.onOpenContinue(continueItem.drama, continueItem.record),
|
|
),
|
|
),
|
|
],
|
|
_channelHeader(l10n),
|
|
if (dramas.isEmpty)
|
|
_empty()
|
|
else
|
|
HomeDramaGrid(
|
|
l10n: l10n,
|
|
dramas: dramas,
|
|
onOpen: (drama) => unawaited(widget.onOpenDrama(drama)),
|
|
),
|
|
];
|
|
}
|
|
|
|
List<FeedBanner> _fallbackBanners(List<Drama> dramas) {
|
|
if (dramas.isEmpty) return const [];
|
|
final drama = dramas.first;
|
|
return [
|
|
FeedBanner(
|
|
id: 'home-fallback-${drama.id}',
|
|
imageUrl: drama.posterUrl,
|
|
linkType: 'DRAMA',
|
|
linkValue: drama.id,
|
|
),
|
|
];
|
|
}
|
|
|
|
Widget _continueHeader(AppLocalizations l10n) => _sectionHeader(
|
|
l10n.continueWatching,
|
|
actionKey: const Key('home-continue-all'),
|
|
actionLabel: l10n.seeAll,
|
|
onAction: widget.onHistory,
|
|
);
|
|
|
|
Widget _channelHeader(AppLocalizations l10n) => _sectionHeader(
|
|
_channelTitle(l10n),
|
|
actionKey: const Key('home-popular-more'),
|
|
actionLabel: l10n.seeAll,
|
|
onAction: widget.onTheater,
|
|
);
|
|
|
|
Widget _empty() => Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceXl),
|
|
child: EmptyState(
|
|
key: const Key('home-browse-empty'),
|
|
icon: Icons.movie_filter_outlined,
|
|
title: AppLocalizations.of(context)!.noStories,
|
|
subtitle: AppLocalizations.of(context)!.emptyHint,
|
|
compact: true,
|
|
),
|
|
);
|
|
|
|
Widget _channels(AppLocalizations l10n) {
|
|
final entries = [
|
|
(HomeChannel.hot, l10n.hot),
|
|
(HomeChannel.free, l10n.free),
|
|
(HomeChannel.newReleases, l10n.newDramas),
|
|
(HomeChannel.ranking, l10n.rankingTab),
|
|
];
|
|
return SizedBox(
|
|
height: AppDimens.buttonMedium,
|
|
child: ListView.separated(
|
|
key: const Key('home-channels'),
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppDimens.pageHorizontal,
|
|
),
|
|
itemCount: entries.length,
|
|
separatorBuilder: (_, _) => const SizedBox(width: AppDimens.spaceSm),
|
|
itemBuilder: (context, index) {
|
|
final entry = entries[index];
|
|
final selected = logic.channel == entry.$1;
|
|
return ChoiceChip(
|
|
key: Key('home-channel-${entry.$1.name}'),
|
|
label: Text(entry.$2),
|
|
selected: selected,
|
|
showCheckmark: false,
|
|
onSelected: (_) => logic.selectChannel(entry.$1),
|
|
backgroundColor: AppColors.card,
|
|
selectedColor: AppColors.blue,
|
|
side: BorderSide(
|
|
color: selected ? AppColors.blue : AppColors.border,
|
|
),
|
|
labelStyle: TextStyle(
|
|
color: selected ? AppColors.onPrimary : AppColors.onSurface,
|
|
fontSize: AppDimens.chipFont,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd),
|
|
shape: const StadiumBorder(),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _hero(List<FeedBanner> banners, List<Drama> dramas) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppDimens.pageHorizontal,
|
|
AppDimens.spaceMd,
|
|
AppDimens.pageHorizontal,
|
|
0,
|
|
),
|
|
child: FeedBannerCarousel(
|
|
key: const Key('home-hero-banner'),
|
|
banners: banners,
|
|
active: widget.active,
|
|
dramaFor: (banner) => _dramaForBanner(banner, dramas),
|
|
onOpen: (banner) {
|
|
final drama = _dramaForBanner(banner, dramas);
|
|
if (drama != null) unawaited(widget.onOpenDrama(drama));
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _sectionHeader(
|
|
String title, {
|
|
required Key actionKey,
|
|
required String actionLabel,
|
|
required VoidCallback onAction,
|
|
}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppDimens.pageHorizontal,
|
|
AppDimens.spaceLg,
|
|
AppDimens.spaceSm,
|
|
AppDimens.spaceSm,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontFamily: 'Outfit',
|
|
color: AppColors.onSurface,
|
|
fontSize: AppDimens.sectionTitle,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
),
|
|
TextButton(
|
|
key: actionKey,
|
|
onPressed: onAction,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppColors.accent,
|
|
minimumSize: const Size(AppDimens.tapTarget, AppDimens.tapTarget),
|
|
),
|
|
child: Text(actionLabel),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _channelTitle(AppLocalizations l10n) {
|
|
return switch (logic.channel) {
|
|
HomeChannel.hot => l10n.trendingMicroDramas,
|
|
HomeChannel.free => l10n.free,
|
|
HomeChannel.newReleases => l10n.newDramas,
|
|
HomeChannel.ranking => l10n.rankingTitle,
|
|
};
|
|
}
|
|
|
|
List<Drama> _channelDramas(List<Drama> source, HomeChannel channel) {
|
|
final dramas = List<Drama>.of(source);
|
|
switch (channel) {
|
|
case HomeChannel.hot:
|
|
dramas.sort((a, b) {
|
|
final hot = (b.isHot ? 1 : 0).compareTo(a.isHot ? 1 : 0);
|
|
return hot != 0 ? hot : b.viewCount.compareTo(a.viewCount);
|
|
});
|
|
case HomeChannel.free:
|
|
return dramas.where(_isFree).toList();
|
|
case HomeChannel.newReleases:
|
|
return dramas;
|
|
case HomeChannel.ranking:
|
|
dramas.sort((a, b) => b.viewCount.compareTo(a.viewCount));
|
|
}
|
|
return dramas;
|
|
}
|
|
|
|
bool _isFree(Drama drama) {
|
|
return drama.freeEpisodes > 0 ||
|
|
drama.episodes.any((episode) => episode.isFree);
|
|
}
|
|
|
|
Drama? _dramaForBanner(FeedBanner banner, List<Drama> dramas) {
|
|
final id = banner.dramaId;
|
|
if (id == null) return null;
|
|
for (final drama in dramas) {
|
|
if (drama.id == id) return drama;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
({Drama drama, WatchProgress record})? _continueItem(
|
|
List<WatchProgress> records,
|
|
List<Drama> dramas,
|
|
) {
|
|
for (final record in records) {
|
|
if (record.finished || !record.hasPosition) continue;
|
|
for (final drama in dramas) {
|
|
if (drama.id == record.dramaId) {
|
|
return (drama: drama, record: record);
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|