feat(flutter): 剧场页按参考图重做
This commit is contained in:
1 parent
2abb8c7032
commit
5a51af4264
13 files changed
+499
-258
No files matched your search
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user