feat(flutter): 剧场页按参考图重做

This commit is contained in:
Zhou Weidong committed 2026-10-20 13:24:21 +08:00
1 parent 2abb8c7032
commit 5a51af4264
13 files changed
+499 -258

No files matched your search

+8
View File
@@ -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",
+3
View File
@@ -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",
+18
View File
@@ -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:
+11
View File
@@ -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';
+11
View File
@@ -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';
+12 -1
View File
@@ -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 => '排行榜';
+4 -1
View File
@@ -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": "新剧",
+188 -51
View File
@@ -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<DiscoverPage> {
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<DiscoverPage> {
? 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<DiscoverPage> {
];
}
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<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) =>
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,
+10 -50
View File
@@ -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<String> 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,
),
),
);
@@ -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<String> 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),
),
],
),
),
),
),
);
}
}
+123 -141
View File
@@ -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<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 {
@@ -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,
),
),
);
+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-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<void> _pumpTheater(
WidgetTester tester, {
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-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);
});