feat(flutter): 新剧首映厅与档期预约页
This commit is contained in:
1 parent
fdee89b345
commit
4f81bcf2b1
10 files changed
+998
-61
No files matched your search
@@ -3,16 +3,24 @@
|
||||
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/widgets/empty_state.dart';
|
||||
import '../../core/widgets/plain_page_scaffold.dart';
|
||||
import '../../core/formatting/count_format.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../discover/widgets/drama_grid.dart';
|
||||
import '../player/open_player.dart';
|
||||
|
||||
/// 剧场新剧页:按 id 倒序展示最新上架短剧。
|
||||
const int _moreLimit = 12;
|
||||
const int _filmHoles = 8;
|
||||
const Color _premiereTop = Color(0xFF1F1B16);
|
||||
const Color _premiereBottom = Color(0xFF2E2119);
|
||||
const Color _filmHole = Color(0x33FFFFFF);
|
||||
|
||||
/// 新剧页:暗色首映厅,今日首映大卡 + 横向更多新剧。
|
||||
class NewReleasesPage extends StatelessWidget {
|
||||
const NewReleasesPage({super.key});
|
||||
|
||||
@@ -21,22 +29,406 @@ class NewReleasesPage extends StatelessWidget {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final dramas = [...context.read<CatalogController>().dramas]
|
||||
..sort((a, b) => b.id.compareTo(a.id));
|
||||
return PlainPageScaffold(
|
||||
pageKey: const Key('new-releases-page'),
|
||||
backKey: const Key('new-releases-back'),
|
||||
title: l10n.rankingNew,
|
||||
subtitle: l10n.newReleasesSubtitle,
|
||||
child: dramas.isEmpty
|
||||
? EmptyState(icon: Icons.fiber_new_outlined, title: l10n.noStories)
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 24),
|
||||
child: DramaGrid(
|
||||
l10n: l10n,
|
||||
dramas: dramas,
|
||||
onOpen: (drama) =>
|
||||
unawaited(playDramaFromCatalog(context, drama)),
|
||||
final hero = dramas.isEmpty ? null : dramas.first;
|
||||
final more = dramas.skip(1).take(_moreLimit).toList();
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light,
|
||||
child: Scaffold(
|
||||
key: const Key('new-releases-page'),
|
||||
body: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [_premiereTop, _premiereBottom],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_header(context, l10n, dramas.length),
|
||||
Expanded(
|
||||
child: hero == null
|
||||
? Center(
|
||||
child: Text(
|
||||
l10n.noStories,
|
||||
key: const Key('new-releases-empty'),
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
child: _heroCard(context, l10n, hero),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const _FilmStrip(),
|
||||
const SizedBox(height: 14),
|
||||
if (more.isNotEmpty) ...[
|
||||
_moreHeader(l10n),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
height: 196,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(BuildContext context, AppLocalizations l10n, int count) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 4, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
key: const Key('new-releases-back'),
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
icon: const Icon(
|
||||
Icons.chevron_left_rounded,
|
||||
color: Colors.white,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.newReleasesTitle,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
key: const Key('new-releases-count'),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
l10n.newReleasesCount(count),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
l10n.newReleasesSubtitle,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
showFallbackIcon: true,
|
||||
fallbackAspectRatio: 16 / 9,
|
||||
),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0xB3000000)],
|
||||
),
|
||||
),
|
||||
),
|
||||
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: Colors.white70,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
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: Colors.white70,
|
||||
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: Colors.white,
|
||||
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: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.seeAll,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: SizedBox(
|
||||
width: 108,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
width: 108,
|
||||
height: 144,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(
|
||||
drama.posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
fallbackAspectRatio: 3 / 4,
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: _newBadge(
|
||||
l10n,
|
||||
key: Key('new-release-badge-$index'),
|
||||
compact: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_releaseDate(index),
|
||||
style: const TextStyle(color: Colors.white54, 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: Colors.white,
|
||||
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,
|
||||
color: const Color(0x1A000000),
|
||||
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: _filmHole,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user