561 lines
19 KiB
Dart
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),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|