This commit is contained in:
commit
78a33cd4f1
49 files changed
+3525
-845
No files matched your search
@@ -153,6 +153,10 @@ abstract final class AppColors {
|
||||
/// 列表底部「没有更多了」这类弱提示。
|
||||
static const textHint = Color(0xFF5C5C60);
|
||||
static const textTabInactive = Color(0xFFB0B0B0);
|
||||
|
||||
/// 首页顶部三项导航未选中文字,略暗于选中白色但保持清晰。
|
||||
static const homeTabInactive = Color(0xFFC7C7CC);
|
||||
|
||||
static const textFilterIcon = Color(0xFF7A7A7E);
|
||||
static const searchResultMeta = Color(0xFF9E9E9E);
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:固定顶部导航文字排版,以绘制缩放和颜色插值实现选中切换。
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
const double _inactiveScale = 0.9;
|
||||
|
||||
/// 保留最大字号、固定字重与占位,避免选中动画触发相邻标签重新布局。
|
||||
class AnimatedNavLabel extends StatelessWidget {
|
||||
/// [style] 指定选中态字号与颜色;字重固定,未指定时默认加粗。
|
||||
const AnimatedNavLabel({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.style,
|
||||
this.alignment = Alignment.bottomCenter,
|
||||
this.selectionProgress,
|
||||
this.inactiveColor = AppColors.textTabInactive,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final TextStyle style;
|
||||
final Alignment alignment;
|
||||
|
||||
/// 未选中文字颜色;默认保持通用导航原有灰色。
|
||||
final Color inactiveColor;
|
||||
|
||||
/// 页面拖动时直接使用连续进度,避免再叠加一层延迟动画。
|
||||
final double? selectionProgress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final target = selected ? 1.0 : 0.0;
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
return Semantics(
|
||||
selected: selected,
|
||||
child: selectionProgress != null
|
||||
? _label(selectionProgress!.clamp(0.0, 1.0))
|
||||
: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: target, end: target),
|
||||
duration: reduceMotion
|
||||
? Duration.zero
|
||||
: AppDimens.homePrimaryTabAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
builder: (context, progress, _) => _label(progress),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _label(double progress) => Transform.scale(
|
||||
scale: _inactiveScale + (1 - _inactiveScale) * progress,
|
||||
alignment: alignment,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.left,
|
||||
style: style.copyWith(
|
||||
fontWeight: style.fontWeight ?? FontWeight.w700,
|
||||
letterSpacing: 0,
|
||||
color: Color.lerp(
|
||||
inactiveColor,
|
||||
style.color ?? AppColors.textPrimary,
|
||||
progress,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -25,14 +25,14 @@ extension AppRootTabUi on AppRootTab {
|
||||
|
||||
IconData get icon => switch (this) {
|
||||
AppRootTab.home => Icons.home_outlined,
|
||||
AppRootTab.recommend => Icons.celebration_outlined,
|
||||
AppRootTab.recommend => Icons.smart_display_outlined,
|
||||
AppRootTab.following => Icons.bookmark_border_rounded,
|
||||
AppRootTab.mine => Icons.person_outline_rounded,
|
||||
};
|
||||
|
||||
IconData get selectedIcon => switch (this) {
|
||||
AppRootTab.home => Icons.home_rounded,
|
||||
AppRootTab.recommend => Icons.celebration_rounded,
|
||||
AppRootTab.recommend => Icons.smart_display_rounded,
|
||||
AppRootTab.following => Icons.bookmark_rounded,
|
||||
AppRootTab.mine => Icons.person_rounded,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:给默认图片加载占位叠加可暂停、局部重绘的轻量扫光。
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const _sweepDuration = Duration(milliseconds: 1400);
|
||||
const double _bandWidthRatio = 0.65;
|
||||
const _highlight = Color(0x12FFFFFF);
|
||||
const _clear = Color(0x00FFFFFF);
|
||||
|
||||
/// 只用于等待真实图片的占位;减少动画或页面离屏时不运行扫光。
|
||||
class ImagePlaceholderShimmer extends StatefulWidget {
|
||||
/// 在 [child] 上方绘制不影响布局与点击区域的扫光。
|
||||
const ImagePlaceholderShimmer({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<ImagePlaceholderShimmer> createState() =>
|
||||
_ImagePlaceholderShimmerState();
|
||||
}
|
||||
|
||||
class _ImagePlaceholderShimmerState extends State<ImagePlaceholderShimmer>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: _sweepDuration,
|
||||
);
|
||||
bool _active = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_active =
|
||||
TickerMode.valuesOf(context).enabled &&
|
||||
!MediaQuery.disableAnimationsOf(context);
|
||||
if (_active) {
|
||||
_controller.repeat();
|
||||
} else {
|
||||
_controller.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_active) return widget.child;
|
||||
return RepaintBoundary(
|
||||
child: ClipRect(
|
||||
child: CustomPaint(
|
||||
foregroundPainter: _SweepPainter(_controller),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SweepPainter extends CustomPainter {
|
||||
_SweepPainter(this.progress) : super(repaint: progress);
|
||||
|
||||
final Animation<double> progress;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (size.isEmpty) return;
|
||||
final bandWidth = size.width * _bandWidthRatio;
|
||||
final left = -bandWidth + (size.width + bandWidth) * progress.value;
|
||||
final rect = Rect.fromLTWH(left, 0, bandWidth, size.height);
|
||||
final paint = Paint()
|
||||
..shader = const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [_clear, _highlight, _clear],
|
||||
).createShader(rect);
|
||||
canvas.drawRect(rect, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _SweepPainter oldDelegate) =>
|
||||
oldDelegate.progress != progress;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'image_placeholder_shimmer.dart';
|
||||
|
||||
const int _minImageDecodePixels = 96;
|
||||
const int _maxImageDecodePixels = 2048;
|
||||
@@ -102,13 +103,19 @@ class RemoteImage extends StatelessWidget {
|
||||
errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon),
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return _fallback(brand: showFallbackIcon);
|
||||
return _loadingFallback();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _loadingFallback() {
|
||||
final custom = fallbackWidget;
|
||||
if (custom != null) return custom;
|
||||
return ImagePlaceholderShimmer(child: _fallback(brand: showFallbackIcon));
|
||||
}
|
||||
|
||||
Widget _fallback({required bool brand}) {
|
||||
final custom = fallbackWidget;
|
||||
if (custom != null) return custom;
|
||||
|
||||
+1
-1
@@ -761,7 +761,7 @@
|
||||
"homeBadgeNew": "New",
|
||||
"homeBadgeAnime": "Anime",
|
||||
"emptyContent": "No content yet",
|
||||
"recommendTopTab": "Recommend",
|
||||
"recommendTopTab": "Featured",
|
||||
"playlistTopTab": "Playlists",
|
||||
"allDramasTopTab": "All Dramas",
|
||||
"weeklyHitsTitle": "This Week's Hits",
|
||||
|
||||
+1
-1
@@ -723,7 +723,7 @@
|
||||
"homeBadgeNew": "Baru",
|
||||
"homeBadgeAnime": "Anime",
|
||||
"emptyContent": "Belum ada konten",
|
||||
"recommendTopTab": "Rekomendasi",
|
||||
"recommendTopTab": "Unggulan",
|
||||
"playlistTopTab": "Daftar Putar",
|
||||
"allDramasTopTab": "Semua",
|
||||
"weeklyHitsTitle": "Hits Minggu Ini",
|
||||
|
||||
@@ -3271,7 +3271,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @recommendTopTab.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Recommend'**
|
||||
/// **'Featured'**
|
||||
String get recommendTopTab;
|
||||
|
||||
/// No description provided for @playlistTopTab.
|
||||
|
||||
@@ -1711,7 +1711,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get emptyContent => 'No content yet';
|
||||
|
||||
@override
|
||||
String get recommendTopTab => 'Recommend';
|
||||
String get recommendTopTab => 'Featured';
|
||||
|
||||
@override
|
||||
String get playlistTopTab => 'Playlists';
|
||||
|
||||
@@ -1711,7 +1711,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get emptyContent => 'Belum ada konten';
|
||||
|
||||
@override
|
||||
String get recommendTopTab => 'Rekomendasi';
|
||||
String get recommendTopTab => 'Unggulan';
|
||||
|
||||
@override
|
||||
String get playlistTopTab => 'Daftar Putar';
|
||||
|
||||
@@ -1686,7 +1686,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get emptyContent => '暂无内容';
|
||||
|
||||
@override
|
||||
String get recommendTopTab => '推荐';
|
||||
String get recommendTopTab => '精选';
|
||||
|
||||
@override
|
||||
String get playlistTopTab => '剧单';
|
||||
|
||||
+1
-1
@@ -685,7 +685,7 @@
|
||||
"homeBadgeNew": "新剧",
|
||||
"homeBadgeAnime": "漫剧",
|
||||
"emptyContent": "暂无内容",
|
||||
"recommendTopTab": "推荐",
|
||||
"recommendTopTab": "精选",
|
||||
"playlistTopTab": "剧单",
|
||||
"allDramasTopTab": "全部短剧",
|
||||
"weeklyHitsTitle": "本週爆款推薦",
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:追剧收藏网格、分页和编辑选择,不承载顶部续播或重复区块标题。
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/watch_record.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/data/services/watch_records_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../player/open_player.dart';
|
||||
import 'following_grid_card.dart';
|
||||
import 'following_paged_grid.dart';
|
||||
import 'widgets/continue_watching_banner.dart';
|
||||
|
||||
/// 追剧编辑中的选中状态。
|
||||
class FollowingFavoriteSelection {
|
||||
@@ -36,19 +32,15 @@ class FollowingFavoritesGrid extends StatelessWidget {
|
||||
const FollowingFavoritesGrid({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.history,
|
||||
required this.selection,
|
||||
required this.scrollController,
|
||||
required this.onGoHome,
|
||||
required this.onContinueRecord,
|
||||
});
|
||||
|
||||
final FavoriteController controller;
|
||||
final WatchRecordsController history;
|
||||
final FollowingFavoriteSelection selection;
|
||||
final ScrollController scrollController;
|
||||
final VoidCallback onGoHome;
|
||||
final ValueChanged<WatchRecord> onContinueRecord;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -59,7 +51,7 @@ class FollowingFavoritesGrid extends StatelessWidget {
|
||||
valueListenable: selection.selected,
|
||||
builder: (context, picked, _) {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([controller, history]),
|
||||
listenable: controller,
|
||||
builder: (context, _) => _grid(context, isEditing, picked),
|
||||
);
|
||||
},
|
||||
@@ -89,7 +81,6 @@ class FollowingFavoritesGrid extends StatelessWidget {
|
||||
onRefresh: controller.load,
|
||||
onLoadMore: controller.loadMore,
|
||||
itemCount: controller.cards.length + extra,
|
||||
topSlivers: _topSlivers(context),
|
||||
empty: EmptyState(
|
||||
key: const Key('following-favorites-empty'),
|
||||
icon: Icons.bookmark_border_rounded,
|
||||
@@ -99,68 +90,6 @@ class FollowingFavoritesGrid extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _topSlivers(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final continueRecord = _continueRecord();
|
||||
return [
|
||||
if (continueRecord != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppDimens.feedPageHorizontal,
|
||||
4,
|
||||
AppDimens.feedPageHorizontal,
|
||||
0,
|
||||
),
|
||||
child: ContinueWatchingBanner(
|
||||
record: continueRecord,
|
||||
languageCode: Localizations.localeOf(context).languageCode,
|
||||
onTap: () => onContinueRecord(continueRecord),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppDimens.feedPageHorizontal,
|
||||
AppDimens.followingSectionTopGap,
|
||||
AppDimens.feedPageHorizontal,
|
||||
AppDimens.followingSectionBottomGap,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
l10n.favoriteDramas,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.followingSectionTitleFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
l10n.totalDramasCount(controller.total),
|
||||
style: const TextStyle(
|
||||
color: AppColors.textTabInactive,
|
||||
fontSize: AppDimens.followingSectionCountFont,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// 最近一条观看记录(records 已按最近观看排序,与观看历史页首条一致)。
|
||||
WatchRecord? _continueRecord() {
|
||||
return history.records.isEmpty ? null : history.records.first;
|
||||
}
|
||||
|
||||
Widget _item(
|
||||
BuildContext context,
|
||||
int index,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:追剧收藏与观看历史切换、数据刷新、管理和续播入口。
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
@@ -39,7 +40,7 @@ class FollowingPage extends StatefulWidget {
|
||||
final FavoriteController favoriteController;
|
||||
final int historyRequest;
|
||||
|
||||
/// 当前是否为首页选中的 tab;重新可见时刷新观看历史,保证横幅是最新进度。
|
||||
/// 当前是否为首页选中的 tab;重新可见时刷新观看历史,保证记录是最新进度。
|
||||
final bool visible;
|
||||
|
||||
@override
|
||||
@@ -157,7 +158,6 @@ class _FollowingPageState extends State<FollowingPage> {
|
||||
FollowingKeepAliveTab(
|
||||
child: FollowingFavoritesGrid(
|
||||
controller: widget.favoriteController,
|
||||
history: _history,
|
||||
selection: FollowingFavoriteSelection(
|
||||
editing: _editing,
|
||||
selected: _selected,
|
||||
@@ -165,9 +165,6 @@ class _FollowingPageState extends State<FollowingPage> {
|
||||
),
|
||||
scrollController: _favoriteScroll,
|
||||
onGoHome: widget.onGoHome,
|
||||
onContinueRecord: (record) => unawaited(
|
||||
_openHistory(context, _history, record),
|
||||
),
|
||||
),
|
||||
),
|
||||
FollowingKeepAliveTab(
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../../core/formatting/count_format.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/animated_nav_label.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
enum HomePrimaryTab {
|
||||
@@ -323,21 +324,16 @@ class _CategoryBarState extends State<_CategoryBar> {
|
||||
height: AppDimens.homePrimaryRowHeight,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: AnimatedDefaultTextStyle(
|
||||
child: AnimatedNavLabel(
|
||||
key: Key('home-primary-label-${category.categoryId}'),
|
||||
duration: AppDimens.homePrimaryTabAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
style: TextStyle(
|
||||
color: selected
|
||||
? AppColors.navSelected
|
||||
: AppColors.textTabInactive,
|
||||
fontSize: selected
|
||||
? AppDimens.homePrimaryTabSelectedFont
|
||||
: AppDimens.homePrimaryTabFont,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
||||
label: category.label,
|
||||
selected: selected,
|
||||
alignment: Alignment.bottomLeft,
|
||||
style: const TextStyle(
|
||||
color: AppColors.navSelected,
|
||||
fontSize: AppDimens.homePrimaryTabSelectedFont,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
child: Text(category.label, textAlign: TextAlign.left),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:推荐流与播放页共用的简介样式、固定切换入口与高度过渡。
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const double _synopsisFont = 15;
|
||||
const double _synopsisLineHeight = 1.4;
|
||||
const int _collapsedSynopsisLines = 2;
|
||||
const double _synopsisToggleWidthFraction = 0.55;
|
||||
const double _synopsisToggleGap = 6;
|
||||
const _synopsisMotion = Duration(milliseconds: 240);
|
||||
const _synopsisStyle = TextStyle(
|
||||
color: AppColors.overlaySecondary,
|
||||
fontSize: _synopsisFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: _synopsisLineHeight,
|
||||
);
|
||||
const _synopsisToggleStyle = TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _synopsisFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: _synopsisLineHeight,
|
||||
);
|
||||
|
||||
/// 两行折叠的简介,展开与收起入口在右下共用同一占位。
|
||||
class ExpandableSynopsis extends StatefulWidget {
|
||||
/// [keyPrefix] 区分页内标识,[expandedFooter] 仅在展开时展示。
|
||||
const ExpandableSynopsis({
|
||||
super.key,
|
||||
required this.synopsis,
|
||||
this.keyPrefix = 'home-feed',
|
||||
this.expandedFooter,
|
||||
});
|
||||
|
||||
/// 按当前语言获取的简介正文。
|
||||
final String synopsis;
|
||||
|
||||
/// 当前页面的组件标识前缀。
|
||||
final String keyPrefix;
|
||||
|
||||
/// 展开后附加的声明等内容。
|
||||
final Widget? expandedFooter;
|
||||
|
||||
@override
|
||||
State<ExpandableSynopsis> createState() => _ExpandableSynopsisState();
|
||||
}
|
||||
|
||||
class _ExpandableSynopsisState extends State<ExpandableSynopsis> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ExpandableSynopsis oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.synopsis != widget.synopsis) _expanded = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.synopsis.trim().isEmpty) return const SizedBox.shrink();
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (!_needsToggle(constraints.maxWidth)) return _body();
|
||||
return Row(
|
||||
key: _key('layout'),
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _toggle,
|
||||
child: _animatedBody(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _synopsisToggleGap),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth * _synopsisToggleWidthFraction,
|
||||
),
|
||||
child: IntrinsicWidth(child: _toggleButton(l10n)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _needsToggle(double maxWidth) {
|
||||
final painter = _painter(widget.synopsis, _synopsisStyle);
|
||||
painter.layout(maxWidth: maxWidth);
|
||||
final overflows = painter.didExceedMaxLines;
|
||||
painter.dispose();
|
||||
return overflows;
|
||||
}
|
||||
|
||||
TextPainter _painter(String text, TextStyle style) => TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: DefaultTextStyle.of(context).style.merge(style),
|
||||
),
|
||||
maxLines: _collapsedSynopsisLines,
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
locale: Localizations.localeOf(context),
|
||||
textHeightBehavior: DefaultTextStyle.of(context).textHeightBehavior,
|
||||
);
|
||||
|
||||
Widget _animatedBody() {
|
||||
if (MediaQuery.disableAnimationsOf(context)) return _body();
|
||||
return AnimatedSize(
|
||||
key: _key('size'),
|
||||
duration: _synopsisMotion,
|
||||
curve: Curves.easeInOutCubic,
|
||||
alignment: Alignment.topLeft,
|
||||
child: _body(),
|
||||
);
|
||||
}
|
||||
|
||||
Key _key(String suffix) => Key('${widget.keyPrefix}-synopsis-$suffix');
|
||||
|
||||
Widget _body() {
|
||||
final text = Text.rich(
|
||||
TextSpan(text: widget.synopsis, style: _synopsisStyle),
|
||||
key: _expanded ? _key('expanded') : Key('${widget.keyPrefix}-synopsis'),
|
||||
style: _synopsisStyle,
|
||||
maxLines: _expanded ? null : _collapsedSynopsisLines,
|
||||
overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis,
|
||||
);
|
||||
if (!_expanded || widget.expandedFooter == null) return text;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [text, widget.expandedFooter!],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _toggleButton(AppLocalizations l10n) => Semantics(
|
||||
button: true,
|
||||
expanded: _expanded,
|
||||
child: GestureDetector(
|
||||
key: _key('more'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _toggle,
|
||||
child: IndexedStack(
|
||||
index: _expanded ? 1 : 0,
|
||||
alignment: Alignment.bottomRight,
|
||||
children: [
|
||||
_toggleLabel(l10n.expandSynopsis),
|
||||
_toggleLabel(l10n.showLess),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _toggleLabel(String label) => Text(
|
||||
label,
|
||||
maxLines: _collapsedSynopsisLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: _synopsisToggleStyle,
|
||||
);
|
||||
|
||||
void _toggle() => setState(() => _expanded = !_expanded);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:推荐流纯文字标签与热搜入口,处理横向滚动和长文案。
|
||||
part of 'home_feed_recommend_panel.dart';
|
||||
|
||||
const double _tagGap = 6;
|
||||
const double _searchFillAlpha = 0.1;
|
||||
const double _searchInset = 10;
|
||||
const double _searchVertical = 4;
|
||||
const double _searchIcon = 14;
|
||||
const double _searchIconGap = 4;
|
||||
const double _searchRadius = 6;
|
||||
|
||||
class _TagStrip extends StatelessWidget {
|
||||
const _TagStrip({required this.chips});
|
||||
|
||||
final List<Widget> chips;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
key: const Key('home-feed-tags'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
for (var index = 0; index < chips.length; index++) ...[
|
||||
if (index > 0) const SizedBox(width: _tagGap),
|
||||
chips[index],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 热搜入口保留点击搜索,长语言文案省略而不挤占信息区。
|
||||
class _HotSearchPill extends StatelessWidget {
|
||||
const _HotSearchPill({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
key: const Key('home-feed-hot-search-pill'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _searchInset,
|
||||
vertical: _searchVertical,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textPrimary.withValues(alpha: _searchFillAlpha),
|
||||
borderRadius: BorderRadius.circular(_searchRadius),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.search_rounded,
|
||||
size: _searchIcon,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: _searchIconGap),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: _tagFont,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: _searchIconGap),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: _searchIcon,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _TagChip extends StatelessWidget {
|
||||
const _TagChip({super.key, required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
strutStyle: _metadataStrutStyle,
|
||||
style: const TextStyle(
|
||||
color: AppColors.overlaySecondary,
|
||||
fontSize: _tagFont,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: _metadataLineHeight,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:推荐视频浮层的底部信息、互动栏、进度条与手势协调。
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
@@ -27,10 +28,13 @@ const double _progressHitHeight = 16;
|
||||
const double _pageHorizontal = 20;
|
||||
const double _railRight = 14;
|
||||
const double _railGap = 12;
|
||||
const double _railWidth = 56;
|
||||
const double _infoWatchAllGap = 2;
|
||||
// 进度线贴着屏幕底部(线心距底 4pt),观看条底边只留 8pt,视觉上就是线上一根条。
|
||||
const double _watchAllBottomInset = 8;
|
||||
// 信息面板底边直接贴住观看条顶沿,中间不留缝。
|
||||
const double _bottomInfoInset = _watchAllBottomInset + homeFeedWatchAllHeight;
|
||||
// 简介与观看条留出明确间距,展开后仍保留相同的底部留白。
|
||||
const double _bottomInfoInset =
|
||||
_watchAllBottomInset + homeFeedWatchAllHeight + _infoWatchAllGap;
|
||||
const double _railBottomOffset = 40;
|
||||
const Duration _overlayFadeDuration = Duration(milliseconds: 120);
|
||||
const List<Shadow> _boostTextShadows = [
|
||||
@@ -169,7 +173,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
// 否则拖拽 seek 的起点会被观看条吞掉。两块的可见区域(文字/进度线)互不遮挡。
|
||||
Positioned(
|
||||
left: _pageHorizontal,
|
||||
right: _pageHorizontal,
|
||||
right: _railRight + _railGap + _railWidth,
|
||||
bottom: _watchAllBottomInset,
|
||||
child: AnimatedOpacity(
|
||||
duration: _overlayFadeDuration,
|
||||
@@ -224,9 +228,14 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
children: [
|
||||
Expanded(child: _bottomInfo()),
|
||||
const SizedBox(width: _railGap),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: _railBottomOffset),
|
||||
child: _rail(),
|
||||
SizedBox(
|
||||
width: _railWidth,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: _railBottomOffset,
|
||||
),
|
||||
child: _rail(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,32 +1,47 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:推荐视频底部标题、标签、集数、简介与观看全集入口。
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/models/locale_text.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../home/widgets/home_drama_classification.dart';
|
||||
import 'expandable_synopsis.dart';
|
||||
import 'interaction_rail.dart';
|
||||
|
||||
const double _seriesFont = 17;
|
||||
const double _tagFont = 12;
|
||||
const double _synopsisFont = 14;
|
||||
part 'home_feed_info_tags.dart';
|
||||
|
||||
const double _seriesFont = 18;
|
||||
const double _tagFont = 13;
|
||||
const double _watchAllHeight = 38;
|
||||
const double _titleMetadataGap = 2;
|
||||
const double _metadataSynopsisGap = 2;
|
||||
const double _hotSearchBottomGap = 2;
|
||||
const double _metadataGap = 8;
|
||||
const int _maximumTags = 2;
|
||||
const double _episodeFont = 14;
|
||||
const double _episodeWidthFraction = 0.55;
|
||||
const double _metadataLineHeight = 1.4;
|
||||
const _metadataStrutStyle = StrutStyle(
|
||||
fontSize: _episodeFont,
|
||||
height: _metadataLineHeight,
|
||||
forceStrutHeight: true,
|
||||
);
|
||||
|
||||
/// 观看条内部左右留白与圆角:外沿由 overlay 按页面边距对齐,内部不再重复内缩。
|
||||
const double _watchAllInset = 12;
|
||||
const double _watchAllRadius = 12;
|
||||
const double _watchAllRadius = 6;
|
||||
const double _watchAllFont = 16;
|
||||
const _watchAllGradient = LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [AppColors.mineStatShade, AppColors.brandRed, AppColors.vipButtonEnd],
|
||||
);
|
||||
|
||||
/// 标题、标签、简介之间的行间距:底部信息本来就挤,留 4pt 够分层。
|
||||
const double _blockGap = 4;
|
||||
const List<Shadow> _textShadows = [
|
||||
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
|
||||
];
|
||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||
|
||||
/// 通栏「观看完整剧集」半透明白底条高度,overlay 用它安排底部避让。
|
||||
/// 「观看完整剧集」渐变按钮高度,overlay 用它安排底部避让。
|
||||
const double homeFeedWatchAllHeight = _watchAllHeight;
|
||||
|
||||
/// 播放量紧凑展示(跟随语言):zh 20万 / en 200K / id 200 rb。
|
||||
@@ -56,15 +71,13 @@ class HomeFeedInfoPanel extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
bool _synopsisExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final dramaTitle = localizedDramaTitle(l10n, widget.drama);
|
||||
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
|
||||
final genres = _genreValues(Localizations.localeOf(context).languageCode);
|
||||
final tags = _tagChips(l10n, genres);
|
||||
final types = _typeLabels(Localizations.localeOf(context).languageCode);
|
||||
final tags = _tagChips(types);
|
||||
return Column(
|
||||
key: const Key('home-feed-info-panel'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -80,306 +93,108 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
),
|
||||
onTap: widget.onOpenSearch!,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
GestureDetector(
|
||||
key: const Key('home-feed-series-name'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onOpenFullPlayer,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
dramaTitle,
|
||||
key: const Key('home-feed-series-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _seriesFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
shadows: _textShadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 18,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 没有标签就整行跳过,不给标题和简介之间留空档。
|
||||
if (tags.isNotEmpty) ...[
|
||||
const SizedBox(height: _blockGap),
|
||||
_tagRow(tags),
|
||||
const SizedBox(height: _hotSearchBottomGap),
|
||||
],
|
||||
_title(dramaTitle),
|
||||
const SizedBox(height: _titleMetadataGap),
|
||||
_metadata(l10n, tags),
|
||||
if (synopsis.isNotEmpty) ...[
|
||||
const SizedBox(height: _blockGap),
|
||||
_synopsis(l10n, synopsis),
|
||||
const SizedBox(height: _metadataSynopsisGap),
|
||||
ExpandableSynopsis(
|
||||
key: ValueKey(widget.drama.id),
|
||||
synopsis: synopsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _tagChips(AppLocalizations l10n, List<String> genres) {
|
||||
return [
|
||||
if (isHotDrama(widget.drama))
|
||||
_TagChip(
|
||||
key: const Key('home-feed-hot-badge'),
|
||||
label: l10n.homeBadgeHot,
|
||||
filled: true,
|
||||
Widget _title(String dramaTitle) => GestureDetector(
|
||||
key: const Key('home-feed-series-name'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onOpenFullPlayer,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
dramaTitle,
|
||||
key: const Key('home-feed-series-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _seriesFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final genre in genres)
|
||||
_TagChip(key: Key('home-feed-tag-$genre'), label: genre),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 18,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
List<Widget> _tagChips(List<String> types) {
|
||||
return [
|
||||
for (final type in types)
|
||||
_TagChip(key: Key('home-feed-tag-$type'), label: type),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _tagRow(List<Widget> chips) {
|
||||
return SizedBox(
|
||||
key: const Key('home-feed-tags'),
|
||||
height: 22,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
itemCount: chips.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 6),
|
||||
itemBuilder: (_, index) => chips[index],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _synopsis(AppLocalizations l10n, String synopsis) {
|
||||
final prefix = l10n.feedEpisodePrefix(widget.episode.number);
|
||||
final baseStyle = const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _synopsisFont,
|
||||
height: 1.3,
|
||||
shadows: _textShadows,
|
||||
);
|
||||
final span = TextSpan(
|
||||
children: [
|
||||
TextSpan(text: prefix, style: baseStyle),
|
||||
TextSpan(
|
||||
text: ' | ',
|
||||
style: baseStyle.copyWith(
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.45),
|
||||
fontSize: _synopsisFont - 2,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
TextSpan(text: synopsis, style: baseStyle),
|
||||
],
|
||||
);
|
||||
if (_synopsisExpanded) {
|
||||
return _expandedSynopsis(span, baseStyle, l10n);
|
||||
}
|
||||
Widget _metadata(AppLocalizations l10n, List<Widget> tags) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final toggle = _synopsisToggle(
|
||||
key: const Key('home-feed-synopsis-more'),
|
||||
label: l10n.expandSynopsis,
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
);
|
||||
final painter = TextPainter(
|
||||
text: span,
|
||||
final episode = Text(
|
||||
widget.drama.episodeCount > 0
|
||||
? l10n.episodeOfTotal(
|
||||
widget.episode.number,
|
||||
widget.drama.episodeCount,
|
||||
)
|
||||
: l10n.feedEpisodePrefix(widget.episode.number),
|
||||
key: const Key('home-feed-episode-meta'),
|
||||
maxLines: 1,
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
)..layout(maxWidth: constraints.maxWidth);
|
||||
if (!painter.didExceedMaxLines) {
|
||||
return Text.rich(
|
||||
span,
|
||||
key: const Key('home-feed-synopsis'),
|
||||
maxLines: 1,
|
||||
style: baseStyle,
|
||||
);
|
||||
}
|
||||
// 设计稿:单行省略号 + 行内固定「展开」。
|
||||
// 整行可点:真机上不必精确命中两个小字,点简介任意位置都能展开。
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _synopsisExpanded = true),
|
||||
child: Row(
|
||||
key: const Key('home-feed-synopsis-collapsed'),
|
||||
// 顶对齐:文字位置不变,toggle 的 bottom 留白把触控区向下扩到空白处。
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
span,
|
||||
key: const Key('home-feed-synopsis'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: baseStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
toggle,
|
||||
],
|
||||
overflow: TextOverflow.ellipsis,
|
||||
strutStyle: _metadataStrutStyle,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _episodeFont,
|
||||
height: _metadataLineHeight,
|
||||
),
|
||||
);
|
||||
return Row(
|
||||
key: const Key('home-feed-metadata'),
|
||||
children: [
|
||||
if (tags.isNotEmpty) ...[
|
||||
Flexible(child: _TagStrip(chips: tags)),
|
||||
const SizedBox(width: _metadataGap),
|
||||
],
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: tags.isEmpty
|
||||
? constraints.maxWidth
|
||||
: constraints.maxWidth * _episodeWidthFraction,
|
||||
),
|
||||
child: episode,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _expandedSynopsis(
|
||||
InlineSpan span,
|
||||
TextStyle style,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
// 「收起」内联在简介最后一行末尾,避免独占一行导致多行简介与进度条间
|
||||
// 出现大片空白;垂直方向只留少量触控留白(仅撑高最后一行)。
|
||||
final expandedSpan = TextSpan(
|
||||
style: style,
|
||||
children: [
|
||||
span,
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: _synopsisToggle(
|
||||
key: const Key('home-feed-synopsis-more'),
|
||||
label: l10n.showLess,
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return Text.rich(
|
||||
expandedSpan,
|
||||
key: const Key('home-feed-synopsis-expanded'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _synopsisToggle({
|
||||
required Key key,
|
||||
required String label,
|
||||
required EdgeInsets padding,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
key: key,
|
||||
// opaque + 留白扩大命中区;留白方向由调用方决定,
|
||||
// 展开态不能加 top padding,否则「收起」距简介正文过远。
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded),
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _synopsisFont,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: _textShadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _genreValues(String languageCode) {
|
||||
final localized = widget.drama.localizedTypeLabels(languageCode);
|
||||
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
|
||||
final result = <String>[];
|
||||
for (final value in [widget.drama.genre, widget.drama.category]) {
|
||||
for (final part in value.split(_tagSplitter)) {
|
||||
final tag = part.trim();
|
||||
if (tag.isEmpty || result.contains(tag)) continue;
|
||||
result.add(tag);
|
||||
}
|
||||
}
|
||||
return result.take(3).toList(growable: false);
|
||||
}
|
||||
List<String> _typeLabels(String languageCode) => widget.drama.types
|
||||
.take(_maximumTags)
|
||||
.map((type) => localizedText(languageCode, '', type).trim())
|
||||
.where((label) => label.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// 热搜榜降级胶囊:🔍 共 xxx 人在看 ›(暂无榜单接口,点击跳搜索页)。
|
||||
class _HotSearchPill extends StatelessWidget {
|
||||
const _HotSearchPill({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: const Key('home-feed-hot-search-pill'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.search_rounded,
|
||||
size: 15,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 16,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 热剧红胶囊 / 类型深色半透明填充胶囊。
|
||||
class _TagChip extends StatelessWidget {
|
||||
const _TagChip({super.key, required this.label, this.filled = false});
|
||||
|
||||
final String label;
|
||||
final bool filled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: filled
|
||||
? AppColors.brandRed
|
||||
: AppColors.textPrimary.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _tagFont,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
shadows: _textShadows,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部「观看完整剧集」条:浮在底部,不再铺满整屏宽度。
|
||||
/// 左侧「观看全集」条:与信息区等宽,居中文字避免与右侧互动栏争抢。
|
||||
class HomeFeedWatchAllBar extends StatelessWidget {
|
||||
const HomeFeedWatchAllBar({
|
||||
super.key,
|
||||
@@ -395,49 +210,43 @@ class HomeFeedWatchAllBar extends StatelessWidget {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(_watchAllRadius),
|
||||
child: Material(
|
||||
key: const Key('home-feed-full-series-bg'),
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.15),
|
||||
child: InkWell(
|
||||
key: const Key('home-feed-full-series'),
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: _watchAllHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _watchAllInset),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.movie_rounded,
|
||||
size: 22,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.watchFullSeries('${drama.episodeCount}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 22,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: DecoratedBox(
|
||||
key: const Key('home-feed-full-series-gradient'),
|
||||
decoration: const BoxDecoration(gradient: _watchAllGradient),
|
||||
child: Material(
|
||||
key: const Key('home-feed-full-series-bg'),
|
||||
color: AppColors.transparent,
|
||||
child: InkWell(
|
||||
key: const Key('home-feed-full-series'),
|
||||
onTap: onTap,
|
||||
child: _content(l10n),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _content(AppLocalizations l10n) => SizedBox(
|
||||
width: double.infinity,
|
||||
height: _watchAllHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _watchAllInset),
|
||||
child: Center(
|
||||
child: Text(
|
||||
l10n.homeWatchAll,
|
||||
semanticsLabel: l10n.watchFullSeries('${drama.episodeCount}'),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _watchAllFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class HomeFeedActionRail extends StatelessWidget {
|
||||
@@ -468,7 +277,7 @@ class HomeFeedActionRail extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return InteractionRail(
|
||||
itemGap: 12,
|
||||
itemGap: interactionRailCompactGap,
|
||||
buttonSize: 48,
|
||||
iconSize: 36,
|
||||
countFontSize: 15,
|
||||
@@ -481,6 +290,7 @@ class HomeFeedActionRail extends StatelessWidget {
|
||||
count: favoriteCount,
|
||||
countKey: const Key('rail-save-count'),
|
||||
selected: saved,
|
||||
animateTap: true,
|
||||
selectedColor: AppColors.brandRed,
|
||||
onTap: onSave,
|
||||
),
|
||||
@@ -499,6 +309,7 @@ class HomeFeedActionRail extends StatelessWidget {
|
||||
count: likeCount,
|
||||
countKey: const Key('rail-like-count'),
|
||||
selected: liked,
|
||||
animateTap: true,
|
||||
selectedColor: AppColors.brandRed,
|
||||
onTap: onLike,
|
||||
),
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:播放互动栏的图标、计数与不影响触控区域的点击反馈。
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/formatting/count_format.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import 'interaction_rail_burst.dart';
|
||||
|
||||
const double _defaultButtonSize = 44;
|
||||
const double _defaultIconSize = 32;
|
||||
const double _defaultCountFont = 12;
|
||||
const double _defaultIconCountGap = 2;
|
||||
const double _defaultItemGap = 24;
|
||||
const _tapDuration = Duration(milliseconds: 360);
|
||||
const double _popScale = 1.34;
|
||||
const double _cancelScale = 0.68;
|
||||
const double _popWeight = 40;
|
||||
const double _settleWeight = 60;
|
||||
final _growTween = _tapTween(_popScale);
|
||||
final _shrinkTween = _tapTween(_cancelScale);
|
||||
|
||||
TweenSequence<double> _tapTween(double target) => TweenSequence<double>([
|
||||
TweenSequenceItem(
|
||||
tween: Tween(
|
||||
begin: 1.0,
|
||||
end: target,
|
||||
).chain(CurveTween(curve: Curves.easeOutCubic)),
|
||||
weight: _popWeight,
|
||||
),
|
||||
TweenSequenceItem(
|
||||
tween: Tween(
|
||||
begin: target,
|
||||
end: 1.0,
|
||||
).chain(CurveTween(curve: Curves.easeOutCubic)),
|
||||
weight: _settleWeight,
|
||||
),
|
||||
]);
|
||||
|
||||
/// 首页与完整播放器互动项之间的紧凑间距。
|
||||
const double interactionRailCompactGap = 6;
|
||||
|
||||
/// 互动栏单项:图标、计数与点击。
|
||||
class InteractionRailItem {
|
||||
@@ -24,6 +53,7 @@ class InteractionRailItem {
|
||||
this.selected = false,
|
||||
this.selectedColor = AppColors.blue,
|
||||
this.mirrorIcon = false,
|
||||
this.animateTap = false,
|
||||
}) : assert((asset == null) != (icon == null));
|
||||
|
||||
final Key key;
|
||||
@@ -38,6 +68,9 @@ class InteractionRailItem {
|
||||
|
||||
/// 水平镜像图标(分享弯箭头用 reply 镜像得到)。
|
||||
final bool mirrorIcon;
|
||||
|
||||
/// 选中放大、取消缩小并显示周围纹路,计数和触控范围不参与缩放。
|
||||
final bool animateTap;
|
||||
}
|
||||
|
||||
/// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。
|
||||
@@ -79,7 +112,7 @@ class InteractionRail extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _RailButton extends StatelessWidget {
|
||||
class _RailButton extends StatefulWidget {
|
||||
const _RailButton({
|
||||
required this.item,
|
||||
required this.buttonSize,
|
||||
@@ -94,50 +127,135 @@ class _RailButton extends StatelessWidget {
|
||||
final double countFontSize;
|
||||
final double iconCountGap;
|
||||
|
||||
@override
|
||||
State<_RailButton> createState() => _RailButtonState();
|
||||
}
|
||||
|
||||
class _RailButtonState extends State<_RailButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _motion;
|
||||
Color _burstColor = AppColors.onPrimary;
|
||||
bool _contracting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_motion = AnimationController(vsync: this, duration: _tapDuration);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (MediaQuery.disableAnimationsOf(context)) _motion.reset();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_motion.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tint = item.selected ? item.selectedColor : AppColors.onPrimary;
|
||||
return Column(
|
||||
key: item.key,
|
||||
key: widget.item.key,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: buttonSize,
|
||||
child: IconButton(
|
||||
onPressed: item.onTap,
|
||||
padding: EdgeInsets.zero,
|
||||
icon: item.asset != null
|
||||
? Image.asset(
|
||||
item.asset!,
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
fit: BoxFit.contain,
|
||||
color: tint,
|
||||
colorBlendMode: BlendMode.srcIn,
|
||||
)
|
||||
: Transform.flip(
|
||||
flipX: item.mirrorIcon,
|
||||
child: Icon(item.icon, size: iconSize, color: tint),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: iconCountGap),
|
||||
Text(
|
||||
item.count == null ? item.label : formatInteractionCount(item.count!),
|
||||
key: item.countKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
height: 1,
|
||||
fontSize: countFontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: const [
|
||||
Shadow(blurRadius: 6, color: AppColors.mediaBlack),
|
||||
],
|
||||
),
|
||||
dimension: widget.buttonSize,
|
||||
child: widget.item.animateTap ? _animatedButton() : _button(_icon()),
|
||||
),
|
||||
SizedBox(height: widget.iconCountGap),
|
||||
_count(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onTap() {
|
||||
if (widget.item.animateTap && !MediaQuery.disableAnimationsOf(context)) {
|
||||
_contracting = widget.item.selected;
|
||||
_burstColor = _contracting
|
||||
? AppColors.onPrimary
|
||||
: widget.item.selectedColor;
|
||||
_motion.forward(from: 0);
|
||||
}
|
||||
widget.item.onTap();
|
||||
}
|
||||
|
||||
Widget _button(Widget icon) => IconButton(
|
||||
onPressed: _onTap,
|
||||
iconSize: widget.iconSize,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
overlayColor: AppColors.transparent,
|
||||
shadowColor: AppColors.transparent,
|
||||
elevation: 0,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
icon: icon,
|
||||
);
|
||||
|
||||
Widget _animatedButton() => AnimatedBuilder(
|
||||
animation: _motion,
|
||||
child: _icon(),
|
||||
builder: (context, child) => Stack(
|
||||
fit: StackFit.expand,
|
||||
alignment: Alignment.center,
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
_button(
|
||||
Transform.scale(
|
||||
scale: (_contracting ? _shrinkTween : _growTween).transform(
|
||||
_motion.value,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
if (_motion.isAnimating)
|
||||
Positioned.fill(
|
||||
child: RailTapBurst(
|
||||
key: const Key('rail-tap-burst'),
|
||||
progress: _motion.value,
|
||||
color: _burstColor,
|
||||
contracting: _contracting,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _icon() {
|
||||
final item = widget.item;
|
||||
final tint = item.selected ? item.selectedColor : AppColors.onPrimary;
|
||||
return item.asset != null
|
||||
? Image.asset(
|
||||
item.asset!,
|
||||
width: widget.iconSize,
|
||||
height: widget.iconSize,
|
||||
fit: BoxFit.contain,
|
||||
color: tint,
|
||||
colorBlendMode: BlendMode.srcIn,
|
||||
)
|
||||
: Transform.flip(
|
||||
flipX: item.mirrorIcon,
|
||||
child: Icon(item.icon, size: widget.iconSize, color: tint),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _count() {
|
||||
final item = widget.item;
|
||||
return Text(
|
||||
item.count == null ? item.label : formatInteractionCount(item.count!),
|
||||
key: item.countKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
height: 1,
|
||||
fontSize: widget.countFontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: const [Shadow(blurRadius: 6, color: AppColors.mediaBlack)],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:绘制收藏点赞周围的紧凑双向短线,无圆环或阴影,不参与手势。
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const int _rayCount = 8;
|
||||
const double _startProgress = 0.1;
|
||||
const double _radiusStart = 0.34;
|
||||
const double _radiusTravel = 0.14;
|
||||
const double _rayLength = 0.07;
|
||||
const double _strokeFraction = 0.025;
|
||||
const double _rayAlpha = 0.65;
|
||||
const double _fullCircle = math.pi * 2;
|
||||
const double _rayRotation = math.pi / _rayCount;
|
||||
|
||||
/// 收藏/点赞的装饰反馈:选中外扩,取消收束。
|
||||
class RailTapBurst extends StatelessWidget {
|
||||
/// [progress] 来自按钮的点击动画,不创建独立计时器。
|
||||
const RailTapBurst({
|
||||
super.key,
|
||||
required this.progress,
|
||||
required this.color,
|
||||
required this.contracting,
|
||||
});
|
||||
|
||||
final double progress;
|
||||
final Color color;
|
||||
final bool contracting;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _RailBurstPainter(
|
||||
progress: progress,
|
||||
color: color,
|
||||
contracting: contracting,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _RailBurstPainter extends CustomPainter {
|
||||
const _RailBurstPainter({
|
||||
required this.progress,
|
||||
required this.color,
|
||||
required this.contracting,
|
||||
});
|
||||
|
||||
final double progress;
|
||||
final Color color;
|
||||
final bool contracting;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final phase = ((progress - _startProgress) / (1 - _startProgress)).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
if (phase == 0 || phase == 1) return;
|
||||
final eased = Curves.easeOutCubic.transform(phase);
|
||||
final travel = contracting ? 1 - eased : eased;
|
||||
final base = size.shortestSide;
|
||||
final radius = base * (_radiusStart + _radiusTravel * travel);
|
||||
final alpha = math.sin(math.pi * phase);
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeWidth = base * _strokeFraction
|
||||
..color = color.withValues(alpha: alpha * _rayAlpha);
|
||||
final center = size.center(Offset.zero);
|
||||
final length = base * _rayLength * (1 - phase);
|
||||
for (var index = 0; index < _rayCount; index++) {
|
||||
final angle = _fullCircle * index / _rayCount + _rayRotation;
|
||||
canvas.drawLine(
|
||||
center + Offset.fromDirection(angle, radius),
|
||||
center + Offset.fromDirection(angle, radius + length),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _RailBurstPainter oldDelegate) =>
|
||||
oldDelegate.progress != progress ||
|
||||
oldDelegate.color != color ||
|
||||
oldDelegate.contracting != contracting;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'expandable_synopsis.dart';
|
||||
import 'player_center_toggle.dart';
|
||||
import 'interaction_rail.dart';
|
||||
import 'player_overlay_models.dart';
|
||||
@@ -178,7 +179,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
opacity: scrubbing ? 0 : 1,
|
||||
child: InteractionRail(
|
||||
key: const Key('player-interaction-rail'),
|
||||
itemGap: 12,
|
||||
itemGap: interactionRailCompactGap,
|
||||
buttonSize: 48,
|
||||
iconSize: 36,
|
||||
countFontSize: 15,
|
||||
@@ -190,6 +191,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
count: data.favoriteCount,
|
||||
countKey: const Key('player-rail-save-count'),
|
||||
selected: data.saved,
|
||||
animateTap: true,
|
||||
onTap: actions.onSave,
|
||||
),
|
||||
InteractionRailItem(
|
||||
@@ -199,6 +201,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
count: data.likeCount,
|
||||
countKey: const Key('player-rail-like-count'),
|
||||
selected: data.liked,
|
||||
animateTap: true,
|
||||
selectedColor: AppColors.brandRed,
|
||||
onTap: actions.onLike,
|
||||
),
|
||||
@@ -300,7 +303,6 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
key: const Key('player-episode-synopsis'),
|
||||
l10n: data.l10n,
|
||||
drama: data.drama,
|
||||
episodeNumber: data.episode.number,
|
||||
synopsis: synopsis,
|
||||
authorNote: data.authorNote,
|
||||
),
|
||||
@@ -412,7 +414,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w400,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -118,14 +118,12 @@ class _EpisodeSynopsis extends StatefulWidget {
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.drama,
|
||||
required this.episodeNumber,
|
||||
required this.synopsis,
|
||||
required this.authorNote,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final Drama drama;
|
||||
final int episodeNumber;
|
||||
final String synopsis;
|
||||
final String authorNote;
|
||||
|
||||
@@ -134,26 +132,13 @@ class _EpisodeSynopsis extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
bool _expanded = false;
|
||||
String? _fullSynopsis;
|
||||
int _detailRequest = 0;
|
||||
bool _detailRequested = false;
|
||||
|
||||
/// 「第 n 集」、分隔符与简介正文共用同一套白字规格。
|
||||
static const TextStyle _lineStyle = TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.35,
|
||||
shadows: _playerTextShadows,
|
||||
);
|
||||
static const TextStyle _linkStyle = TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: _playerTextShadows,
|
||||
);
|
||||
static const int _collapsedMaxLines = 2;
|
||||
static const int _authorNoteLines = 2;
|
||||
static const double _authorNoteGap = 4;
|
||||
static const double _authorNoteFont = 10;
|
||||
|
||||
String get _synopsis => _fullSynopsis ?? widget.synopsis;
|
||||
|
||||
@@ -173,7 +158,6 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
return;
|
||||
}
|
||||
_fullSynopsis = null;
|
||||
_expanded = false;
|
||||
_detailRequested = !widget.drama.detailLoaded;
|
||||
if (_detailRequested) unawaited(_loadFullSynopsis());
|
||||
}
|
||||
@@ -200,123 +184,26 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => _block(constraints.maxWidth),
|
||||
return ExpandableSynopsis(
|
||||
key: ValueKey(widget.drama.id),
|
||||
keyPrefix: 'player',
|
||||
synopsis: _synopsis,
|
||||
expandedFooter: widget.authorNote.isEmpty ? null : _authorNote(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 与首页推荐 feed 的简介展开/收起保持一致:
|
||||
/// 折叠单行省略 + 行尾「展开」(整行可点);展开后内联「收起」。
|
||||
Widget _block(double maxWidth) {
|
||||
final label = widget.l10n.episodeLabel(widget.episodeNumber);
|
||||
final span = TextSpan(
|
||||
style: _lineStyle,
|
||||
children: [
|
||||
TextSpan(text: label, style: _lineStyle),
|
||||
_separatorSpan(),
|
||||
TextSpan(text: _synopsis, style: _lineStyle),
|
||||
],
|
||||
);
|
||||
if (_expanded) return _expandedBlock(span);
|
||||
|
||||
final painter = TextPainter(
|
||||
text: span,
|
||||
maxLines: 1,
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
)..layout(maxWidth: maxWidth);
|
||||
if (!painter.didExceedMaxLines) {
|
||||
return Text.rich(
|
||||
span,
|
||||
key: const Key('player-synopsis'),
|
||||
maxLines: 1,
|
||||
style: _lineStyle,
|
||||
);
|
||||
}
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _expanded = true),
|
||||
child: Row(
|
||||
key: const Key('player-synopsis-collapsed'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
span,
|
||||
key: const Key('player-synopsis'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: _lineStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
_toggle(
|
||||
widget.l10n.expandSynopsis,
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
),
|
||||
],
|
||||
Widget _authorNote() => Padding(
|
||||
padding: const EdgeInsets.only(top: _authorNoteGap),
|
||||
child: Text(
|
||||
widget.authorNote,
|
||||
key: const Key('player-author-note'),
|
||||
maxLines: _authorNoteLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.playerMuted,
|
||||
fontSize: _authorNoteFont,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _expandedBlock(InlineSpan span) {
|
||||
final expandedSpan = TextSpan(
|
||||
style: _lineStyle,
|
||||
children: [
|
||||
span,
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: _toggle(
|
||||
widget.l10n.showLess,
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return Column(
|
||||
key: const Key('player-synopsis-expanded'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(expandedSpan, key: const Key('player-synopsis')),
|
||||
if (widget.authorNote.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.authorNote,
|
||||
key: const Key('player-author-note'),
|
||||
maxLines: _collapsedMaxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.playerMuted,
|
||||
fontSize: 10,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 分隔竖线:同字号 w400 + 45% 透明白,靠两侧空格留白。
|
||||
/// 必须用 TextSpan:WidgetSpan 占位盒的基线对齐会让第n集与简介错位。
|
||||
TextSpan _separatorSpan() => TextSpan(
|
||||
text: ' | ',
|
||||
style: _lineStyle.copyWith(
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.45),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _toggle(String label, {required EdgeInsets padding}) {
|
||||
return GestureDetector(
|
||||
key: const Key('player-synopsis-toggle'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Text(label, maxLines: 1, style: _linkStyle),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:协调首页三页的点击动画、最终选中目标和手势中断。
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
const _transitionDuration = Duration(milliseconds: 220);
|
||||
|
||||
/// 页面移动时保留用户最终目标,防止跨页动画途中的页面抢占高亮。
|
||||
class HomeTabMotionController extends PageController {
|
||||
/// [onSettled] 在动画结束或被手势中断时同步实际停留页面。
|
||||
HomeTabMotionController({required this.onSettled});
|
||||
|
||||
final ValueChanged<int> onSettled;
|
||||
int? _target;
|
||||
int _revision = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
/// 供导航绘制连续位置;布局尚未建立时使用初始页。
|
||||
double get pagePosition => hasClients && position.hasContentDimensions
|
||||
? page ?? initialPage.toDouble()
|
||||
: initialPage.toDouble();
|
||||
|
||||
/// 点击跨页过程中只接受最终目标;拖动和稳定状态接受实际页。
|
||||
bool acceptsSelection(int index) => _target == null || _target == index;
|
||||
|
||||
/// 点击导航时平滑移动;系统减少动画时直接到位。
|
||||
Future<void> selectPage(int index, {required bool reduceMotion}) async {
|
||||
final revision = ++_revision;
|
||||
_target = index;
|
||||
if (!hasClients) {
|
||||
_target = null;
|
||||
return;
|
||||
}
|
||||
if (reduceMotion) {
|
||||
jumpToPage(index);
|
||||
} else {
|
||||
await animateToPage(
|
||||
index,
|
||||
duration: _transitionDuration,
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
if (_disposed || revision != _revision) return;
|
||||
_target = null;
|
||||
if (hasClients) onSettled(pagePosition.round());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,15 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../home/home_feed_channel.dart';
|
||||
import '../player/open_player.dart';
|
||||
import '../player/player_view.dart';
|
||||
import 'home_tab_motion_controller.dart';
|
||||
import 'recommend_types.dart';
|
||||
import 'widgets/all_dramas_filter_grid.dart';
|
||||
import 'widgets/playlist_detail_page.dart';
|
||||
import 'widgets/recommend_sections_view.dart';
|
||||
import 'widgets/recommend_top_nav.dart';
|
||||
|
||||
part 'recommend_page_parts.dart';
|
||||
|
||||
class RecommendPage extends StatefulWidget {
|
||||
const RecommendPage({
|
||||
super.key,
|
||||
@@ -60,7 +63,9 @@ class _RecommendPageState extends State<RecommendPage>
|
||||
static const int _maxRecommendRetries = 3;
|
||||
|
||||
RecommendInnerTab _tab = RecommendInnerTab.feed;
|
||||
final PageController _pageController = PageController();
|
||||
late final _pageController = HomeTabMotionController(
|
||||
onSettled: _onPageChanged,
|
||||
);
|
||||
int _scrubCancelRequest = 0;
|
||||
bool _recommendDataRefreshScheduled = false;
|
||||
bool _recommendRefreshInFlight = false;
|
||||
@@ -274,12 +279,16 @@ class _RecommendPageState extends State<RecommendPage>
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: RecommendTopNav(
|
||||
selected: _tab,
|
||||
onVideo: _tab == RecommendInnerTab.feed,
|
||||
onSelected: _selectTab,
|
||||
onSearch: _handleSearch,
|
||||
onGift: widget.onOpenWallet,
|
||||
child: ListenableBuilder(
|
||||
listenable: _pageController,
|
||||
builder: (context, _) => RecommendTopNav(
|
||||
selected: _tab,
|
||||
pagePosition: _pageController.pagePosition,
|
||||
onVideo: _tab == RecommendInnerTab.feed,
|
||||
onSelected: _selectTab,
|
||||
onSearch: _handleSearch,
|
||||
onGift: widget.onOpenWallet,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -401,18 +410,16 @@ class _RecommendPageState extends State<RecommendPage>
|
||||
_tab = tab;
|
||||
_scrubCancelRequest++;
|
||||
});
|
||||
if (_pageController.hasClients) {
|
||||
unawaited(
|
||||
_pageController.animateToPage(
|
||||
tab.index,
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOut,
|
||||
),
|
||||
);
|
||||
}
|
||||
unawaited(
|
||||
_pageController.selectPage(
|
||||
tab.index,
|
||||
reduceMotion: MediaQuery.disableAnimationsOf(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onPageChanged(int index) {
|
||||
if (!_pageController.acceptsSelection(index)) return;
|
||||
final next = RecommendInnerTab.values[index];
|
||||
if (next == _tab) return;
|
||||
setState(() {
|
||||
@@ -484,110 +491,7 @@ class _RecommendPageState extends State<RecommendPage>
|
||||
);
|
||||
}
|
||||
|
||||
(String, String) _sectionCopy(
|
||||
AppLocalizations l10n,
|
||||
RecommendSectionKind section,
|
||||
) {
|
||||
return switch (section) {
|
||||
RecommendSectionKind.hits => (
|
||||
l10n.weeklyHitsTitle,
|
||||
l10n.weeklyHitsSubtitle,
|
||||
),
|
||||
RecommendSectionKind.newReleases => (
|
||||
l10n.weeklyNewTitle,
|
||||
l10n.weeklyNewSubtitle,
|
||||
),
|
||||
RecommendSectionKind.costume => (
|
||||
l10n.costumeAwardsTitle,
|
||||
l10n.costumeAwardsSubtitle,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _openDrama(Drama drama, {String? episodeId}) async {
|
||||
await playDramaFromCatalog(context, drama, episodeId: episodeId);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecommendFeedSkeleton extends StatelessWidget {
|
||||
const _RecommendFeedSkeleton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const ColoredBox(
|
||||
key: Key('recommend-feed-skeleton'),
|
||||
color: AppColors.mediaBlack,
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: 132,
|
||||
height: 176,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.skeletonMedia,
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 14,
|
||||
right: 72,
|
||||
bottom: 42,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SkeletonLine(width: 190, height: 18),
|
||||
SizedBox(height: 10),
|
||||
_SkeletonLine(width: 140, height: 12),
|
||||
SizedBox(height: 8),
|
||||
_SkeletonLine(width: 220, height: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SkeletonLine extends StatelessWidget {
|
||||
const _SkeletonLine({required this.width, required this.height});
|
||||
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.skeletonMedia,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeepAliveTab extends StatefulWidget {
|
||||
const _KeepAliveTab({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_KeepAliveTab> createState() => _KeepAliveTabState();
|
||||
}
|
||||
|
||||
class _KeepAliveTabState extends State<_KeepAliveTab>
|
||||
with AutomaticKeepAliveClientMixin<_KeepAliveTab> {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Author: fruit.com.cn
|
||||
// 职责:首页推荐流加载骨架、页面保活与静态文案,保持原有行为。
|
||||
part of 'recommend_page.dart';
|
||||
|
||||
const double _posterWidth = 132;
|
||||
const double _posterHeight = 176;
|
||||
const double _posterRadius = 12;
|
||||
const double _copyLeft = 14;
|
||||
const double _copyRight = 72;
|
||||
const double _copyBottom = 42;
|
||||
const double _titleWidth = 190;
|
||||
const double _titleHeight = 18;
|
||||
const double _subtitleWidth = 140;
|
||||
const double _subtitleHeight = 12;
|
||||
const double _descriptionWidth = 220;
|
||||
const double _descriptionHeight = 10;
|
||||
const double _titleGap = 10;
|
||||
const double _subtitleGap = 8;
|
||||
const double _lineRadius = 6;
|
||||
|
||||
class _RecommendFeedSkeleton extends StatelessWidget {
|
||||
const _RecommendFeedSkeleton();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const ColoredBox(
|
||||
key: Key('recommend-feed-skeleton'),
|
||||
color: AppColors.mediaBlack,
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: SizedBox(
|
||||
width: _posterWidth,
|
||||
height: _posterHeight,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.skeletonMedia,
|
||||
borderRadius: BorderRadius.all(Radius.circular(_posterRadius)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: _copyLeft,
|
||||
right: _copyRight,
|
||||
bottom: _copyBottom,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SkeletonLine(width: _titleWidth, height: _titleHeight),
|
||||
SizedBox(height: _titleGap),
|
||||
_SkeletonLine(width: _subtitleWidth, height: _subtitleHeight),
|
||||
SizedBox(height: _subtitleGap),
|
||||
_SkeletonLine(
|
||||
width: _descriptionWidth,
|
||||
height: _descriptionHeight,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SkeletonLine extends StatelessWidget {
|
||||
const _SkeletonLine({required this.width, required this.height});
|
||||
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.skeletonMedia,
|
||||
borderRadius: BorderRadius.circular(_lineRadius),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _KeepAliveTab extends StatefulWidget {
|
||||
const _KeepAliveTab({super.key, required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_KeepAliveTab> createState() => _KeepAliveTabState();
|
||||
}
|
||||
|
||||
class _KeepAliveTabState extends State<_KeepAliveTab>
|
||||
with AutomaticKeepAliveClientMixin<_KeepAliveTab> {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
(String, String) _sectionCopy(
|
||||
AppLocalizations l10n,
|
||||
RecommendSectionKind section,
|
||||
) => switch (section) {
|
||||
RecommendSectionKind.hits => (l10n.weeklyHitsTitle, l10n.weeklyHitsSubtitle),
|
||||
RecommendSectionKind.newReleases => (
|
||||
l10n.weeklyNewTitle,
|
||||
l10n.weeklyNewSubtitle,
|
||||
),
|
||||
RecommendSectionKind.costume => (
|
||||
l10n.costumeAwardsTitle,
|
||||
l10n.costumeAwardsSubtitle,
|
||||
),
|
||||
};
|
||||
@@ -122,7 +122,7 @@ class HomePosterCard extends StatelessWidget {
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/content_category.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/animated_nav_label.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'search_gift_glyphs.dart';
|
||||
|
||||
@@ -16,7 +17,7 @@ const double _giftIconSize = 26;
|
||||
const double _underlineWidth = 26;
|
||||
const double _underlineHeight = 3;
|
||||
|
||||
/// 推荐页(礼花 tab)顶部频道导航:频道全部来自分类接口,右侧搜索/礼物。
|
||||
/// 推荐页顶部频道导航:频道全部来自分类接口,右侧搜索/礼物。
|
||||
class RecommendChannelNav extends StatefulWidget {
|
||||
const RecommendChannelNav({
|
||||
super.key,
|
||||
@@ -49,8 +50,7 @@ class _RecommendChannelNavState extends State<RecommendChannelNav> {
|
||||
final GlobalKey _scrollViewKey = GlobalKey();
|
||||
final Map<String, GlobalKey> _tabKeys = {};
|
||||
|
||||
GlobalKey _keyFor(String id) =>
|
||||
_tabKeys.putIfAbsent(id, () => GlobalKey());
|
||||
GlobalKey _keyFor(String id) => _tabKeys.putIfAbsent(id, () => GlobalKey());
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant RecommendChannelNav oldWidget) {
|
||||
@@ -82,9 +82,10 @@ class _RecommendChannelNavState extends State<RecommendChannelNav> {
|
||||
final tabLeft = tabBox.localToGlobal(Offset.zero).dx;
|
||||
final relative = tabLeft - viewportLeft;
|
||||
final position = _scrollController.position;
|
||||
final target =
|
||||
(position.pixels + relative - _revealLeftInset)
|
||||
.clamp(0.0, position.maxScrollExtent);
|
||||
final target = (position.pixels + relative - _revealLeftInset).clamp(
|
||||
0.0,
|
||||
position.maxScrollExtent,
|
||||
);
|
||||
if ((target - position.pixels).abs() < 0.5) return;
|
||||
_scrollController.animateTo(
|
||||
target,
|
||||
@@ -147,7 +148,10 @@ class _RecommendChannelNavState extends State<RecommendChannelNav> {
|
||||
),
|
||||
iconSize: _searchIconSize,
|
||||
color: AppColors.textPrimary,
|
||||
icon: SearchGlyph(size: _searchIconSize, color: AppColors.textPrimary),
|
||||
icon: SearchGlyph(
|
||||
size: _searchIconSize,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.onGift != null)
|
||||
@@ -188,18 +192,16 @@ class _RecommendChannelNavState extends State<RecommendChannelNav> {
|
||||
children: [
|
||||
// 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。
|
||||
const SizedBox(height: 4 + _underlineHeight),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
AnimatedNavLabel(
|
||||
label: label,
|
||||
selected: selected,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _tabFont,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0,
|
||||
height: 1.0,
|
||||
shadows: const [
|
||||
Shadow(color: AppColors.mediaBlack, blurRadius: 6),
|
||||
],
|
||||
shadows: [Shadow(color: AppColors.mediaBlack, blurRadius: 6)],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
@@ -4,13 +4,14 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/animated_nav_label.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../recommend_types.dart';
|
||||
import 'search_gift_glyphs.dart';
|
||||
|
||||
const double _navHeight = 56;
|
||||
const double _tabGap = 20;
|
||||
const double _tabFont = 17;
|
||||
const double _tabFont = 18;
|
||||
const double _maxNavigationTextScale = 1.3;
|
||||
const double _searchIconSize = 26;
|
||||
const double _giftIconSize = 26;
|
||||
@@ -25,6 +26,7 @@ class RecommendTopNav extends StatelessWidget {
|
||||
required this.onSearch,
|
||||
this.onVideo = false,
|
||||
this.onGift,
|
||||
this.pagePosition,
|
||||
});
|
||||
|
||||
static const double height = _navHeight;
|
||||
@@ -34,6 +36,9 @@ class RecommendTopNav extends StatelessWidget {
|
||||
final VoidCallback onSearch;
|
||||
final bool onVideo;
|
||||
|
||||
/// 首页横向页面的连续位置,用于文字和下划线跟手过渡。
|
||||
final double? pagePosition;
|
||||
|
||||
/// 右上角金色礼物盒(钱包入口);为空时不显示。
|
||||
final VoidCallback? onGift;
|
||||
|
||||
@@ -65,14 +70,7 @@ class RecommendTopNav extends StatelessWidget {
|
||||
children: [
|
||||
for (var index = 0; index < items.length; index++) ...[
|
||||
if (index > 0) const SizedBox(width: _tabGap),
|
||||
_tab(
|
||||
key: Key('recommend-tab-${items[index].$3}'),
|
||||
code: items[index].$3,
|
||||
label: items[index].$2,
|
||||
selected: selected == items[index].$1,
|
||||
shadows: shadows,
|
||||
onTap: () => onSelected(items[index].$1),
|
||||
),
|
||||
_tab(items[index], shadows, context),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -120,47 +118,79 @@ class RecommendTopNav extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tab({
|
||||
required Key key,
|
||||
required String code,
|
||||
required String label,
|
||||
required bool selected,
|
||||
required List<Shadow>? shadows,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
Widget _tab(
|
||||
(RecommendInnerTab, String, String) item,
|
||||
List<Shadow>? shadows,
|
||||
BuildContext context,
|
||||
) {
|
||||
final (tab, label, code) = item;
|
||||
final isSelected = selected == tab;
|
||||
final position = pagePosition;
|
||||
final progress = position == null
|
||||
? null
|
||||
: (1 - (position - tab.index).abs()).clamp(0.0, 1.0);
|
||||
return GestureDetector(
|
||||
key: key,
|
||||
key: Key('recommend-tab-$code'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
onTap: () => onSelected(tab),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。
|
||||
const SizedBox(height: 4 + _underlineHeight),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
AnimatedNavLabel(
|
||||
label: label,
|
||||
selected: isSelected,
|
||||
selectionProgress: progress,
|
||||
inactiveColor: AppColors.homeTabInactive,
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _tabFont,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0,
|
||||
height: 1.0,
|
||||
shadows: shadows,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
key: Key('recommend-tab-$code-underline'),
|
||||
width: _underlineWidth,
|
||||
height: _underlineHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.brandRed : AppColors.transparent,
|
||||
borderRadius: BorderRadius.circular(_underlineHeight / 2),
|
||||
),
|
||||
_underline(
|
||||
code,
|
||||
progress,
|
||||
isSelected,
|
||||
MediaQuery.disableAnimationsOf(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _underline(
|
||||
String code,
|
||||
double? progress,
|
||||
bool isSelected,
|
||||
bool reduceMotion,
|
||||
) {
|
||||
if (progress != null) return _line(code, progress);
|
||||
final target = isSelected ? 1.0 : 0.0;
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: target, end: target),
|
||||
duration: reduceMotion
|
||||
? Duration.zero
|
||||
: AppDimens.homePrimaryTabAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
builder: (_, value, _) => _line(code, value),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _line(String code, double progress) => Container(
|
||||
key: Key('recommend-tab-$code-underline'),
|
||||
width: _underlineWidth,
|
||||
height: _underlineHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: progress == 0
|
||||
? AppColors.transparent
|
||||
: AppColors.brandRed.withValues(alpha: progress),
|
||||
borderRadius: BorderRadius.circular(_underlineHeight / 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user