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

+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,
),
),
);