Files
ReelShort/lib/pages/new_releases/new_releases_page.dart
T

561 lines
19 KiB
Dart

// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/formatting/count_format.dart';
import '../../core/theme/app_colors.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/paged_local_list.dart';
import '../../core/widgets/remote_image.dart';
import '../../core/widgets/subpage_header.dart';
import '../../l10n/app_localizations.dart';
import '../discover/widgets/drama_grid.dart';
import '../player/open_player.dart';
const int _moreLimit = 12;
const int _filmHoles = 8;
const double _cardPadding = 8;
const double _cardWidth = 124;
/// 横向卡片行高:封面 144 + 两行剧名 + 上映日期。
const double _rowHeight = 226;
const double _cardRadius = 12;
const double _loadMoreThreshold = 200;
/// 新剧页:暖色首映厅,今日首映大卡 + 横向更多新剧。
class NewReleasesPage extends StatefulWidget {
const NewReleasesPage({super.key});
@override
State<NewReleasesPage> createState() => _NewReleasesPageState();
}
class _NewReleasesPageState extends State<NewReleasesPage> {
final ScrollController _scroll = ScrollController();
PagedLocalListController<Drama>? _pager;
bool _refreshing = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_pager != null) return;
_pager = PagedLocalListController<Drama>(
items: _newest(context.read<CatalogController>().dramas),
)..addListener(_rebuild);
}
@override
void dispose() {
_pager
?..removeListener(_rebuild)
..dispose();
_scroll.dispose();
super.dispose();
}
void _rebuild() {
if (mounted) setState(() {});
}
List<Drama> _newest(List<Drama> source) {
return [...source]..sort((a, b) => b.id.compareTo(a.id));
}
Future<void> _refresh() async {
if (_refreshing) return;
_refreshing = true;
try {
final catalog = context.read<CatalogController>();
await catalog.refresh();
if (!mounted) return;
_pager!.replaceAll(_newest(catalog.dramas));
if (_scroll.hasClients) {
_scroll.jumpTo(_scroll.position.minScrollExtent);
}
} finally {
_refreshing = false;
}
}
bool _onScroll(ScrollNotification notification) {
if (notification.depth != 0 || _refreshing) return false;
final reachedBottom =
notification.metrics.extentAfter <= _loadMoreThreshold;
if (reachedBottom &&
(notification is ScrollUpdateNotification ||
notification is ScrollEndNotification ||
notification is OverscrollNotification)) {
unawaited(_pager!.loadMore());
}
return false;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final pager = _pager!;
final visible = pager.visibleItems;
final hero = visible.isEmpty ? null : visible.first;
final more = pager.items.skip(1).take(_moreLimit).toList();
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Scaffold(
key: const Key('new-releases-page'),
backgroundColor: AppColors.ink,
body: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.followingGlow,
AppColors.followingGlowMid,
AppColors.appBackground,
],
stops: [0, 0.14, 0.3],
),
),
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppSubpageHeader(
title: l10n.newReleasesTitle,
subtitle: l10n.newReleasesSubtitle,
backKey: const Key('new-releases-back'),
actions: _countChip(l10n, pager.items.length),
),
Expanded(
child: NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: AppPullToRefresh(
key: const Key('new-releases-refresh'),
onRefresh: _refresh,
child: CustomScrollView(
key: const Key('new-releases-list'),
controller: _scroll,
physics: AppPullToRefresh.physics,
slivers: hero == null
? [
SliverToBoxAdapter(
child: SizedBox(
height: 420,
child: Center(
child: Text(
l10n.noStories,
key: const Key('new-releases-empty'),
style: const TextStyle(
color: AppColors.muted,
),
),
),
),
),
]
: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: _heroCard(context, l10n, hero),
),
),
const SliverToBoxAdapter(
child: SizedBox(height: 14),
),
const SliverToBoxAdapter(child: _FilmStrip()),
const SliverToBoxAdapter(
child: SizedBox(height: 14),
),
if (more.isNotEmpty) ...[
SliverToBoxAdapter(child: _moreHeader(l10n)),
const SliverToBoxAdapter(
child: SizedBox(height: 10),
),
SliverToBoxAdapter(
child: SizedBox(
height: _rowHeight,
child: ListView.separated(
key: const Key('new-releases-row'),
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
itemCount: more.length,
separatorBuilder: (_, _) =>
const SizedBox(width: 10),
itemBuilder: (context, index) =>
_moreCard(
context,
l10n,
more[index],
index,
),
),
),
),
],
const SliverToBoxAdapter(
child: SizedBox(height: 20),
),
SliverToBoxAdapter(child: _allHeader(l10n)),
const SliverToBoxAdapter(
child: SizedBox(height: 10),
),
SliverDramaGrid(
key: const Key('new-releases-all-grid'),
l10n: l10n,
dramas: visible,
onOpen: (drama) => unawaited(
playDramaFromCatalog(context, drama),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(bottom: 24),
child: PagedLoadFooter(
loading: pager.loading,
hasMore: pager.hasMore,
hasItems: visible.isNotEmpty,
loadMoreKey: const Key(
'new-releases-load-more',
),
),
),
),
],
),
),
),
),
],
),
),
),
),
);
}
Widget _countChip(AppLocalizations l10n, int count) {
return Container(
key: const Key('new-releases-count'),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppColors.badgeBeige,
borderRadius: BorderRadius.circular(999),
),
child: Text(
l10n.newReleasesCount(count),
style: const TextStyle(
color: AppColors.blue,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
);
}
Widget _heroCard(BuildContext context, AppLocalizations l10n, Drama drama) {
return GestureDetector(
key: const Key('new-releases-hero'),
onTap: () => unawaited(playDramaFromCatalog(context, drama)),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
fallbackAspectRatio: 16 / 9,
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.transparent,
AppColors.playerOverlayPill,
],
),
),
),
Positioned(
top: 10,
left: 10,
child: _newBadge(
l10n,
key: const Key('new-releases-hero-badge'),
),
),
Positioned(
left: 12,
right: 64,
bottom: 10,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.premiereToday,
style: const TextStyle(
color: AppColors.muted,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
drama.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Row(
children: [
const Icon(
Icons.local_fire_department_rounded,
color: AppColors.blue,
size: 12,
),
const SizedBox(width: 4),
Flexible(
child: Text(
l10n.heatLabel(
formatInteractionCount(drama.viewCount),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 11,
),
),
),
],
),
],
),
),
Positioned(
right: 12,
bottom: 12,
child: Container(
key: const Key('new-releases-hero-play'),
width: 40,
height: 40,
decoration: const BoxDecoration(
color: AppColors.blue,
shape: BoxShape.circle,
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 22,
),
),
),
],
),
),
),
);
}
Widget _moreHeader(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: Text(
l10n.rankingNew,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
),
Text(
l10n.seeAll,
style: const TextStyle(color: AppColors.subtle, fontSize: 12),
),
],
),
);
}
Widget _allHeader(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
l10n.newReleasesAll,
key: const Key('new-releases-all-header'),
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
);
}
Widget _moreCard(
BuildContext context,
AppLocalizations l10n,
Drama drama,
int index,
) {
return GestureDetector(
key: Key('new-release-card-${drama.id}'),
onTap: () => unawaited(playDramaFromCatalog(context, drama)),
child: Container(
width: _cardWidth,
padding: const EdgeInsets.all(_cardPadding),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(_cardRadius),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 108,
height: 144,
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
fallbackAspectRatio: 3 / 4,
),
Positioned(
top: 8,
left: 8,
child: _newBadge(
l10n,
key: Key('new-release-badge-$index'),
compact: true,
),
),
],
),
),
),
const SizedBox(height: 4),
Text(
drama.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
_releaseDate(index),
style: const TextStyle(color: AppColors.subtle, fontSize: 12),
),
],
),
),
);
}
/// 目录暂无上架时间字段,用倒序位次生成近几日的展示日期。
String _releaseDate(int index) {
final date = DateTime.now().subtract(Duration(days: index + 1));
final month = date.month.toString().padLeft(2, '0');
final day = date.day.toString().padLeft(2, '0');
return '$month/$day';
}
Widget _newBadge(
AppLocalizations l10n, {
required Key key,
bool compact = false,
}) {
return Container(
key: key,
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : 8,
vertical: compact ? 2 : 3,
),
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(999),
),
child: Text(
l10n.newBadge,
style: TextStyle(
color: AppColors.onPrimary,
fontSize: compact ? 9 : 10,
fontWeight: FontWeight.w800,
),
),
);
}
}
class _FilmStrip extends StatelessWidget {
const _FilmStrip();
@override
Widget build(BuildContext context) {
return Container(
key: const Key('new-releases-film'),
height: 22,
decoration: const BoxDecoration(
color: AppColors.raised,
border: Border(
top: BorderSide(color: AppColors.border),
bottom: BorderSide(color: AppColors.border),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [_holes(), _holes()],
),
);
}
Widget _holes() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (var index = 0; index < _filmHoles; index++)
Container(
width: 6,
height: 8,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(2),
),
),
],
);
}
}