merge: 合并 K32-A 剧场页重做
This commit is contained in:
commit
edfef61ee5
13 files changed
+499
-258
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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": "新剧",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user