From 4f81bcf2b1d46047f26c34fd120da51dfa02e2b0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 15:28:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=E6=96=B0=E5=89=A7=E9=A6=96?= =?UTF-8?q?=E6=98=A0=E5=8E=85=E4=B8=8E=E6=A1=A3=E6=9C=9F=E9=A2=84=E7=BA=A6?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 16 + lib/l10n/app_id.arb | 11 + lib/l10n/app_localizations.dart | 66 +++ lib/l10n/app_localizations_en.dart | 35 ++ lib/l10n/app_localizations_id.dart | 35 ++ lib/l10n/app_localizations_zh.dart | 35 ++ lib/l10n/app_zh.arb | 11 + lib/pages/new_releases/new_releases_page.dart | 428 +++++++++++++++++- lib/pages/reserve/reserve_page.dart | 376 +++++++++++++-- test/discover/theater_pages_test.dart | 46 +- 10 files changed, 998 insertions(+), 61 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6c5ae4e..692373b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -156,6 +156,22 @@ "createUploadSubtitle": "Publish a finished short drama", "createMottoPrimary": "Your imagination", "createMottoSecondary": "Can be seen by more people", + "newReleasesTitle": "New premieres", + "newReleasesCount": "{count} new this week", + "@newReleasesCount": { + "placeholders": { + "count": {"type": "int"} + } + }, + "premiereToday": "Today's premiere", + "newBadge": "NEW", + "reserveTitle": "Schedule booking", + "goNewReleases": "Browse new releases", + "reserveStepsTitle": "What you get after reserving", + "reserveStep1": "One-tap reserve", + "reserveStep2": "Premiere reminder", + "reserveStep3": "Watch right away", + "reserveWeek": "This week", "weekdayMon": "Mon", "weekdayTue": "Tue", "weekdayWed": "Wed", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4a47fe5..286f866 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -135,6 +135,17 @@ "createUploadSubtitle": "Terbitkan drama pendek yang sudah selesai", "createMottoPrimary": "Imajinasimu", "createMottoSecondary": "Bisa dilihat lebih banyak orang", + "newReleasesTitle": "Premiere baru", + "newReleasesCount": "{count} baru minggu ini", + "premiereToday": "Tayang perdana hari ini", + "newBadge": "BARU", + "reserveTitle": "Jadwal reservasi", + "goNewReleases": "Lihat rilis baru", + "reserveStepsTitle": "Yang kamu dapat setelah reservasi", + "reserveStep1": "Reservasi sekali ketuk", + "reserveStep2": "Pengingat tayang", + "reserveStep3": "Langsung tonton", + "reserveWeek": "Minggu ini", "weekdayMon": "Sen", "weekdayTue": "Sel", "weekdayWed": "Rab", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 817d79b..d74c2aa 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -676,6 +676,72 @@ abstract class AppLocalizations { /// **'Can be seen by more people'** String get createMottoSecondary; + /// No description provided for @newReleasesTitle. + /// + /// In en, this message translates to: + /// **'New premieres'** + String get newReleasesTitle; + + /// No description provided for @newReleasesCount. + /// + /// In en, this message translates to: + /// **'{count} new this week'** + String newReleasesCount(int count); + + /// No description provided for @premiereToday. + /// + /// In en, this message translates to: + /// **'Today\'s premiere'** + String get premiereToday; + + /// No description provided for @newBadge. + /// + /// In en, this message translates to: + /// **'NEW'** + String get newBadge; + + /// No description provided for @reserveTitle. + /// + /// In en, this message translates to: + /// **'Schedule booking'** + String get reserveTitle; + + /// No description provided for @goNewReleases. + /// + /// In en, this message translates to: + /// **'Browse new releases'** + String get goNewReleases; + + /// No description provided for @reserveStepsTitle. + /// + /// In en, this message translates to: + /// **'What you get after reserving'** + String get reserveStepsTitle; + + /// No description provided for @reserveStep1. + /// + /// In en, this message translates to: + /// **'One-tap reserve'** + String get reserveStep1; + + /// No description provided for @reserveStep2. + /// + /// In en, this message translates to: + /// **'Premiere reminder'** + String get reserveStep2; + + /// No description provided for @reserveStep3. + /// + /// In en, this message translates to: + /// **'Watch right away'** + String get reserveStep3; + + /// No description provided for @reserveWeek. + /// + /// In en, this message translates to: + /// **'This week'** + String get reserveWeek; + /// No description provided for @weekdayMon. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 63e059f..3f7e73d 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -323,6 +323,41 @@ class AppLocalizationsEn extends AppLocalizations { @override String get createMottoSecondary => 'Can be seen by more people'; + @override + String get newReleasesTitle => 'New premieres'; + + @override + String newReleasesCount(int count) { + return '$count new this week'; + } + + @override + String get premiereToday => 'Today\'s premiere'; + + @override + String get newBadge => 'NEW'; + + @override + String get reserveTitle => 'Schedule booking'; + + @override + String get goNewReleases => 'Browse new releases'; + + @override + String get reserveStepsTitle => 'What you get after reserving'; + + @override + String get reserveStep1 => 'One-tap reserve'; + + @override + String get reserveStep2 => 'Premiere reminder'; + + @override + String get reserveStep3 => 'Watch right away'; + + @override + String get reserveWeek => 'This week'; + @override String get weekdayMon => 'Mon'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index c8a1408..12239da 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -324,6 +324,41 @@ class AppLocalizationsId extends AppLocalizations { @override String get createMottoSecondary => 'Bisa dilihat lebih banyak orang'; + @override + String get newReleasesTitle => 'Premiere baru'; + + @override + String newReleasesCount(int count) { + return '$count baru minggu ini'; + } + + @override + String get premiereToday => 'Tayang perdana hari ini'; + + @override + String get newBadge => 'BARU'; + + @override + String get reserveTitle => 'Jadwal reservasi'; + + @override + String get goNewReleases => 'Lihat rilis baru'; + + @override + String get reserveStepsTitle => 'Yang kamu dapat setelah reservasi'; + + @override + String get reserveStep1 => 'Reservasi sekali ketuk'; + + @override + String get reserveStep2 => 'Pengingat tayang'; + + @override + String get reserveStep3 => 'Langsung tonton'; + + @override + String get reserveWeek => 'Minggu ini'; + @override String get weekdayMon => 'Sen'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 00adeb8..8cbbaad 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -318,6 +318,41 @@ class AppLocalizationsZh extends AppLocalizations { @override String get createMottoSecondary => '可以被更多人看见'; + @override + String get newReleasesTitle => '新剧首映'; + + @override + String newReleasesCount(int count) { + return '本周上新 $count 部'; + } + + @override + String get premiereToday => '今日首映'; + + @override + String get newBadge => 'NEW'; + + @override + String get reserveTitle => '档期预约'; + + @override + String get goNewReleases => '去新剧页看看'; + + @override + String get reserveStepsTitle => '预约后你会得到'; + + @override + String get reserveStep1 => '一键预约'; + + @override + String get reserveStep2 => '开播提醒'; + + @override + String get reserveStep3 => '立即追剧'; + + @override + String get reserveWeek => '本周待播'; + @override String get weekdayMon => '周一'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b52e17e..c0caaac 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -135,6 +135,17 @@ "createUploadSubtitle": "发布已完成的漫剧", "createMottoPrimary": "你的想象", "createMottoSecondary": "可以被更多人看见", + "newReleasesTitle": "新剧首映", + "newReleasesCount": "本周上新 {count} 部", + "premiereToday": "今日首映", + "newBadge": "NEW", + "reserveTitle": "档期预约", + "goNewReleases": "去新剧页看看", + "reserveStepsTitle": "预约后你会得到", + "reserveStep1": "一键预约", + "reserveStep2": "开播提醒", + "reserveStep3": "立即追剧", + "reserveWeek": "本周待播", "weekdayMon": "周一", "weekdayTue": "周二", "weekdayWed": "周三", diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index ec9aee8..b540b8a 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -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().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( + 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), + ), + ), + ], ); } } diff --git a/lib/pages/reserve/reserve_page.dart b/lib/pages/reserve/reserve_page.dart index a680410..1d8189b 100644 --- a/lib/pages/reserve/reserve_page.dart +++ b/lib/pages/reserve/reserve_page.dart @@ -3,46 +3,360 @@ import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; +import '../new_releases/new_releases_page.dart'; -/// 剧场预约页:暂无预约数据源,展示空态与即将上线说明。 -class ReservePage extends StatelessWidget { +const double _ticketRadius = 20; +const double _notchSize = 24; +const Color _ticketDash = Color(0x66FF7A1A); + +/// 预约页:档期周历 + 电影票式空态 + 预约权益说明。 +class ReservePage extends StatefulWidget { const ReservePage({super.key}); + @override + State createState() => _ReservePageState(); +} + +class _ReservePageState extends State { + late final List _week; + late int _selected; + + @override + void initState() { + super.initState(); + final today = DateTime.now(); + final monday = today.subtract(Duration(days: today.weekday - 1)); + _week = [ + for (var index = 0; index < 7; index++) monday.add(Duration(days: index)), + ]; + _selected = today.weekday - 1; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - return PlainPageScaffold( - pageKey: const Key('reserve-page'), - backKey: const Key('reserve-back'), - title: l10n.reserve, - subtitle: l10n.reserveSubtitle, - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - EmptyState( - key: const Key('reserve-empty'), - icon: Icons.event_available_outlined, - title: l10n.reserveEmptyTitle, - subtitle: l10n.reserveEmptyHint, - ), - const SizedBox(height: 12), - TextButton( - onPressed: null, - child: Text( - l10n.comingSoon, - style: const TextStyle(color: AppColors.subtle), - ), - ), - ], - ), + return Scaffold( + key: const Key('reserve-page'), + backgroundColor: AppColors.ink, + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(0, 4, 0, 24), + children: [ + _header(context, l10n), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _weekStrip(l10n), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: _ticketCard(context, l10n), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 0), + child: _steps(l10n), + ), + ], ), ), ); } + + Widget _header(BuildContext context, AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.fromLTRB(6, 0, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + key: const Key('reserve-back'), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon( + Icons.chevron_left_rounded, + color: AppColors.onSurface, + size: 26, + ), + ), + Expanded( + child: Text( + l10n.reserveTitle, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 24, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + l10n.reserveSubtitle, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ), + ], + ), + ); + } + + Widget _weekStrip(AppLocalizations l10n) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.reserveWeek, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + SizedBox( + key: const Key('reserve-week-strip'), + height: 56, + child: Row( + children: [ + for (var index = 0; index < _week.length; index++) ...[ + if (index > 0) const SizedBox(width: 6), + Expanded(child: _dayCapsule(index)), + ], + ], + ), + ), + ], + ); + } + + Widget _dayCapsule(int index) { + final date = _week[index]; + final selected = _selected == index; + final weekdays = ['一', '二', '三', '四', '五', '六', '日']; + return GestureDetector( + key: Key('reserve-day-$index'), + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _selected = index), + child: Container( + decoration: BoxDecoration( + color: selected ? AppColors.blue : AppColors.card, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected ? AppColors.blue : AppColors.border, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + weekdays[date.weekday - 1], + style: TextStyle( + color: selected ? Colors.white : AppColors.muted, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + '${date.day}', + style: TextStyle( + color: selected ? Colors.white : AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ); + } + + Widget _ticketCard(BuildContext context, AppLocalizations l10n) { + return LayoutBuilder( + builder: (context, constraints) => Stack( + clipBehavior: Clip.none, + children: [ + Container( + key: const Key('reserve-empty'), + width: double.infinity, + padding: const EdgeInsets.fromLTRB(28, 26, 28, 22), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_ticketRadius), + ), + child: Column( + children: [ + Container( + width: 56, + height: 56, + decoration: const BoxDecoration( + color: AppColors.raised, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.notifications_active_rounded, + color: AppColors.blue, + size: 32, + ), + ), + const SizedBox(height: 12), + Text( + l10n.reserveEmptyTitle, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 17, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + l10n.reserveEmptyHint, + maxLines: 2, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + const SizedBox(height: 16), + SizedBox( + height: 40, + child: FilledButton( + key: const Key('reserve-go-new'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const NewReleasesPage(), + ), + ), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + l10n.goNewReleases, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + Positioned(left: -_notchSize / 2, top: 120, child: _notch()), + Positioned(right: -_notchSize / 2, top: 120, child: _notch()), + Positioned.fill( + child: IgnorePointer( + child: CustomPaint(painter: _DashedTicketPainter()), + ), + ), + ], + ), + ); + } + + Widget _notch() { + return Container( + width: _notchSize, + height: _notchSize, + decoration: const BoxDecoration( + color: AppColors.ink, + shape: BoxShape.circle, + ), + ); + } + + Widget _steps(AppLocalizations l10n) { + final steps = [l10n.reserveStep1, l10n.reserveStep2, l10n.reserveStep3]; + return Column( + key: const Key('reserve-steps'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.reserveStepsTitle, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 10), + for (var index = 0; index < steps.length; index++) ...[ + if (index > 0) const SizedBox(height: 8), + Container( + key: Key('reserve-step-${index + 1}'), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + Container( + width: 22, + height: 22, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + ), + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 10), + Text( + steps[index], + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ], + ); + } +} + +class _DashedTicketPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = _ticketDash + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + final path = Path() + ..addRRect( + RRect.fromRectAndRadius( + Offset.zero & size, + const Radius.circular(_ticketRadius), + ), + ); + for (final metric in path.computeMetrics()) { + var distance = 0.0; + while (distance < metric.length) { + final next = (distance + 6).clamp(0.0, metric.length); + canvas.drawPath(metric.extractPath(distance, next), paint); + distance = next + 4; + } + } + } + + @override + bool shouldRepaint(covariant _DashedTicketPainter oldDelegate) => false; } diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index ddb4580..8294393 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -4,10 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/theme/app_colors.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/discover/widgets/drama_grid.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'; @@ -46,12 +46,20 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('new-releases-page')), findsOneWidget); - final grid = tester.widget(find.byType(DramaGrid)); - expect(grid.dramas.map((drama) => drama.id).toList(), [ - 'vengeance-demo', - 'the-signal-demo', - 'city-lights-demo', - ]); + expect(find.byKey(const Key('new-releases-hero')), findsOneWidget); + expect(find.text('Vengeance Rising'), findsWidgets); + expect( + find.byKey(const Key('new-release-card-the-signal-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('new-release-card-city-lights-demo')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('new-releases-hero'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-scaffold')), findsOneWidget); }); testWidgets('should_open_reserve_empty_state', (tester) async { @@ -60,12 +68,26 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('reserve-page')), findsOneWidget); + expect(find.byKey(const Key('reserve-week-strip')), findsOneWidget); + for (var index = 0; index < 7; index++) { + expect(find.byKey(Key('reserve-day-$index')), findsOneWidget); + } expect(find.byKey(const Key('reserve-empty')), findsOneWidget); - expect(find.text('Coming soon'), findsOneWidget); - expect( - tester.widget(find.byType(TextButton)).onPressed, - isNull, + for (var index = 1; index <= 3; index++) { + expect(find.byKey(Key('reserve-step-$index')), findsOneWidget); + } + final todayIndex = DateTime.now().weekday - 1; + final today = tester.widget( + find.descendant( + of: find.byKey(Key('reserve-day-$todayIndex')), + matching: find.byType(Container), + ), ); + expect((today.decoration! as BoxDecoration).color, AppColors.blue); + + await tester.tap(find.byKey(const Key('reserve-go-new'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('new-releases-page')), findsOneWidget); }); testWidgets('should_render_podium_with_center_on_top', (tester) async { @@ -119,7 +141,7 @@ void main() { await tester.tap(find.byKey(const Key('theater-toolbar-new'))); await tester.pumpAndSettle(); - final card = find.byKey(const Key('home-drama-card-the-signal-demo')); + final card = find.byKey(const Key('new-release-card-the-signal-demo')); await tester.ensureVisible(card); await tester.pumpAndSettle(); await tester.tap(card);