From 5a51af426494967780d2520dae2d250aa570b9cc Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 13:24:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=E5=89=A7=E5=9C=BA=E9=A1=B5?= =?UTF-8?q?=E6=8C=89=E5=8F=82=E8=80=83=E5=9B=BE=E9=87=8D=E5=81=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 8 + lib/l10n/app_id.arb | 3 + lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_en.dart | 11 + lib/l10n/app_localizations_id.dart | 11 + lib/l10n/app_localizations_zh.dart | 13 +- lib/l10n/app_zh.arb | 5 +- lib/pages/discover/discover_view.dart | 239 ++++++++++++---- .../discover/widgets/discover_header.dart | 60 +--- .../widgets/discover_search_field.dart | 70 ++++- lib/pages/discover/widgets/drama_grid.dart | 264 ++++++++---------- test/discover/home_all_dramas_grid_test.dart | 53 +++- test/widget_test.dart | 2 +- 13 files changed, 499 insertions(+), 258 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3d18911..261acae 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -29,6 +29,14 @@ "romance": "Romance", "actionGenre": "Action", "filter": "Filter", + "reserve": "Reserve", + "identifyDrama": "Identify", + "heatLabel": "{count} heat", + "@heatLabel": { + "placeholders": { + "count": {"type": "String"} + } + }, "topCharts": "Top Charts", "newReleases": "New Releases", "newDramas": "New", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 01110ff..e54c24b 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -29,6 +29,9 @@ "romance": "Romantis", "actionGenre": "Aksi", "filter": "Filter", + "reserve": "Reservasi", + "identifyDrama": "Identifikasi", + "heatLabel": "{count} panas", "topCharts": "Peringkat Teratas", "newReleases": "Rilisan Baru", "newDramas": "Baru", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 909b346..a4e37c2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -274,6 +274,24 @@ abstract class AppLocalizations { /// **'Filter'** String get filter; + /// No description provided for @reserve. + /// + /// In en, this message translates to: + /// **'Reserve'** + String get reserve; + + /// No description provided for @identifyDrama. + /// + /// In en, this message translates to: + /// **'Identify'** + String get identifyDrama; + + /// No description provided for @heatLabel. + /// + /// In en, this message translates to: + /// **'{count} heat'** + String heatLabel(String count); + /// No description provided for @topCharts. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e2f12e0..0c8adfd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -98,6 +98,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get filter => 'Filter'; + @override + String get reserve => 'Reserve'; + + @override + String get identifyDrama => 'Identify'; + + @override + String heatLabel(String count) { + return '$count heat'; + } + @override String get topCharts => 'Top Charts'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index da33e71..c92b2e5 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -98,6 +98,17 @@ class AppLocalizationsId extends AppLocalizations { @override String get filter => 'Filter'; + @override + String get reserve => 'Reservasi'; + + @override + String get identifyDrama => 'Identifikasi'; + + @override + String heatLabel(String count) { + return '$count panas'; + } + @override String get topCharts => 'Peringkat Teratas'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index a8703df..1e2ebcf 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -69,7 +69,7 @@ class AppLocalizationsZh extends AppLocalizations { String get authLandingSubtitle => '登录后同步进度、收藏与金币权益'; @override - String get comingSoon => '即将开放'; + String get comingSoon => '即将上线'; @override String get authLegal => '继续即代表同意《用户协议》与《隐私政策》'; @@ -95,6 +95,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String get filter => '筛选'; + @override + String get reserve => '预约'; + + @override + String get identifyDrama => '识剧'; + + @override + String heatLabel(String count) { + return '$count热度'; + } + @override String get topCharts => '排行榜'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a1ade16..dd5e21b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -20,7 +20,7 @@ "recommended": "推荐", "fruitShortsBrand": "水果短剧", "authLandingSubtitle": "登录后同步进度、收藏与金币权益", - "comingSoon": "即将开放", + "comingSoon": "即将上线", "authLegal": "继续即代表同意《用户协议》与《隐私政策》", "authContinueGoogle": "使用 Google 继续", "authPhoneSignIn": "手机号登录", @@ -29,6 +29,9 @@ "romance": "都市爱情", "actionGenre": "动作", "filter": "筛选", + "reserve": "预约", + "identifyDrama": "识剧", + "heatLabel": "{count}热度", "topCharts": "排行榜", "newReleases": "新剧", "newDramas": "新剧", diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 0970307..3193a6d 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -22,6 +22,16 @@ import 'widgets/discover_header.dart'; import 'widgets/discover_search_results.dart'; import 'widgets/drama_grid.dart'; +const Color _filterIconColor = Color(0xFF7B61FF); +const Color _newIconColor = Color(0xFF14B8A6); +const double _toolbarHeight = 36; +const double _toolbarGap = 10; +const double _toolbarRadius = 10; +const double _categoryTabHeight = 44; +const double _categoryTabGap = 20; +const double _categorySelectedFontSize = 16; +const double _categoryFontSize = 15; + /// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。 class DiscoverPage extends StatefulWidget { const DiscoverPage({ @@ -96,7 +106,7 @@ class _DiscoverPageState extends State { focusNode: logic.searchFocus, onChanged: (_) => logic.onSearchChanged(), loading: logic.searchLoading, - onProfile: _openProfile, + onIdentify: () => _showComingSoon(l10n), ), Expanded(child: _feed(l10n, categories)), if (!embedded) _bottomNav(l10n), @@ -174,7 +184,8 @@ class _DiscoverPageState extends State { ? categories.feed.dramas : categories.dramasOf(logic.selectedCategoryId!); return [ - _categoryChips(l10n, categories), + _categoryTabs(l10n, categories), + _toolbar(l10n, categories), if (dramas.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 40), @@ -190,73 +201,199 @@ class _DiscoverPageState extends State { ]; } - Widget _categoryChips(AppLocalizations l10n, CategoryController categories) { - return Padding( - padding: const EdgeInsets.fromLTRB( - AppDimens.pageHorizontal, - AppDimens.spaceMd, - AppDimens.pageHorizontal, - 0, + Widget _categoryTabs(AppLocalizations l10n, CategoryController categories) { + return SizedBox( + key: const Key('theater-category-chips'), + height: _categoryTabHeight, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 14), + itemCount: categories.categories.length + 1, + separatorBuilder: (_, _) => const SizedBox(width: _categoryTabGap), + itemBuilder: (context, index) { + if (index == 0) { + return _categoryTab( + key: const Key('theater-category-all'), + label: l10n.all, + selected: logic.selectedCategoryId == null, + onTap: () => logic.selectCategory(null), + ); + } + final category = categories.categories[index - 1]; + return _categoryTab( + key: Key('theater-category-${category.id}'), + label: category.name, + selected: logic.selectedCategoryId == category.id, + onTap: () => logic.selectCategory(category.id), + ); + }, ), - child: Wrap( - key: const Key('theater-category-chips'), - spacing: 10, - runSpacing: 10, - children: [ - _filterChip( - key: const Key('theater-category-all'), - label: l10n.all, - selected: logic.selectedCategoryId == null, - onSelected: () => logic.selectCategory(null), + ); + } + + Widget _categoryTab({ + required Key key, + required String label, + required bool selected, + required VoidCallback onTap, + }) { + return GestureDetector( + key: key, + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Center( + child: Text( + label, + maxLines: 1, + style: TextStyle( + color: selected ? AppColors.onSurface : AppColors.muted, + fontSize: selected ? _categorySelectedFontSize : _categoryFontSize, + fontWeight: selected ? FontWeight.w800 : FontWeight.w500, ), - for (final category in categories.categories) - _filterChip( - key: Key('theater-category-${category.id}'), - label: category.name, - selected: logic.selectedCategoryId == category.id, - onSelected: () => logic.selectCategory(category.id), + ), + ), + ); + } + + Widget _toolbar(AppLocalizations l10n, CategoryController categories) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 12), + child: Row( + children: [ + Expanded( + child: _toolButton( + key: const Key('theater-toolbar-filter'), + icon: Icons.tune_rounded, + color: _filterIconColor, + label: l10n.filter, + onTap: () => _openFilterSheet(l10n, categories), ), + ), + const SizedBox(width: _toolbarGap), + Expanded( + child: _toolButton( + key: const Key('theater-toolbar-ranking'), + icon: Icons.local_fire_department_rounded, + color: AppColors.blue, + label: l10n.rankingTab, + onTap: () => _showComingSoon(l10n), + ), + ), + const SizedBox(width: _toolbarGap), + Expanded( + child: _toolButton( + key: const Key('theater-toolbar-new'), + icon: Icons.play_circle_fill_rounded, + color: _newIconColor, + label: l10n.newDramas, + onTap: () => _showComingSoon(l10n), + ), + ), + const SizedBox(width: _toolbarGap), + Expanded( + child: _toolButton( + key: const Key('theater-toolbar-reserve'), + icon: Icons.calendar_month_rounded, + color: AppColors.blue, + label: l10n.reserve, + onTap: () => _showComingSoon(l10n), + ), + ), ], ), ); } - Widget _filterChip({ + Widget _toolButton({ required Key key, + required IconData icon, + required Color color, required String label, - required bool selected, - required VoidCallback onSelected, + required VoidCallback onTap, }) { - return ChoiceChip( - key: key, - label: Text(label), - selected: selected, - showCheckmark: false, - onSelected: (_) => onSelected(), - backgroundColor: AppColors.card, - selectedColor: AppColors.blue, - side: BorderSide(color: selected ? AppColors.blue : AppColors.border), - labelStyle: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.onSurface, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w700, + return Material( + color: AppColors.card, + borderRadius: BorderRadius.circular(_toolbarRadius), + child: InkWell( + key: key, + onTap: onTap, + borderRadius: BorderRadius.circular(_toolbarRadius), + child: Container( + height: _toolbarHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(_toolbarRadius), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 4), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), ), - shape: const StadiumBorder(), ); } + Future _openFilterSheet( + AppLocalizations l10n, + CategoryController categories, + ) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: ListView( + key: const Key('theater-filter-sheet'), + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 12), + children: [ + ListTile( + key: const Key('theater-filter-option-all'), + title: Text(l10n.all), + onTap: () { + Navigator.pop(sheetContext); + logic.selectCategory(null); + }, + ), + for (final category in categories.categories) + ListTile( + key: Key('theater-filter-option-${category.id}'), + title: Text(category.name), + onTap: () { + Navigator.pop(sheetContext); + logic.selectCategory(category.id); + }, + ), + ], + ), + ), + ); + } + + void _showComingSoon(AppLocalizations l10n) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.comingSoon))); + } + Widget _block(Widget child) => SizedBox(height: 280, child: Center(child: child)); - void _openProfile() { - final callback = widget.callbacks.onProfile; - if (callback != null) { - callback(); - return; - } - _act(DiscoverPageAction.my); - } - void _act(DiscoverPageAction action) { logic.act( context, diff --git a/lib/pages/discover/widgets/discover_header.dart b/lib/pages/discover/widgets/discover_header.dart index bbff9d2..c2e0ecf 100644 --- a/lib/pages/discover/widgets/discover_header.dart +++ b/lib/pages/discover/widgets/discover_header.dart @@ -2,12 +2,10 @@ import 'package:flutter/material.dart'; -import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; -import '../../../l10n/app_localizations.dart'; import 'discover_search_field.dart'; -/// 剧场页页头:搜索与头像入口。 +/// 剧场页页头:整行搜索框(内嵌识剧入口)。 class DiscoverHeader extends StatelessWidget { const DiscoverHeader({ super.key, @@ -15,64 +13,26 @@ class DiscoverHeader extends StatelessWidget { required this.focusNode, required this.onChanged, required this.loading, - required this.onProfile, + required this.onIdentify, }); final TextEditingController controller; final FocusNode focusNode; final ValueChanged onChanged; final bool loading; - final VoidCallback onProfile; + final VoidCallback onIdentify; @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; return SizedBox( height: AppDimens.contentHeaderHeight, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.pageHorizontal, - ), - child: Row( - children: [ - Expanded( - child: DiscoverSearchField( - controller: controller, - focusNode: focusNode, - onChanged: onChanged, - loading: loading, - horizontalPadding: 0, - ), - ), - const SizedBox(width: AppDimens.spaceSm), - _profile(l10n), - ], - ), - ), - ); - } - - Widget _profile(AppLocalizations l10n) { - return Semantics( - button: true, - label: l10n.myTab, - child: InkWell( - key: const Key('discover-profile'), - customBorder: const CircleBorder(), - onTap: onProfile, - child: Container( - width: AppDimens.tapTarget, - height: AppDimens.tapTarget, - decoration: BoxDecoration( - color: AppColors.card, - shape: BoxShape.circle, - border: Border.all(color: AppColors.border), - ), - child: const Icon( - Icons.person_rounded, - color: AppColors.onSurface, - size: AppDimens.accountIcon, - ), + child: Center( + child: DiscoverSearchField( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + loading: loading, + onIdentify: onIdentify, ), ), ); diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart index a9a9aab..3cda27d 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -6,7 +6,10 @@ import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../l10n/app_localizations.dart'; -/// 剧场顶部搜索框。 +const double _searchHeight = 40; +const double _searchRadius = 18; + +/// 剧场顶部搜索框:右侧内嵌「识剧」入口。 class DiscoverSearchField extends StatelessWidget { const DiscoverSearchField({ super.key, @@ -14,6 +17,7 @@ class DiscoverSearchField extends StatelessWidget { required this.focusNode, required this.onChanged, required this.loading, + required this.onIdentify, this.horizontalPadding = 14, }); @@ -21,6 +25,7 @@ class DiscoverSearchField extends StatelessWidget { final FocusNode focusNode; final ValueChanged onChanged; final bool loading; + final VoidCallback onIdentify; final double horizontalPadding; @override @@ -28,7 +33,7 @@ class DiscoverSearchField extends StatelessWidget { return Padding( padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: SizedBox( - height: AppDimens.searchHeight, + height: _searchHeight, child: TextField( key: const Key('discover-search'), controller: controller, @@ -40,7 +45,7 @@ class DiscoverSearchField extends StatelessWidget { decoration: InputDecoration( isDense: true, filled: true, - fillColor: AppColors.input, + fillColor: AppColors.card, hintText: AppLocalizations.of(context)!.searchDiscover, hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12), prefixIcon: const Icon( @@ -48,19 +53,28 @@ class DiscoverSearchField extends StatelessWidget { color: AppColors.subtle, size: 17, ), - suffixIcon: loading - ? const Padding( - padding: EdgeInsets.all(11), - child: CircularProgressIndicator(strokeWidth: 2), - ) - : null, + suffixIcon: Padding( + padding: const EdgeInsets.only(right: 6), + child: loading + ? const SizedBox.square( + dimension: 18, + child: Center( + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : _identifyPill(context), + ), + suffixIconConstraints: const BoxConstraints( + minWidth: 0, + minHeight: 0, + ), contentPadding: const EdgeInsets.symmetric(horizontal: 12), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppDimens.pillRadius), + borderRadius: BorderRadius.circular(_searchRadius), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppDimens.pillRadius), + borderRadius: BorderRadius.circular(_searchRadius), borderSide: const BorderSide(color: AppColors.blue, width: 1), ), ), @@ -68,4 +82,38 @@ class DiscoverSearchField extends StatelessWidget { ), ); } + + Widget _identifyPill(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Material( + color: AppColors.raised, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + child: InkWell( + key: const Key('discover-identify'), + onTap: onIdentify, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.center_focus_weak_rounded, + color: AppColors.muted, + size: 14, + ), + const SizedBox(width: 4), + Text( + l10n.identifyDrama, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ], + ), + ), + ), + ), + ); + } } diff --git a/lib/pages/discover/widgets/drama_grid.dart b/lib/pages/discover/widgets/drama_grid.dart index 437f827..3f003b4 100644 --- a/lib/pages/discover/widgets/drama_grid.dart +++ b/lib/pages/discover/widgets/drama_grid.dart @@ -4,16 +4,29 @@ import 'package:flutter/material.dart'; import '../../../core/config/app_config.dart'; import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/count_format.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../l10n/app_localizations.dart'; const int _columns = 2; -const double _columnGap = 10; -const double _cardGap = 10; -const double _addSize = 28; -const double _addIconSize = 18; +const double _columnGap = 8; +const double _cardGap = 14; +const double _gridHorizontalPadding = 12; +const double _coverAspectRatio = 3 / 4; +const double _coverRadius = 10; +const double _titleGap = 8; +const double _tagsGap = 6; +const double _titleFontSize = 15; +const double _tagChipRadius = 6; +const double _tagChipFontSize = 11; +const double _heatFontSize = 9; +const double _heatIconSize = 11; +const int _maxTags = 3; +const Color _freeBadgeColor = Color(0xFF12B76A); +const Color _heatBackground = Color(0x99000000); +const Color _tagChipBackground = Color(0xFFF4F1EE); +final RegExp _tagSplitter = RegExp(r'[,/、·]'); /// 剧场/分类页共用的双列剧目网格。 class DramaGrid extends StatelessWidget { @@ -32,7 +45,7 @@ class DramaGrid extends StatelessWidget { Widget build(BuildContext context) { return Padding( key: const Key('home-all-dramas-section'), - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric(horizontal: _gridHorizontalPadding), child: Row( key: const Key('home-all-dramas-grid'), crossAxisAlignment: CrossAxisAlignment.start, @@ -60,7 +73,7 @@ class DramaGrid extends StatelessWidget { } } -/// 双列网格中的剧目卡片。 +/// 双列网格中的剧目卡片:海报 + 标题 + 题材标签。 class DramaGridCard extends StatelessWidget { const DramaGridCard({ super.key, @@ -73,103 +86,88 @@ class DramaGridCard extends StatelessWidget { final Drama drama; final VoidCallback onTap; + bool get _isFree => + drama.freeEpisodes > 0 || drama.episodes.any((episode) => episode.isFree); + @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - boxShadow: const [AppTheme.cardShadow], - ), - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - clipBehavior: Clip.antiAlias, - child: InkWell( - key: Key('home-drama-card-${drama.id}'), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(AppDimens.spaceSm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _cover(), - const SizedBox(height: AppDimens.spaceSm), - _title(), - const SizedBox(height: AppDimens.spaceXs), - _footer(), - ], - ), - ), - ), + return GestureDetector( + key: Key('home-drama-card-${drama.id}'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _cover(), + const SizedBox(height: _titleGap), + _title(), + const SizedBox(height: _tagsGap), + _tags(), + ], ), ); } Widget _cover() { - final free = - drama.freeEpisodes > 0 || - drama.episodes.any((episode) => episode.isFree); return ClipRRect( key: Key('home-drama-cover-${drama.id}'), - borderRadius: BorderRadius.circular(AppDimens.coverRadius), - child: Stack( - children: [ - _DiscoverCoverImage(path: drama.posterUrl), - Positioned(top: 8, left: 8, child: _tagBadge(free)), - if (_lockedCount > 0) - Positioned(top: 8, right: 8, child: _lockedBadge()), - Positioned(right: 8, bottom: 8, child: _episodeBadge()), - ], - ), - ); - } - - int get _lockedCount { - final explicit = drama.episodes.where((episode) => episode.locked).length; - if (explicit > 0) return explicit; - final inferred = drama.episodeCount - drama.freeEpisodes; - return inferred > 0 ? inferred : 0; - } - - Widget _tagBadge(bool free) { - return Container( - key: Key('drama-tag-${drama.id}'), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.badgePink, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), - child: Text( - free ? l10n.free : l10n.originalTag, - style: const TextStyle( - color: AppColors.badgePinkText, - fontSize: AppDimens.cardMeta, - fontWeight: FontWeight.w800, + borderRadius: BorderRadius.circular(_coverRadius), + child: AspectRatio( + aspectRatio: _coverAspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + _DiscoverCoverImage(path: drama.posterUrl), + Positioned(top: 8, left: 8, child: _tagBadge()), + Positioned(left: 8, bottom: 8, child: _heatBadge()), + ], ), ), ); } - Widget _lockedBadge() { + Widget _tagBadge() { return Container( - key: Key('drama-locked-${drama.id}'), + key: Key('drama-tag-${drama.id}'), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: _freeBadgeColor, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Text( + _isFree ? l10n.free : l10n.originalTag, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + Widget _heatBadge() { + return Container( + key: Key('drama-heat-${drama.id}'), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration( - color: AppColors.overlayDark, + color: _heatBackground, borderRadius: BorderRadius.circular(AppDimens.pillRadius), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.lock_rounded, color: AppColors.onPrimary, size: 11), + const Icon( + Icons.local_fire_department_rounded, + color: AppColors.onPrimary, + size: _heatIconSize, + ), const SizedBox(width: 2), Text( - l10n.lockedEpisodeCount(_lockedCount), + l10n.heatLabel(formatInteractionCount(drama.viewCount)), style: const TextStyle( color: AppColors.onPrimary, - fontSize: 9, - fontWeight: FontWeight.w700, + fontSize: _heatFontSize, + fontWeight: FontWeight.w600, ), ), ], @@ -177,74 +175,60 @@ class DramaGridCard extends StatelessWidget { ); } - Widget _episodeBadge() { - return Container( - key: Key('drama-episodes-badge-${drama.id}'), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.badgeBeige, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), - child: Text( - l10n.allEpisodesBadge(drama.episodeCount), - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 9, - fontWeight: FontWeight.w700, - ), - ), - ); - } - Widget _title() { return Text( drama.title, key: Key('home-drama-title-${drama.id}'), - maxLines: 1, + maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: AppDimens.cardTitle, - fontWeight: FontWeight.w800, + fontSize: _titleFontSize, + fontWeight: FontWeight.w700, + height: 1.25, ), ); } - Widget _genre() { - final genre = drama.genre.isEmpty ? drama.category : drama.genre; - return Text( - genre.isEmpty ? l10n.videoPending : genre, - key: Key('home-drama-genre-${drama.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.cardMeta, - ), - ); - } - - Widget _footer() { - return Row( + Widget _tags() { + final tags = _tagValues(); + if (tags.isEmpty) return const SizedBox.shrink(); + return Wrap( + spacing: 6, + runSpacing: 4, children: [ - Expanded(child: _genre()), - Container( - key: Key('drama-add-${drama.id}'), - width: _addSize, - height: _addSize, - decoration: const BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, + for (var index = 0; index < tags.length; index++) + Container( + key: Key('drama-tag-chip-${drama.id}-$index'), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: _tagChipBackground, + borderRadius: BorderRadius.circular(_tagChipRadius), + ), + child: Text( + tags[index], + style: const TextStyle( + color: AppColors.muted, + fontSize: _tagChipFontSize, + ), + ), ), - child: const Icon( - Icons.add_rounded, - color: AppColors.onPrimary, - size: _addIconSize, - ), - ), ], ); } + + List _tagValues() { + final result = []; + for (final value in [drama.genre, drama.category]) { + for (final part in value.split(_tagSplitter)) { + final tag = part.trim(); + if (tag.isEmpty || result.contains(tag)) continue; + result.add(tag); + if (result.length == _maxTags) return result; + } + } + return result; + } } class _DiscoverCoverImage extends StatelessWidget { @@ -259,7 +243,8 @@ class _DiscoverCoverImage extends StatelessWidget { return Image.network( url, width: double.infinity, - fit: BoxFit.fitWidth, + height: double.infinity, + fit: BoxFit.cover, loadingBuilder: (context, child, progress) { if (progress == null) return child; return _placeholder(); @@ -269,16 +254,13 @@ class _DiscoverCoverImage extends StatelessWidget { } Widget _placeholder() { - return AspectRatio( - aspectRatio: 3 / 4, - child: ColoredBox( - color: AppColors.card, - child: const Center( - child: Icon( - Icons.image_not_supported_outlined, - color: AppColors.muted, - size: 32, - ), + return const ColoredBox( + color: AppColors.card, + child: Center( + child: Icon( + Icons.image_not_supported_outlined, + color: AppColors.muted, + size: 32, ), ), ); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 2080cb0..d8a6e66 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -34,9 +34,54 @@ void main() { expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('discover-identify')), findsOneWidget); + expect(find.byKey(const Key('discover-profile')), findsNothing); + for (final tool in ['filter', 'ranking', 'new', 'reserve']) { + expect(find.byKey(Key('theater-toolbar-$tool')), findsOneWidget); + } + expect( + find.byKey(const Key('drama-heat-city-lights-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('drama-tag-chip-city-lights-demo-0')), + findsOneWidget, + ); expect(tester.takeException(), isNull); }); + testWidgets('should_open_filter_sheet_and_apply_category', (tester) async { + await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); + + await tester.tap(find.byKey(const Key('theater-toolbar-filter'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('theater-filter-sheet')), findsOneWidget); + + await tester.tap(find.byKey(const Key('theater-filter-option-7'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('theater-filter-sheet')), findsNothing); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsNothing, + ); + }); + + testWidgets('should_show_coming_soon_when_identify_is_tapped', ( + tester, + ) async { + await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); + + await tester.tap(find.byKey(const Key('discover-identify'))); + await tester.pump(); + + expect(find.text('Coming soon'), findsOneWidget); + }); + testWidgets('should_filter_grid_when_category_chip_is_tapped', ( tester, ) async { @@ -73,7 +118,7 @@ void main() { matching: find.byType(Scrollable), ); - await tester.drag(find.byType(ListView), const Offset(0, -140)); + await tester.drag(_verticalFeed, const Offset(0, -140)); await tester.pumpAndSettle(); expect( @@ -129,7 +174,7 @@ void main() { await _pumpTheater(tester, categoryRepository: counter); final before = counter.categoryLoads + counter.feedLoads; - await tester.drag(find.byType(ListView), const Offset(0, 320)); + await tester.drag(_verticalFeed, const Offset(0, 320)); await tester.pump(); await tester.pump(const Duration(seconds: 1)); await tester.pumpAndSettle(); @@ -139,6 +184,10 @@ void main() { }); } +final Finder _verticalFeed = find.byWidgetPredicate( + (widget) => widget is ListView && widget.scrollDirection == Axis.vertical, +); + Future _pumpTheater( WidgetTester tester, { required CategoryRepository categoryRepository, diff --git a/test/widget_test.dart b/test/widget_test.dart index 40b6905..3f0fb47 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -215,7 +215,7 @@ void main() { expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-profile')), findsOneWidget); + expect(find.byKey(const Key('discover-identify')), findsOneWidget); expect(tester.takeException(), isNull); });