merge: 合并 K32-A 剧场页重做

This commit is contained in:
Zhou Weidong committed 2026-10-20 13:24:24 +08:00
commit edfef61ee5
13 files changed
+499 -258

No files matched your search

+8
View File
@@ -29,6 +29,14 @@
"romance": "Romance", "romance": "Romance",
"actionGenre": "Action", "actionGenre": "Action",
"filter": "Filter", "filter": "Filter",
"reserve": "Reserve",
"identifyDrama": "Identify",
"heatLabel": "{count} heat",
"@heatLabel": {
"placeholders": {
"count": {"type": "String"}
}
},
"topCharts": "Top Charts", "topCharts": "Top Charts",
"newReleases": "New Releases", "newReleases": "New Releases",
"newDramas": "New", "newDramas": "New",
+3
View File
@@ -29,6 +29,9 @@
"romance": "Romantis", "romance": "Romantis",
"actionGenre": "Aksi", "actionGenre": "Aksi",
"filter": "Filter", "filter": "Filter",
"reserve": "Reservasi",
"identifyDrama": "Identifikasi",
"heatLabel": "{count} panas",
"topCharts": "Peringkat Teratas", "topCharts": "Peringkat Teratas",
"newReleases": "Rilisan Baru", "newReleases": "Rilisan Baru",
"newDramas": "Baru", "newDramas": "Baru",
+18
View File
@@ -274,6 +274,24 @@ abstract class AppLocalizations {
/// **'Filter'** /// **'Filter'**
String get 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. /// No description provided for @topCharts.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+11
View File
@@ -98,6 +98,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get filter => 'Filter'; String get filter => 'Filter';
@override
String get reserve => 'Reserve';
@override
String get identifyDrama => 'Identify';
@override
String heatLabel(String count) {
return '$count heat';
}
@override @override
String get topCharts => 'Top Charts'; String get topCharts => 'Top Charts';
+11
View File
@@ -98,6 +98,17 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get filter => 'Filter'; String get filter => 'Filter';
@override
String get reserve => 'Reservasi';
@override
String get identifyDrama => 'Identifikasi';
@override
String heatLabel(String count) {
return '$count panas';
}
@override @override
String get topCharts => 'Peringkat Teratas'; String get topCharts => 'Peringkat Teratas';
+12 -1
View File
@@ -69,7 +69,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get authLandingSubtitle => '登录后同步进度、收藏与金币权益'; String get authLandingSubtitle => '登录后同步进度、收藏与金币权益';
@override @override
String get comingSoon => '即将开放'; String get comingSoon => '即将上线';
@override @override
String get authLegal => '继续即代表同意《用户协议》与《隐私政策》'; String get authLegal => '继续即代表同意《用户协议》与《隐私政策》';
@@ -95,6 +95,17 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get filter => '筛选'; String get filter => '筛选';
@override
String get reserve => '预约';
@override
String get identifyDrama => '识剧';
@override
String heatLabel(String count) {
return '$count热度';
}
@override @override
String get topCharts => '排行榜'; String get topCharts => '排行榜';
+4 -1
View File
@@ -20,7 +20,7 @@
"recommended": "推荐", "recommended": "推荐",
"fruitShortsBrand": "水果短剧", "fruitShortsBrand": "水果短剧",
"authLandingSubtitle": "登录后同步进度、收藏与金币权益", "authLandingSubtitle": "登录后同步进度、收藏与金币权益",
"comingSoon": "即将开放", "comingSoon": "即将上线",
"authLegal": "继续即代表同意《用户协议》与《隐私政策》", "authLegal": "继续即代表同意《用户协议》与《隐私政策》",
"authContinueGoogle": "使用 Google 继续", "authContinueGoogle": "使用 Google 继续",
"authPhoneSignIn": "手机号登录", "authPhoneSignIn": "手机号登录",
@@ -29,6 +29,9 @@
"romance": "都市爱情", "romance": "都市爱情",
"actionGenre": "动作", "actionGenre": "动作",
"filter": "筛选", "filter": "筛选",
"reserve": "预约",
"identifyDrama": "识剧",
"heatLabel": "{count}热度",
"topCharts": "排行榜", "topCharts": "排行榜",
"newReleases": "新剧", "newReleases": "新剧",
"newDramas": "新剧", "newDramas": "新剧",
+188 -51
View File
@@ -22,6 +22,16 @@ import 'widgets/discover_header.dart';
import 'widgets/discover_search_results.dart'; import 'widgets/discover_search_results.dart';
import 'widgets/drama_grid.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 与双列剧库。 /// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。
class DiscoverPage extends StatefulWidget { class DiscoverPage extends StatefulWidget {
const DiscoverPage({ const DiscoverPage({
@@ -96,7 +106,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
focusNode: logic.searchFocus, focusNode: logic.searchFocus,
onChanged: (_) => logic.onSearchChanged(), onChanged: (_) => logic.onSearchChanged(),
loading: logic.searchLoading, loading: logic.searchLoading,
onProfile: _openProfile, onIdentify: () => _showComingSoon(l10n),
), ),
Expanded(child: _feed(l10n, categories)), Expanded(child: _feed(l10n, categories)),
if (!embedded) _bottomNav(l10n), if (!embedded) _bottomNav(l10n),
@@ -174,7 +184,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
? categories.feed.dramas ? categories.feed.dramas
: categories.dramasOf(logic.selectedCategoryId!); : categories.dramasOf(logic.selectedCategoryId!);
return [ return [
_categoryChips(l10n, categories), _categoryTabs(l10n, categories),
_toolbar(l10n, categories),
if (dramas.isEmpty) if (dramas.isEmpty)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 40), padding: const EdgeInsets.symmetric(vertical: 40),
@@ -190,73 +201,199 @@ class _DiscoverPageState extends State<DiscoverPage> {
]; ];
} }
Widget _categoryChips(AppLocalizations l10n, CategoryController categories) { Widget _categoryTabs(AppLocalizations l10n, CategoryController categories) {
return Padding( return SizedBox(
padding: const EdgeInsets.fromLTRB( key: const Key('theater-category-chips'),
AppDimens.pageHorizontal, height: _categoryTabHeight,
AppDimens.spaceMd, child: ListView.separated(
AppDimens.pageHorizontal, scrollDirection: Axis.horizontal,
0, 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, Widget _categoryTab({
children: [ required Key key,
_filterChip( required String label,
key: const Key('theater-category-all'), required bool selected,
label: l10n.all, required VoidCallback onTap,
selected: logic.selectedCategoryId == null, }) {
onSelected: () => logic.selectCategory(null), 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 Key key,
required IconData icon,
required Color color,
required String label, required String label,
required bool selected, required VoidCallback onTap,
required VoidCallback onSelected,
}) { }) {
return ChoiceChip( return Material(
key: key, color: AppColors.card,
label: Text(label), borderRadius: BorderRadius.circular(_toolbarRadius),
selected: selected, child: InkWell(
showCheckmark: false, key: key,
onSelected: (_) => onSelected(), onTap: onTap,
backgroundColor: AppColors.card, borderRadius: BorderRadius.circular(_toolbarRadius),
selectedColor: AppColors.blue, child: Container(
side: BorderSide(color: selected ? AppColors.blue : AppColors.border), height: _toolbarHeight,
labelStyle: TextStyle( decoration: BoxDecoration(
color: selected ? AppColors.onPrimary : AppColors.onSurface, borderRadius: BorderRadius.circular(_toolbarRadius),
fontSize: AppDimens.caption, border: Border.all(color: AppColors.border),
fontWeight: FontWeight.w700, ),
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<void> _openFilterSheet(
AppLocalizations l10n,
CategoryController categories,
) async {
await showModalBottomSheet<void>(
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) => Widget _block(Widget child) =>
SizedBox(height: 280, child: Center(child: 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) { void _act(DiscoverPageAction action) {
logic.act( logic.act(
context, context,
+10 -50
View File
@@ -2,12 +2,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
import 'discover_search_field.dart'; import 'discover_search_field.dart';
/// 剧场页页头:搜索与头像入口。 /// 剧场页页头:整行搜索框(内嵌识剧入口)。
class DiscoverHeader extends StatelessWidget { class DiscoverHeader extends StatelessWidget {
const DiscoverHeader({ const DiscoverHeader({
super.key, super.key,
@@ -15,64 +13,26 @@ class DiscoverHeader extends StatelessWidget {
required this.focusNode, required this.focusNode,
required this.onChanged, required this.onChanged,
required this.loading, required this.loading,
required this.onProfile, required this.onIdentify,
}); });
final TextEditingController controller; final TextEditingController controller;
final FocusNode focusNode; final FocusNode focusNode;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
final bool loading; final bool loading;
final VoidCallback onProfile; final VoidCallback onIdentify;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SizedBox( return SizedBox(
height: AppDimens.contentHeaderHeight, height: AppDimens.contentHeaderHeight,
child: Padding( child: Center(
padding: const EdgeInsets.symmetric( child: DiscoverSearchField(
horizontal: AppDimens.pageHorizontal, controller: controller,
), focusNode: focusNode,
child: Row( onChanged: onChanged,
children: [ loading: loading,
Expanded( onIdentify: onIdentify,
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,
),
), ),
), ),
); );
@@ -6,7 +6,10 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
/// 剧场顶部搜索框。 const double _searchHeight = 40;
const double _searchRadius = 18;
/// 剧场顶部搜索框:右侧内嵌「识剧」入口。
class DiscoverSearchField extends StatelessWidget { class DiscoverSearchField extends StatelessWidget {
const DiscoverSearchField({ const DiscoverSearchField({
super.key, super.key,
@@ -14,6 +17,7 @@ class DiscoverSearchField extends StatelessWidget {
required this.focusNode, required this.focusNode,
required this.onChanged, required this.onChanged,
required this.loading, required this.loading,
required this.onIdentify,
this.horizontalPadding = 14, this.horizontalPadding = 14,
}); });
@@ -21,6 +25,7 @@ class DiscoverSearchField extends StatelessWidget {
final FocusNode focusNode; final FocusNode focusNode;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
final bool loading; final bool loading;
final VoidCallback onIdentify;
final double horizontalPadding; final double horizontalPadding;
@override @override
@@ -28,7 +33,7 @@ class DiscoverSearchField extends StatelessWidget {
return Padding( return Padding(
padding: EdgeInsets.symmetric(horizontal: horizontalPadding), padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
child: SizedBox( child: SizedBox(
height: AppDimens.searchHeight, height: _searchHeight,
child: TextField( child: TextField(
key: const Key('discover-search'), key: const Key('discover-search'),
controller: controller, controller: controller,
@@ -40,7 +45,7 @@ class DiscoverSearchField extends StatelessWidget {
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
filled: true, filled: true,
fillColor: AppColors.input, fillColor: AppColors.card,
hintText: AppLocalizations.of(context)!.searchDiscover, hintText: AppLocalizations.of(context)!.searchDiscover,
hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12), hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12),
prefixIcon: const Icon( prefixIcon: const Icon(
@@ -48,19 +53,28 @@ class DiscoverSearchField extends StatelessWidget {
color: AppColors.subtle, color: AppColors.subtle,
size: 17, size: 17,
), ),
suffixIcon: loading suffixIcon: Padding(
? const Padding( padding: const EdgeInsets.only(right: 6),
padding: EdgeInsets.all(11), child: loading
child: CircularProgressIndicator(strokeWidth: 2), ? const SizedBox.square(
) dimension: 18,
: null, child: Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: _identifyPill(context),
),
suffixIconConstraints: const BoxConstraints(
minWidth: 0,
minHeight: 0,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 12),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDimens.pillRadius), borderRadius: BorderRadius.circular(_searchRadius),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDimens.pillRadius), borderRadius: BorderRadius.circular(_searchRadius),
borderSide: const BorderSide(color: AppColors.blue, width: 1), 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),
),
],
),
),
),
),
);
}
} }
+123 -141
View File
@@ -4,16 +4,29 @@ import 'package:flutter/material.dart';
import '../../../core/config/app_config.dart'; import '../../../core/config/app_config.dart';
import '../../../core/data/models/drama.dart'; import '../../../core/data/models/drama.dart';
import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_theme.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
const int _columns = 2; const int _columns = 2;
const double _columnGap = 10; const double _columnGap = 8;
const double _cardGap = 10; const double _cardGap = 14;
const double _addSize = 28; const double _gridHorizontalPadding = 12;
const double _addIconSize = 18; 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 { class DramaGrid extends StatelessWidget {
@@ -32,7 +45,7 @@ class DramaGrid extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
key: const Key('home-all-dramas-section'), key: const Key('home-all-dramas-section'),
padding: const EdgeInsets.symmetric(horizontal: 14), padding: const EdgeInsets.symmetric(horizontal: _gridHorizontalPadding),
child: Row( child: Row(
key: const Key('home-all-dramas-grid'), key: const Key('home-all-dramas-grid'),
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -60,7 +73,7 @@ class DramaGrid extends StatelessWidget {
} }
} }
/// 双列网格中的剧目卡片。 /// 双列网格中的剧目卡片:海报 + 标题 + 题材标签。
class DramaGridCard extends StatelessWidget { class DramaGridCard extends StatelessWidget {
const DramaGridCard({ const DramaGridCard({
super.key, super.key,
@@ -73,103 +86,88 @@ class DramaGridCard extends StatelessWidget {
final Drama drama; final Drama drama;
final VoidCallback onTap; final VoidCallback onTap;
bool get _isFree =>
drama.freeEpisodes > 0 || drama.episodes.any((episode) => episode.isFree);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return GestureDetector(
decoration: BoxDecoration( key: Key('home-drama-card-${drama.id}'),
color: AppColors.card, behavior: HitTestBehavior.opaque,
borderRadius: BorderRadius.circular(AppDimens.cardRadius), onTap: onTap,
boxShadow: const [AppTheme.cardShadow], child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
child: Material( children: [
color: Colors.transparent, _cover(),
borderRadius: BorderRadius.circular(AppDimens.cardRadius), const SizedBox(height: _titleGap),
clipBehavior: Clip.antiAlias, _title(),
child: InkWell( const SizedBox(height: _tagsGap),
key: Key('home-drama-card-${drama.id}'), _tags(),
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(),
],
),
),
),
), ),
); );
} }
Widget _cover() { Widget _cover() {
final free =
drama.freeEpisodes > 0 ||
drama.episodes.any((episode) => episode.isFree);
return ClipRRect( return ClipRRect(
key: Key('home-drama-cover-${drama.id}'), key: Key('home-drama-cover-${drama.id}'),
borderRadius: BorderRadius.circular(AppDimens.coverRadius), borderRadius: BorderRadius.circular(_coverRadius),
child: Stack( child: AspectRatio(
children: [ aspectRatio: _coverAspectRatio,
_DiscoverCoverImage(path: drama.posterUrl), child: Stack(
Positioned(top: 8, left: 8, child: _tagBadge(free)), fit: StackFit.expand,
if (_lockedCount > 0) children: [
Positioned(top: 8, right: 8, child: _lockedBadge()), _DiscoverCoverImage(path: drama.posterUrl),
Positioned(right: 8, bottom: 8, child: _episodeBadge()), Positioned(top: 8, left: 8, child: _tagBadge()),
], Positioned(left: 8, bottom: 8, child: _heatBadge()),
), ],
);
}
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,
), ),
), ),
); );
} }
Widget _lockedBadge() { Widget _tagBadge() {
return Container( 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), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.overlayDark, color: _heatBackground,
borderRadius: BorderRadius.circular(AppDimens.pillRadius), borderRadius: BorderRadius.circular(AppDimens.pillRadius),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(width: 2),
Text( Text(
l10n.lockedEpisodeCount(_lockedCount), l10n.heatLabel(formatInteractionCount(drama.viewCount)),
style: const TextStyle( style: const TextStyle(
color: AppColors.onPrimary, color: AppColors.onPrimary,
fontSize: 9, fontSize: _heatFontSize,
fontWeight: FontWeight.w700, 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() { Widget _title() {
return Text( return Text(
drama.title, drama.title,
key: Key('home-drama-title-${drama.id}'), key: Key('home-drama-title-${drama.id}'),
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: AppDimens.cardTitle, fontSize: _titleFontSize,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w700,
height: 1.25,
), ),
); );
} }
Widget _genre() { Widget _tags() {
final genre = drama.genre.isEmpty ? drama.category : drama.genre; final tags = _tagValues();
return Text( if (tags.isEmpty) return const SizedBox.shrink();
genre.isEmpty ? l10n.videoPending : genre, return Wrap(
key: Key('home-drama-genre-${drama.id}'), spacing: 6,
maxLines: 1, runSpacing: 4,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: AppDimens.cardMeta,
),
);
}
Widget _footer() {
return Row(
children: [ children: [
Expanded(child: _genre()), for (var index = 0; index < tags.length; index++)
Container( Container(
key: Key('drama-add-${drama.id}'), key: Key('drama-tag-chip-${drama.id}-$index'),
width: _addSize, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
height: _addSize, decoration: BoxDecoration(
decoration: const BoxDecoration( color: _tagChipBackground,
color: AppColors.blue, borderRadius: BorderRadius.circular(_tagChipRadius),
shape: BoxShape.circle, ),
child: Text(
tags[index],
style: const TextStyle(
color: AppColors.muted,
fontSize: _tagChipFontSize,
),
),
), ),
child: const Icon(
Icons.add_rounded,
color: AppColors.onPrimary,
size: _addIconSize,
),
),
], ],
); );
} }
List<String> _tagValues() {
final result = <String>[];
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 { class _DiscoverCoverImage extends StatelessWidget {
@@ -259,7 +243,8 @@ class _DiscoverCoverImage extends StatelessWidget {
return Image.network( return Image.network(
url, url,
width: double.infinity, width: double.infinity,
fit: BoxFit.fitWidth, height: double.infinity,
fit: BoxFit.cover,
loadingBuilder: (context, child, progress) { loadingBuilder: (context, child, progress) {
if (progress == null) return child; if (progress == null) return child;
return _placeholder(); return _placeholder();
@@ -269,16 +254,13 @@ class _DiscoverCoverImage extends StatelessWidget {
} }
Widget _placeholder() { Widget _placeholder() {
return AspectRatio( return const ColoredBox(
aspectRatio: 3 / 4, color: AppColors.card,
child: ColoredBox( child: Center(
color: AppColors.card, child: Icon(
child: const Center( Icons.image_not_supported_outlined,
child: Icon( color: AppColors.muted,
Icons.image_not_supported_outlined, size: 32,
color: AppColors.muted,
size: 32,
),
), ),
), ),
); );
+51 -2
View File
@@ -34,9 +34,54 @@ void main() {
expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); expect(find.byKey(const Key('theater-category-chips')), findsOneWidget);
expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(find.byKey(const Key('theater-language-chips')), findsNothing);
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); 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); 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', ( testWidgets('should_filter_grid_when_category_chip_is_tapped', (
tester, tester,
) async { ) async {
@@ -73,7 +118,7 @@ void main() {
matching: find.byType(Scrollable), 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(); await tester.pumpAndSettle();
expect( expect(
@@ -129,7 +174,7 @@ void main() {
await _pumpTheater(tester, categoryRepository: counter); await _pumpTheater(tester, categoryRepository: counter);
final before = counter.categoryLoads + counter.feedLoads; 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();
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -139,6 +184,10 @@ void main() {
}); });
} }
final Finder _verticalFeed = find.byWidgetPredicate(
(widget) => widget is ListView && widget.scrollDirection == Axis.vertical,
);
Future<void> _pumpTheater( Future<void> _pumpTheater(
WidgetTester tester, { WidgetTester tester, {
required CategoryRepository categoryRepository, required CategoryRepository categoryRepository,
+1 -1
View File
@@ -215,7 +215,7 @@ void main() {
expect(find.byKey(const Key('theater-category-chips')), findsOneWidget); expect(find.byKey(const Key('theater-category-chips')), findsOneWidget);
expect(find.byKey(const Key('theater-language-chips')), findsNothing); expect(find.byKey(const Key('theater-language-chips')), findsNothing);
expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); 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); expect(tester.takeException(), isNull);
}); });