feat(home): 顶部导航随翻页联动并把推荐更名为精选
This commit is contained in:
1 parent
2f4593a46e
commit
335e5dc286
27 files changed
+1095
-214
No files matched your search
@@ -153,6 +153,10 @@ abstract final class AppColors {
|
|||||||
/// 列表底部「没有更多了」这类弱提示。
|
/// 列表底部「没有更多了」这类弱提示。
|
||||||
static const textHint = Color(0xFF5C5C60);
|
static const textHint = Color(0xFF5C5C60);
|
||||||
static const textTabInactive = Color(0xFFB0B0B0);
|
static const textTabInactive = Color(0xFFB0B0B0);
|
||||||
|
|
||||||
|
/// 首页顶部三项导航未选中文字,略暗于选中白色但保持清晰。
|
||||||
|
static const homeTabInactive = Color(0xFFC7C7CC);
|
||||||
|
|
||||||
static const textFilterIcon = Color(0xFF7A7A7E);
|
static const textFilterIcon = Color(0xFF7A7A7E);
|
||||||
static const searchResultMeta = Color(0xFF9E9E9E);
|
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) {
|
IconData get icon => switch (this) {
|
||||||
AppRootTab.home => Icons.home_outlined,
|
AppRootTab.home => Icons.home_outlined,
|
||||||
AppRootTab.recommend => Icons.celebration_outlined,
|
AppRootTab.recommend => Icons.smart_display_outlined,
|
||||||
AppRootTab.following => Icons.bookmark_border_rounded,
|
AppRootTab.following => Icons.bookmark_border_rounded,
|
||||||
AppRootTab.mine => Icons.person_outline_rounded,
|
AppRootTab.mine => Icons.person_outline_rounded,
|
||||||
};
|
};
|
||||||
|
|
||||||
IconData get selectedIcon => switch (this) {
|
IconData get selectedIcon => switch (this) {
|
||||||
AppRootTab.home => Icons.home_rounded,
|
AppRootTab.home => Icons.home_rounded,
|
||||||
AppRootTab.recommend => Icons.celebration_rounded,
|
AppRootTab.recommend => Icons.smart_display_rounded,
|
||||||
AppRootTab.following => Icons.bookmark_rounded,
|
AppRootTab.following => Icons.bookmark_rounded,
|
||||||
AppRootTab.mine => Icons.person_rounded,
|
AppRootTab.mine => Icons.person_rounded,
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -761,7 +761,7 @@
|
|||||||
"homeBadgeNew": "New",
|
"homeBadgeNew": "New",
|
||||||
"homeBadgeAnime": "Anime",
|
"homeBadgeAnime": "Anime",
|
||||||
"emptyContent": "No content yet",
|
"emptyContent": "No content yet",
|
||||||
"recommendTopTab": "Recommend",
|
"recommendTopTab": "Featured",
|
||||||
"playlistTopTab": "Playlists",
|
"playlistTopTab": "Playlists",
|
||||||
"allDramasTopTab": "All Dramas",
|
"allDramasTopTab": "All Dramas",
|
||||||
"weeklyHitsTitle": "This Week's Hits",
|
"weeklyHitsTitle": "This Week's Hits",
|
||||||
|
|||||||
+1
-1
@@ -723,7 +723,7 @@
|
|||||||
"homeBadgeNew": "Baru",
|
"homeBadgeNew": "Baru",
|
||||||
"homeBadgeAnime": "Anime",
|
"homeBadgeAnime": "Anime",
|
||||||
"emptyContent": "Belum ada konten",
|
"emptyContent": "Belum ada konten",
|
||||||
"recommendTopTab": "Rekomendasi",
|
"recommendTopTab": "Unggulan",
|
||||||
"playlistTopTab": "Daftar Putar",
|
"playlistTopTab": "Daftar Putar",
|
||||||
"allDramasTopTab": "Semua",
|
"allDramasTopTab": "Semua",
|
||||||
"weeklyHitsTitle": "Hits Minggu Ini",
|
"weeklyHitsTitle": "Hits Minggu Ini",
|
||||||
|
|||||||
@@ -3271,7 +3271,7 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @recommendTopTab.
|
/// No description provided for @recommendTopTab.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Recommend'**
|
/// **'Featured'**
|
||||||
String get recommendTopTab;
|
String get recommendTopTab;
|
||||||
|
|
||||||
/// No description provided for @playlistTopTab.
|
/// No description provided for @playlistTopTab.
|
||||||
|
|||||||
@@ -1711,7 +1711,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get emptyContent => 'No content yet';
|
String get emptyContent => 'No content yet';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recommendTopTab => 'Recommend';
|
String get recommendTopTab => 'Featured';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get playlistTopTab => 'Playlists';
|
String get playlistTopTab => 'Playlists';
|
||||||
|
|||||||
@@ -1711,7 +1711,7 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get emptyContent => 'Belum ada konten';
|
String get emptyContent => 'Belum ada konten';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recommendTopTab => 'Rekomendasi';
|
String get recommendTopTab => 'Unggulan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get playlistTopTab => 'Daftar Putar';
|
String get playlistTopTab => 'Daftar Putar';
|
||||||
|
|||||||
@@ -1686,7 +1686,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get emptyContent => '暂无内容';
|
String get emptyContent => '暂无内容';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recommendTopTab => '推荐';
|
String get recommendTopTab => '精选';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get playlistTopTab => '剧单';
|
String get playlistTopTab => '剧单';
|
||||||
|
|||||||
+1
-1
@@ -685,7 +685,7 @@
|
|||||||
"homeBadgeNew": "新剧",
|
"homeBadgeNew": "新剧",
|
||||||
"homeBadgeAnime": "漫剧",
|
"homeBadgeAnime": "漫剧",
|
||||||
"emptyContent": "暂无内容",
|
"emptyContent": "暂无内容",
|
||||||
"recommendTopTab": "推荐",
|
"recommendTopTab": "精选",
|
||||||
"playlistTopTab": "剧单",
|
"playlistTopTab": "剧单",
|
||||||
"allDramasTopTab": "全部短剧",
|
"allDramasTopTab": "全部短剧",
|
||||||
"weeklyHitsTitle": "本週爆款推薦",
|
"weeklyHitsTitle": "本週爆款推薦",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../../core/formatting/count_format.dart';
|
import '../../../core/formatting/count_format.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
import '../../../core/widgets/animated_nav_label.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
enum HomePrimaryTab {
|
enum HomePrimaryTab {
|
||||||
@@ -323,21 +324,16 @@ class _CategoryBarState extends State<_CategoryBar> {
|
|||||||
height: AppDimens.homePrimaryRowHeight,
|
height: AppDimens.homePrimaryRowHeight,
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomLeft,
|
alignment: Alignment.bottomLeft,
|
||||||
child: AnimatedDefaultTextStyle(
|
child: AnimatedNavLabel(
|
||||||
key: Key('home-primary-label-${category.categoryId}'),
|
key: Key('home-primary-label-${category.categoryId}'),
|
||||||
duration: AppDimens.homePrimaryTabAnimationDuration,
|
label: category.label,
|
||||||
curve: Curves.easeOut,
|
selected: selected,
|
||||||
style: TextStyle(
|
alignment: Alignment.bottomLeft,
|
||||||
color: selected
|
style: const TextStyle(
|
||||||
? AppColors.navSelected
|
color: AppColors.navSelected,
|
||||||
: AppColors.textTabInactive,
|
fontSize: AppDimens.homePrimaryTabSelectedFont,
|
||||||
fontSize: selected
|
|
||||||
? AppDimens.homePrimaryTabSelectedFont
|
|
||||||
: AppDimens.homePrimaryTabFont,
|
|
||||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
),
|
),
|
||||||
child: Text(category.label, textAlign: TextAlign.left),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 '../home/home_feed_channel.dart';
|
||||||
import '../player/open_player.dart';
|
import '../player/open_player.dart';
|
||||||
import '../player/player_view.dart';
|
import '../player/player_view.dart';
|
||||||
|
import 'home_tab_motion_controller.dart';
|
||||||
import 'recommend_types.dart';
|
import 'recommend_types.dart';
|
||||||
import 'widgets/all_dramas_filter_grid.dart';
|
import 'widgets/all_dramas_filter_grid.dart';
|
||||||
import 'widgets/playlist_detail_page.dart';
|
import 'widgets/playlist_detail_page.dart';
|
||||||
import 'widgets/recommend_sections_view.dart';
|
import 'widgets/recommend_sections_view.dart';
|
||||||
import 'widgets/recommend_top_nav.dart';
|
import 'widgets/recommend_top_nav.dart';
|
||||||
|
|
||||||
|
part 'recommend_page_parts.dart';
|
||||||
|
|
||||||
class RecommendPage extends StatefulWidget {
|
class RecommendPage extends StatefulWidget {
|
||||||
const RecommendPage({
|
const RecommendPage({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -60,7 +63,9 @@ class _RecommendPageState extends State<RecommendPage>
|
|||||||
static const int _maxRecommendRetries = 3;
|
static const int _maxRecommendRetries = 3;
|
||||||
|
|
||||||
RecommendInnerTab _tab = RecommendInnerTab.feed;
|
RecommendInnerTab _tab = RecommendInnerTab.feed;
|
||||||
final PageController _pageController = PageController();
|
late final _pageController = HomeTabMotionController(
|
||||||
|
onSettled: _onPageChanged,
|
||||||
|
);
|
||||||
int _scrubCancelRequest = 0;
|
int _scrubCancelRequest = 0;
|
||||||
bool _recommendDataRefreshScheduled = false;
|
bool _recommendDataRefreshScheduled = false;
|
||||||
bool _recommendRefreshInFlight = false;
|
bool _recommendRefreshInFlight = false;
|
||||||
@@ -274,12 +279,16 @@ class _RecommendPageState extends State<RecommendPage>
|
|||||||
right: 0,
|
right: 0,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: RecommendTopNav(
|
child: ListenableBuilder(
|
||||||
selected: _tab,
|
listenable: _pageController,
|
||||||
onVideo: _tab == RecommendInnerTab.feed,
|
builder: (context, _) => RecommendTopNav(
|
||||||
onSelected: _selectTab,
|
selected: _tab,
|
||||||
onSearch: _handleSearch,
|
pagePosition: _pageController.pagePosition,
|
||||||
onGift: widget.onOpenWallet,
|
onVideo: _tab == RecommendInnerTab.feed,
|
||||||
|
onSelected: _selectTab,
|
||||||
|
onSearch: _handleSearch,
|
||||||
|
onGift: widget.onOpenWallet,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -401,18 +410,16 @@ class _RecommendPageState extends State<RecommendPage>
|
|||||||
_tab = tab;
|
_tab = tab;
|
||||||
_scrubCancelRequest++;
|
_scrubCancelRequest++;
|
||||||
});
|
});
|
||||||
if (_pageController.hasClients) {
|
unawaited(
|
||||||
unawaited(
|
_pageController.selectPage(
|
||||||
_pageController.animateToPage(
|
tab.index,
|
||||||
tab.index,
|
reduceMotion: MediaQuery.disableAnimationsOf(context),
|
||||||
duration: const Duration(milliseconds: 220),
|
),
|
||||||
curve: Curves.easeOut,
|
);
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPageChanged(int index) {
|
void _onPageChanged(int index) {
|
||||||
|
if (!_pageController.acceptsSelection(index)) return;
|
||||||
final next = RecommendInnerTab.values[index];
|
final next = RecommendInnerTab.values[index];
|
||||||
if (next == _tab) return;
|
if (next == _tab) return;
|
||||||
setState(() {
|
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 {
|
Future<void> _openDrama(Drama drama, {String? episodeId}) async {
|
||||||
await playDramaFromCatalog(context, drama, episodeId: episodeId);
|
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(
|
style: const TextStyle(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w400,
|
||||||
height: 1.1,
|
height: 1.1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../../core/data/models/content_category.dart';
|
import '../../../core/data/models/content_category.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/widgets/animated_nav_label.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import 'search_gift_glyphs.dart';
|
import 'search_gift_glyphs.dart';
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ const double _giftIconSize = 26;
|
|||||||
const double _underlineWidth = 26;
|
const double _underlineWidth = 26;
|
||||||
const double _underlineHeight = 3;
|
const double _underlineHeight = 3;
|
||||||
|
|
||||||
/// 推荐页(礼花 tab)顶部频道导航:频道全部来自分类接口,右侧搜索/礼物。
|
/// 推荐页顶部频道导航:频道全部来自分类接口,右侧搜索/礼物。
|
||||||
class RecommendChannelNav extends StatefulWidget {
|
class RecommendChannelNav extends StatefulWidget {
|
||||||
const RecommendChannelNav({
|
const RecommendChannelNav({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -49,8 +50,7 @@ class _RecommendChannelNavState extends State<RecommendChannelNav> {
|
|||||||
final GlobalKey _scrollViewKey = GlobalKey();
|
final GlobalKey _scrollViewKey = GlobalKey();
|
||||||
final Map<String, GlobalKey> _tabKeys = {};
|
final Map<String, GlobalKey> _tabKeys = {};
|
||||||
|
|
||||||
GlobalKey _keyFor(String id) =>
|
GlobalKey _keyFor(String id) => _tabKeys.putIfAbsent(id, () => GlobalKey());
|
||||||
_tabKeys.putIfAbsent(id, () => GlobalKey());
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant RecommendChannelNav oldWidget) {
|
void didUpdateWidget(covariant RecommendChannelNav oldWidget) {
|
||||||
@@ -82,9 +82,10 @@ class _RecommendChannelNavState extends State<RecommendChannelNav> {
|
|||||||
final tabLeft = tabBox.localToGlobal(Offset.zero).dx;
|
final tabLeft = tabBox.localToGlobal(Offset.zero).dx;
|
||||||
final relative = tabLeft - viewportLeft;
|
final relative = tabLeft - viewportLeft;
|
||||||
final position = _scrollController.position;
|
final position = _scrollController.position;
|
||||||
final target =
|
final target = (position.pixels + relative - _revealLeftInset).clamp(
|
||||||
(position.pixels + relative - _revealLeftInset)
|
0.0,
|
||||||
.clamp(0.0, position.maxScrollExtent);
|
position.maxScrollExtent,
|
||||||
|
);
|
||||||
if ((target - position.pixels).abs() < 0.5) return;
|
if ((target - position.pixels).abs() < 0.5) return;
|
||||||
_scrollController.animateTo(
|
_scrollController.animateTo(
|
||||||
target,
|
target,
|
||||||
@@ -147,7 +148,10 @@ class _RecommendChannelNavState extends State<RecommendChannelNav> {
|
|||||||
),
|
),
|
||||||
iconSize: _searchIconSize,
|
iconSize: _searchIconSize,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
icon: SearchGlyph(size: _searchIconSize, color: AppColors.textPrimary),
|
icon: SearchGlyph(
|
||||||
|
size: _searchIconSize,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.onGift != null)
|
if (widget.onGift != null)
|
||||||
@@ -188,18 +192,16 @@ class _RecommendChannelNavState extends State<RecommendChannelNav> {
|
|||||||
children: [
|
children: [
|
||||||
// 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。
|
// 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。
|
||||||
const SizedBox(height: 4 + _underlineHeight),
|
const SizedBox(height: 4 + _underlineHeight),
|
||||||
Text(
|
AnimatedNavLabel(
|
||||||
label,
|
label: label,
|
||||||
maxLines: 1,
|
selected: selected,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontSize: _tabFont,
|
fontSize: _tabFont,
|
||||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
height: 1.0,
|
height: 1.0,
|
||||||
shadows: const [
|
shadows: [Shadow(color: AppColors.mediaBlack, blurRadius: 6)],
|
||||||
Shadow(color: AppColors.mediaBlack, blurRadius: 6),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
import '../../../core/widgets/animated_nav_label.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../recommend_types.dart';
|
import '../recommend_types.dart';
|
||||||
import 'search_gift_glyphs.dart';
|
import 'search_gift_glyphs.dart';
|
||||||
|
|
||||||
const double _navHeight = 56;
|
const double _navHeight = 56;
|
||||||
const double _tabGap = 20;
|
const double _tabGap = 20;
|
||||||
const double _tabFont = 17;
|
const double _tabFont = 18;
|
||||||
const double _maxNavigationTextScale = 1.3;
|
const double _maxNavigationTextScale = 1.3;
|
||||||
const double _searchIconSize = 26;
|
const double _searchIconSize = 26;
|
||||||
const double _giftIconSize = 26;
|
const double _giftIconSize = 26;
|
||||||
@@ -25,6 +26,7 @@ class RecommendTopNav extends StatelessWidget {
|
|||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
this.onVideo = false,
|
this.onVideo = false,
|
||||||
this.onGift,
|
this.onGift,
|
||||||
|
this.pagePosition,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const double height = _navHeight;
|
static const double height = _navHeight;
|
||||||
@@ -34,6 +36,9 @@ class RecommendTopNav extends StatelessWidget {
|
|||||||
final VoidCallback onSearch;
|
final VoidCallback onSearch;
|
||||||
final bool onVideo;
|
final bool onVideo;
|
||||||
|
|
||||||
|
/// 首页横向页面的连续位置,用于文字和下划线跟手过渡。
|
||||||
|
final double? pagePosition;
|
||||||
|
|
||||||
/// 右上角金色礼物盒(钱包入口);为空时不显示。
|
/// 右上角金色礼物盒(钱包入口);为空时不显示。
|
||||||
final VoidCallback? onGift;
|
final VoidCallback? onGift;
|
||||||
|
|
||||||
@@ -65,14 +70,7 @@ class RecommendTopNav extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
for (var index = 0; index < items.length; index++) ...[
|
for (var index = 0; index < items.length; index++) ...[
|
||||||
if (index > 0) const SizedBox(width: _tabGap),
|
if (index > 0) const SizedBox(width: _tabGap),
|
||||||
_tab(
|
_tab(items[index], shadows, context),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -120,47 +118,79 @@ class RecommendTopNav extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _tab({
|
Widget _tab(
|
||||||
required Key key,
|
(RecommendInnerTab, String, String) item,
|
||||||
required String code,
|
List<Shadow>? shadows,
|
||||||
required String label,
|
BuildContext context,
|
||||||
required bool selected,
|
) {
|
||||||
required List<Shadow>? shadows,
|
final (tab, label, code) = item;
|
||||||
required VoidCallback onTap,
|
final isSelected = selected == tab;
|
||||||
}) {
|
final position = pagePosition;
|
||||||
|
final progress = position == null
|
||||||
|
? null
|
||||||
|
: (1 - (position - tab.index).abs()).clamp(0.0, 1.0);
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
key: key,
|
key: Key('recommend-tab-$code'),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: onTap,
|
onTap: () => onSelected(tab),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。
|
// 补偿文字下方的 4pt 间距 + 3pt 下划线,让文字视觉中心与右侧图标对齐。
|
||||||
const SizedBox(height: 4 + _underlineHeight),
|
const SizedBox(height: 4 + _underlineHeight),
|
||||||
Text(
|
AnimatedNavLabel(
|
||||||
label,
|
label: label,
|
||||||
maxLines: 1,
|
selected: isSelected,
|
||||||
|
selectionProgress: progress,
|
||||||
|
inactiveColor: AppColors.homeTabInactive,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontSize: _tabFont,
|
fontSize: _tabFont,
|
||||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
height: 1.0,
|
height: 1.0,
|
||||||
shadows: shadows,
|
shadows: shadows,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Container(
|
_underline(
|
||||||
key: Key('recommend-tab-$code-underline'),
|
code,
|
||||||
width: _underlineWidth,
|
progress,
|
||||||
height: _underlineHeight,
|
isSelected,
|
||||||
decoration: BoxDecoration(
|
MediaQuery.disableAnimationsOf(context),
|
||||||
color: selected ? AppColors.brandRed : AppColors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(_underlineHeight / 2),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
// 职责:验证顶部标签双向过渡、固定布局和减少动画行为。
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||||
|
import 'package:fruitshorts/core/widgets/animated_nav_label.dart';
|
||||||
|
|
||||||
|
const _halfDuration = Duration(milliseconds: 90);
|
||||||
|
const _duration = Duration(milliseconds: 180);
|
||||||
|
const _style = TextStyle(fontSize: 20, color: AppColors.navSelected);
|
||||||
|
const _firstKey = Key('first');
|
||||||
|
const _secondKey = Key('second');
|
||||||
|
|
||||||
|
Widget _labels(bool firstSelected, {bool reduceMotion = false}) {
|
||||||
|
return MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: MediaQueryData(
|
||||||
|
devicePixelRatio: 3,
|
||||||
|
disableAnimations: reduceMotion,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
AnimatedNavLabel(
|
||||||
|
key: _firstKey,
|
||||||
|
label: '精选 Featured',
|
||||||
|
selected: firstSelected,
|
||||||
|
style: _style,
|
||||||
|
),
|
||||||
|
AnimatedNavLabel(
|
||||||
|
key: _secondKey,
|
||||||
|
label: '推荐 Rekomendasi',
|
||||||
|
selected: !firstSelected,
|
||||||
|
style: _style,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _scale(WidgetTester tester, Key key) {
|
||||||
|
final transform = tester.widget<Transform>(
|
||||||
|
find.descendant(of: find.byKey(key), matching: find.byType(Transform)),
|
||||||
|
);
|
||||||
|
return transform.transform.entry(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _color(WidgetTester tester, Key key) {
|
||||||
|
return tester
|
||||||
|
.widget<Text>(
|
||||||
|
find.descendant(of: find.byKey(key), matching: find.byType(Text)),
|
||||||
|
)
|
||||||
|
.style!
|
||||||
|
.color!;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('should_cross_animate_without_relayout_when_selection_changes', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_labels(true));
|
||||||
|
final firstRect = tester.getRect(find.byKey(_firstKey));
|
||||||
|
final secondRect = tester.getRect(find.byKey(_secondKey));
|
||||||
|
await tester.pumpWidget(_labels(false));
|
||||||
|
await tester.pump(_halfDuration);
|
||||||
|
|
||||||
|
expect(_scale(tester, _firstKey), inExclusiveRange(0.9, 1.0));
|
||||||
|
expect(_scale(tester, _secondKey), inExclusiveRange(0.9, 1.0));
|
||||||
|
expect(_color(tester, _firstKey), isNot(AppColors.navSelected));
|
||||||
|
expect(_color(tester, _firstKey), isNot(AppColors.textTabInactive));
|
||||||
|
expect(_color(tester, _secondKey), isNot(AppColors.navSelected));
|
||||||
|
expect(_color(tester, _secondKey), isNot(AppColors.textTabInactive));
|
||||||
|
expect(tester.getRect(find.byKey(_firstKey)), firstRect);
|
||||||
|
expect(tester.getRect(find.byKey(_secondKey)), secondRect);
|
||||||
|
await tester.pump(_duration);
|
||||||
|
expect(_scale(tester, _firstKey), closeTo(0.9, 0.0001));
|
||||||
|
expect(_scale(tester, _secondKey), 1);
|
||||||
|
expect(_color(tester, _secondKey), AppColors.navSelected);
|
||||||
|
expect(tester.getRect(find.byKey(_secondKey)), secondRect);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_continue_from_current_value_when_selection_reverses', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_labels(true));
|
||||||
|
await tester.pumpWidget(_labels(false));
|
||||||
|
await tester.pump(_halfDuration);
|
||||||
|
final scale = _scale(tester, _firstKey);
|
||||||
|
final color = _color(tester, _firstKey);
|
||||||
|
await tester.pumpWidget(_labels(true));
|
||||||
|
expect(_scale(tester, _firstKey), scale);
|
||||||
|
expect(_color(tester, _firstKey), color);
|
||||||
|
await tester.pump(_duration);
|
||||||
|
expect(_scale(tester, _firstKey), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_skip_transition_when_reduce_motion_is_enabled', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_labels(true, reduceMotion: true));
|
||||||
|
await tester.pumpWidget(_labels(false, reduceMotion: true));
|
||||||
|
await tester.pump();
|
||||||
|
expect(_scale(tester, _firstKey), closeTo(0.9, 0.0001));
|
||||||
|
expect(_color(tester, _secondKey), AppColors.navSelected);
|
||||||
|
expect(tester.binding.hasScheduledFrame, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
// 职责:首页顶部精选与底部推荐使用不同文案,覆盖支持的三种语言。
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/widgets/app_root_tab.dart';
|
||||||
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
|
import 'package:fruitshorts/pages/recommend/recommend_types.dart';
|
||||||
|
import 'package:fruitshorts/pages/recommend/widgets/recommend_top_nav.dart';
|
||||||
|
|
||||||
|
const _labels = [
|
||||||
|
('zh', '精选', '推荐', '剧单', '全部短剧'),
|
||||||
|
('en', 'Featured', 'Recommend', 'Playlists', 'All Dramas'),
|
||||||
|
('id', 'Unggulan', 'Rekomendasi', 'Daftar Putar', 'Semua'),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final (language, featured, recommend, playlists, all) in _labels) {
|
||||||
|
testWidgets(
|
||||||
|
'should_distinguish_featured_from_recommend_when_locale_is_$language',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(_host(language));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final top = find.byKey(const Key('recommend-top-nav'));
|
||||||
|
final bottom = find.byKey(const Key('tab-recommend'));
|
||||||
|
expect(
|
||||||
|
find.descendant(of: top, matching: find.text(featured)),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.descendant(of: top, matching: find.text(recommend)),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.descendant(of: bottom, matching: find.text(recommend)),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.descendant(of: top, matching: find.text(playlists)),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.descendant(of: top, matching: find.text(all)),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _host(String language) => MaterialApp(
|
||||||
|
locale: Locale(language),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: RecommendTopNav(
|
||||||
|
selected: RecommendInnerTab.feed,
|
||||||
|
onSelected: (_) {},
|
||||||
|
onSearch: () {},
|
||||||
|
),
|
||||||
|
bottomNavigationBar: AppRootBottomNav(
|
||||||
|
selected: AppRootTab.home,
|
||||||
|
onSelected: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -13,6 +13,8 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
|||||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||||
import 'package:fruitshorts/pages/player/player_view.dart';
|
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||||
|
import 'package:fruitshorts/pages/recommend/recommend_types.dart';
|
||||||
|
import 'package:fruitshorts/pages/recommend/widgets/recommend_top_nav.dart';
|
||||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ void main() {
|
|||||||
.toList();
|
.toList();
|
||||||
expect(centers, orderedEquals(centers.toList()..sort()));
|
expect(centers, orderedEquals(centers.toList()..sort()));
|
||||||
expect(find.byKey(const Key('recommend-section-hits')), findsNothing);
|
expect(find.byKey(const Key('recommend-section-hits')), findsNothing);
|
||||||
expect(find.text('Featured'), findsNothing);
|
expect(find.text('Featured'), findsOneWidget);
|
||||||
expect(find.text('Categories'), findsNothing);
|
expect(find.text('Categories'), findsNothing);
|
||||||
expect(find.text('Charts'), findsNothing);
|
expect(find.text('Charts'), findsNothing);
|
||||||
expect(find.byKey(const Key('mine-mini-player')), findsNothing);
|
expect(find.byKey(const Key('mine-mini-player')), findsNothing);
|
||||||
@@ -173,7 +175,7 @@ void main() {
|
|||||||
matching: find.text('Playlists'),
|
matching: find.text('Playlists'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(selected.style?.fontWeight, FontWeight.w700);
|
expect(selected.style?.fontWeight, FontWeight.w400);
|
||||||
expect(
|
expect(
|
||||||
tester.getRect(find.byKey(const Key('recommend-top-nav'))),
|
tester.getRect(find.byKey(const Key('recommend-top-nav'))),
|
||||||
topNavBefore,
|
topNavBefore,
|
||||||
@@ -191,6 +193,46 @@ void main() {
|
|||||||
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
|
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('should_follow_partial_swipe_before_selected_page_changes', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpApp(tester, _PagedCatalogRepository());
|
||||||
|
final pages = find.byKey(const Key('recommend-tab-page-view'));
|
||||||
|
final gesture = await tester.startGesture(tester.getCenter(pages));
|
||||||
|
await gesture.moveBy(const Offset(-30, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.moveBy(const Offset(-80, 0));
|
||||||
|
await tester.pump();
|
||||||
|
final nav = tester.widget<RecommendTopNav>(find.byType(RecommendTopNav));
|
||||||
|
expect(nav.pagePosition, inExclusiveRange(0.0, 0.5));
|
||||||
|
final underline = tester.widget<Container>(
|
||||||
|
find.byKey(const Key('recommend-tab-playlist-underline')),
|
||||||
|
);
|
||||||
|
final color = (underline.decoration! as BoxDecoration).color!;
|
||||||
|
expect(color.a, inExclusiveRange(0.0, 0.5));
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
_drainLayoutExceptions(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'should_keep_all_dramas_selected_during_direct_cross_page_click',
|
||||||
|
(tester) async {
|
||||||
|
await _pumpApp(tester, _PagedCatalogRepository());
|
||||||
|
await tester.ensureVisible(find.byKey(const Key('recommend-tab-all')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('recommend-tab-all')));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 70));
|
||||||
|
final nav = tester.widget<RecommendTopNav>(find.byType(RecommendTopNav));
|
||||||
|
expect(nav.pagePosition, inExclusiveRange(0.5, 1.5));
|
||||||
|
expect(nav.selected, RecommendInnerTab.allDramas);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
_drainLayoutExceptions(tester);
|
||||||
|
expect(find.byKey(const Key('all-dramas-grid')), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('should_load_next_page_without_expanding_page_size', (
|
testWidgets('should_load_next_page_without_expanding_page_size', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -67,10 +67,7 @@ void main() {
|
|||||||
tester.getCenter(searchEntry).dy,
|
tester.getCenter(searchEntry).dy,
|
||||||
closeTo(tester.getCenter(walletEntry).dy, 0.1),
|
closeTo(tester.getCenter(walletEntry).dy, 0.1),
|
||||||
);
|
);
|
||||||
expect(
|
expect(tester.getTopRight(walletEntry).dx, closeTo(390 - 8, 0.1));
|
||||||
tester.getTopRight(walletEntry).dx,
|
|
||||||
closeTo(390 - 8, 0.1),
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
tester.getTopRight(searchEntry).dx,
|
tester.getTopRight(searchEntry).dx,
|
||||||
closeTo(tester.getTopLeft(walletEntry).dx, 0.1),
|
closeTo(tester.getTopLeft(walletEntry).dx, 0.1),
|
||||||
@@ -160,7 +157,7 @@ void main() {
|
|||||||
expect(
|
expect(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const Key('tab-recommend')),
|
of: find.byKey(const Key('tab-recommend')),
|
||||||
matching: find.byIcon(Icons.celebration_outlined),
|
matching: find.byIcon(Icons.smart_display_outlined),
|
||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
@@ -191,7 +188,7 @@ void main() {
|
|||||||
expect(
|
expect(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const Key('tab-recommend')),
|
of: find.byKey(const Key('tab-recommend')),
|
||||||
matching: find.byIcon(Icons.celebration_rounded),
|
matching: find.byIcon(Icons.smart_display_rounded),
|
||||||
),
|
),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -198,6 +198,11 @@ void main() {
|
|||||||
posterUrl: '',
|
posterUrl: '',
|
||||||
genre: 'Romance',
|
genre: 'Romance',
|
||||||
category: 'Urban',
|
category: 'Urban',
|
||||||
|
types: [
|
||||||
|
{'en': 'Romance'},
|
||||||
|
{'en': 'Urban'},
|
||||||
|
{'en': 'Revenge'},
|
||||||
|
],
|
||||||
totalEpisodes: 8,
|
totalEpisodes: 8,
|
||||||
freeEpisodes: 8,
|
freeEpisodes: 8,
|
||||||
viewCount: 419000,
|
viewCount: 419000,
|
||||||
@@ -255,7 +260,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(seriesTitle.data, 'Feed Drama');
|
expect(seriesTitle.data, 'Feed Drama');
|
||||||
expect(find.text('Episode 2'), findsNothing);
|
expect(find.text('Episode 2'), findsNothing);
|
||||||
expect(seriesTitle.style?.fontSize, 17);
|
expect(seriesTitle.style?.fontSize, 18);
|
||||||
expect(seriesTitle.style?.fontWeight, FontWeight.w400);
|
expect(seriesTitle.style?.fontWeight, FontWeight.w400);
|
||||||
expect(seriesTitle.style?.color, AppColors.textPrimary);
|
expect(seriesTitle.style?.color, AppColors.textPrimary);
|
||||||
final seriesChevron = tester.widget<Icon>(
|
final seriesChevron = tester.widget<Icon>(
|
||||||
@@ -284,11 +289,13 @@ void main() {
|
|||||||
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
|
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('home-feed-tags')), findsOneWidget);
|
expect(find.byKey(const Key('home-feed-tags')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('home-feed-tag-Romance')), findsOneWidget);
|
expect(find.byKey(const Key('home-feed-tag-Romance')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-hot-badge')), findsNothing);
|
||||||
expect(find.byKey(const Key('home-feed-tag-Urban')), findsOneWidget);
|
expect(find.byKey(const Key('home-feed-tag-Urban')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('home-feed-tag-Revenge')), findsNothing);
|
||||||
final collapsedSynopsis = tester.widget<Text>(
|
final collapsedSynopsis = tester.widget<Text>(
|
||||||
find.byKey(const Key('home-feed-synopsis')),
|
find.byKey(const Key('home-feed-synopsis')),
|
||||||
);
|
);
|
||||||
expect(collapsedSynopsis.maxLines, 1);
|
expect(collapsedSynopsis.maxLines, 2);
|
||||||
expect(collapsedSynopsis.overflow, TextOverflow.ellipsis);
|
expect(collapsedSynopsis.overflow, TextOverflow.ellipsis);
|
||||||
expect(find.byKey(const Key('home-feed-synopsis-more')), findsOneWidget);
|
expect(find.byKey(const Key('home-feed-synopsis-more')), findsOneWidget);
|
||||||
await tester.tap(find.byKey(const Key('home-feed-synopsis-more')));
|
await tester.tap(find.byKey(const Key('home-feed-synopsis-more')));
|
||||||
@@ -302,8 +309,9 @@ void main() {
|
|||||||
.widget<Text>(find.byKey(const Key('home-feed-synopsis-expanded')))
|
.widget<Text>(find.byKey(const Key('home-feed-synopsis-expanded')))
|
||||||
.textSpan!
|
.textSpan!
|
||||||
.toPlainText(),
|
.toPlainText(),
|
||||||
allOf(contains('EP2'), contains('dangerous city')),
|
contains('dangerous city'),
|
||||||
);
|
);
|
||||||
|
expect(find.text('Episode 2 / 8'), findsOneWidget);
|
||||||
expect(
|
expect(
|
||||||
tester.widget<Text>(find.byKey(const Key('rail-like-count'))).data,
|
tester.widget<Text>(find.byKey(const Key('rail-like-count'))).data,
|
||||||
'1006',
|
'1006',
|
||||||
@@ -358,10 +366,10 @@ void main() {
|
|||||||
final likeRect = tester.getRect(find.byKey(const Key('rail-like')));
|
final likeRect = tester.getRect(find.byKey(const Key('rail-like')));
|
||||||
final commentsRect = tester.getRect(find.byKey(const Key('rail-comments')));
|
final commentsRect = tester.getRect(find.byKey(const Key('rail-comments')));
|
||||||
final shareRect = tester.getRect(find.byKey(const Key('rail-share')));
|
final shareRect = tester.getRect(find.byKey(const Key('rail-share')));
|
||||||
// 新版互动栏顺序:收藏 → 评论 → 喜欢 → 分享,间距 12。
|
// 互动栏顺序:收藏 → 评论 → 喜欢 → 分享,紧凑间距 6。
|
||||||
expect(commentsRect.top - saveRect.bottom, closeTo(12, 0.1));
|
expect(commentsRect.top - saveRect.bottom, closeTo(6, 0.1));
|
||||||
expect(likeRect.top - commentsRect.bottom, closeTo(12, 0.1));
|
expect(likeRect.top - commentsRect.bottom, closeTo(6, 0.1));
|
||||||
expect(shareRect.top - likeRect.bottom, closeTo(12, 0.1));
|
expect(shareRect.top - likeRect.bottom, closeTo(6, 0.1));
|
||||||
expect(
|
expect(
|
||||||
tester.getRect(find.byKey(const Key('home-feed-action-rail'))).right,
|
tester.getRect(find.byKey(const Key('home-feed-action-rail'))).right,
|
||||||
closeTo(
|
closeTo(
|
||||||
@@ -582,9 +590,13 @@ void main() {
|
|||||||
final overlayRect = tester.getRect(
|
final overlayRect = tester.getRect(
|
||||||
find.byKey(const Key('home-feed-gesture')),
|
find.byKey(const Key('home-feed-gesture')),
|
||||||
);
|
);
|
||||||
// 观看条与底部进度条共用同一组左右边距(各 20pt),宽度一致。
|
// 观看条与左侧信息等宽,进度条仍横跨整个页面,避开右侧互动栏。
|
||||||
expect(bar.left, closeTo(overlayRect.left + 20, 0.1));
|
expect(bar.left, closeTo(overlayRect.left + 20, 0.1));
|
||||||
expect(bar.width, closeTo(overlayRect.width - 40, 0.1));
|
final infoRect = tester.getRect(
|
||||||
|
find.byKey(const Key('home-feed-info-panel')),
|
||||||
|
);
|
||||||
|
expect(bar.width, closeTo(infoRect.width, 0.1));
|
||||||
|
expect(bar.right, closeTo(infoRect.right, 0.1));
|
||||||
expect(
|
expect(
|
||||||
bar.left,
|
bar.left,
|
||||||
closeTo(
|
closeTo(
|
||||||
@@ -594,9 +606,8 @@ void main() {
|
|||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
bar.width,
|
bar.width,
|
||||||
closeTo(
|
lessThan(
|
||||||
tester.getRect(find.byKey(const Key('home-feed-progress'))).width,
|
tester.getRect(find.byKey(const Key('home-feed-progress'))).width,
|
||||||
0.1,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// 进度线贴着浮层底部(线心距底 4pt),且位于观看条下方。
|
// 进度线贴着浮层底部(线心距底 4pt),且位于观看条下方。
|
||||||
@@ -609,10 +620,10 @@ void main() {
|
|||||||
tester
|
tester
|
||||||
.widget<Material>(find.byKey(const Key('home-feed-full-series-bg')))
|
.widget<Material>(find.byKey(const Key('home-feed-full-series-bg')))
|
||||||
.color,
|
.color,
|
||||||
AppColors.textPrimary.withValues(alpha: 0.15),
|
Colors.transparent,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
tester.getRect(find.textContaining('Watch full series')).center.dy,
|
tester.getRect(find.text('Watch all')).center.dy,
|
||||||
closeTo(bar.center.dy, 2),
|
closeTo(bar.center.dy, 2),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
// 职责:验证三种顶部导航接入稳定文字过渡并保留点击与选中语义。
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/content_category.dart';
|
||||||
|
import 'package:fruitshorts/core/widgets/animated_nav_label.dart';
|
||||||
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
|
import 'package:fruitshorts/pages/home/widgets/home_grid_header.dart';
|
||||||
|
import 'package:fruitshorts/pages/recommend/recommend_types.dart';
|
||||||
|
import 'package:fruitshorts/pages/recommend/widgets/recommend_channel_nav.dart';
|
||||||
|
import 'package:fruitshorts/pages/recommend/widgets/recommend_top_nav.dart';
|
||||||
|
|
||||||
|
const _midpoint = Duration(milliseconds: 90);
|
||||||
|
const _duration = Duration(milliseconds: 180);
|
||||||
|
const _categories = [
|
||||||
|
ContentCategory(id: 'featured', name: '精选'),
|
||||||
|
ContentCategory(id: 'new', name: '新剧'),
|
||||||
|
];
|
||||||
|
const _shortcuts = [
|
||||||
|
HomeGridCategoryShortcut(
|
||||||
|
label: '精选',
|
||||||
|
categoryName: 'featured',
|
||||||
|
categoryId: 'featured',
|
||||||
|
tab: HomePrimaryTab.featured,
|
||||||
|
),
|
||||||
|
HomeGridCategoryShortcut(
|
||||||
|
label: '新剧',
|
||||||
|
categoryName: 'new',
|
||||||
|
categoryId: 'new',
|
||||||
|
tab: HomePrimaryTab.newReleases,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Widget _host(Widget child) => MaterialApp(
|
||||||
|
locale: const Locale('zh'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(body: child),
|
||||||
|
);
|
||||||
|
|
||||||
|
double _scale(WidgetTester tester, Finder label) {
|
||||||
|
return tester
|
||||||
|
.widget<Transform>(
|
||||||
|
find.descendant(of: label, matching: find.byType(Transform)),
|
||||||
|
)
|
||||||
|
.transform
|
||||||
|
.entry(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _verifyTransition(
|
||||||
|
WidgetTester tester,
|
||||||
|
Widget Function(bool) build,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_host(build(true)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final labels = find.byType(AnimatedNavLabel);
|
||||||
|
final rects = [tester.getRect(labels.at(0)), tester.getRect(labels.at(1))];
|
||||||
|
await tester.pumpWidget(_host(build(false)));
|
||||||
|
await tester.pump(_midpoint);
|
||||||
|
expect(_scale(tester, labels.at(0)), inExclusiveRange(0.9, 1.0));
|
||||||
|
expect(_scale(tester, labels.at(1)), inExclusiveRange(0.9, 1.0));
|
||||||
|
expect(tester.getRect(labels.at(0)), rects[0]);
|
||||||
|
expect(tester.getRect(labels.at(1)), rects[1]);
|
||||||
|
await tester.pump(_duration);
|
||||||
|
expect(tester.getRect(labels.at(1)), rects[1]);
|
||||||
|
final semantics = tester.widget<Semantics>(
|
||||||
|
find.descendant(of: labels.at(1), matching: find.byType(Semantics)).first,
|
||||||
|
);
|
||||||
|
expect(semantics.properties.selected, isTrue);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('should_keep_channel_labels_regular_when_selection_changes', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
const categories = [
|
||||||
|
ContentCategory(id: 'featured', name: '精选'),
|
||||||
|
ContentCategory(id: 'weekly', name: '本周必看'),
|
||||||
|
];
|
||||||
|
for (final selected in categories) {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_host(
|
||||||
|
RecommendChannelNav(
|
||||||
|
categories: categories,
|
||||||
|
selectedId: selected.id,
|
||||||
|
onSelected: (_) {},
|
||||||
|
onSearch: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
for (final category in categories) {
|
||||||
|
final text = tester.widget<Text>(find.text(category.name));
|
||||||
|
expect(text.style?.fontWeight, FontWeight.w400);
|
||||||
|
expect(text.style?.fontSize, 17);
|
||||||
|
}
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_keep_home_labels_stable_when_category_changes', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
int? tapped;
|
||||||
|
HomeGridHeader header(bool first) => HomeGridHeader(
|
||||||
|
callbacks: HomeGridCallbacks(
|
||||||
|
onSearch: () {},
|
||||||
|
onOpenVip: () {},
|
||||||
|
onOpenWallet: () {},
|
||||||
|
onOpenFilter: () {},
|
||||||
|
),
|
||||||
|
categories: _shortcuts,
|
||||||
|
selectedIndex: first ? 0 : 1,
|
||||||
|
onSectionSelected: (index) => tapped = index,
|
||||||
|
);
|
||||||
|
await _verifyTransition(tester, header);
|
||||||
|
await tester.tap(find.byKey(const Key('home-primary-label-new')));
|
||||||
|
expect(tapped, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_keep_inner_nav_labels_stable_when_tab_changes', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
RecommendInnerTab? tapped;
|
||||||
|
RecommendTopNav nav(bool first) => RecommendTopNav(
|
||||||
|
selected: first ? RecommendInnerTab.feed : RecommendInnerTab.playlists,
|
||||||
|
onSelected: (tab) => tapped = tab,
|
||||||
|
onSearch: () {},
|
||||||
|
);
|
||||||
|
await _verifyTransition(tester, nav);
|
||||||
|
await tester.tap(find.byKey(const Key('recommend-tab-playlist')));
|
||||||
|
expect(tapped, RecommendInnerTab.playlists);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_keep_channel_labels_stable_when_selection_changes', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
String? tapped;
|
||||||
|
RecommendChannelNav nav(bool first) => RecommendChannelNav(
|
||||||
|
categories: _categories,
|
||||||
|
selectedId: first ? 'featured' : 'new',
|
||||||
|
onSelected: (id) => tapped = id,
|
||||||
|
onSearch: () {},
|
||||||
|
);
|
||||||
|
await _verifyTransition(tester, nav);
|
||||||
|
await tester.tap(find.byKey(const Key('recommend-tab-new')));
|
||||||
|
expect(tapped, 'new');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
// 职责:验证推荐页封面标签使用常规字重,保留原有样式与点击。
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
|
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||||
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
|
import 'package:fruitshorts/pages/recommend/widgets/home_recommend_content.dart';
|
||||||
|
|
||||||
|
const _tag = '热播';
|
||||||
|
const _fallback = '上新';
|
||||||
|
const _posterWidth = 110.0;
|
||||||
|
const _posterHeight = 230.0;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final tag in [_tag, '']) {
|
||||||
|
testWidgets(
|
||||||
|
'should_keep_badge_regular_when_using_${tag.isEmpty ? 'fallback' : 'tag'}',
|
||||||
|
(tester) async {
|
||||||
|
var taps = 0;
|
||||||
|
await tester.pumpWidget(_host(tag, () => taps++));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final label = find.text(tag.isEmpty ? _fallback : tag);
|
||||||
|
final text = tester.widget<Text>(label);
|
||||||
|
expect(text.style?.fontWeight, FontWeight.w400);
|
||||||
|
expect(text.style?.fontSize, 11);
|
||||||
|
expect(text.style?.color, AppColors.textPrimary);
|
||||||
|
final badge = tester.widget<Container>(
|
||||||
|
find.ancestor(of: label, matching: find.byType(Container)).first,
|
||||||
|
);
|
||||||
|
final decoration = badge.decoration! as BoxDecoration;
|
||||||
|
expect(decoration.color, AppColors.brandRed);
|
||||||
|
expect(decoration.borderRadius, BorderRadius.circular(6));
|
||||||
|
expect(decoration.boxShadow, isNull);
|
||||||
|
expect(
|
||||||
|
badge.padding,
|
||||||
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
);
|
||||||
|
final position = tester.widget<Positioned>(
|
||||||
|
find.ancestor(of: label, matching: find.byType(Positioned)).first,
|
||||||
|
);
|
||||||
|
expect(position.left, 8);
|
||||||
|
expect(position.top, 8);
|
||||||
|
await tester.tap(label);
|
||||||
|
expect(taps, 1);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _host(String tag, VoidCallback onTap) => MaterialApp(
|
||||||
|
locale: const Locale('zh'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SizedBox(
|
||||||
|
width: _posterWidth,
|
||||||
|
height: _posterHeight,
|
||||||
|
child: HomePosterCard(
|
||||||
|
drama: Drama(
|
||||||
|
id: 'poster',
|
||||||
|
title: '测试短剧',
|
||||||
|
synopsis: '',
|
||||||
|
posterUrl: '',
|
||||||
|
tag: tag,
|
||||||
|
),
|
||||||
|
badge: _fallback,
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
// 职责:验证首页跨页动画的目标保留、连点打断和减少动画行为。
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/pages/recommend/home_tab_motion_controller.dart';
|
||||||
|
|
||||||
|
const _midpoint = Duration(milliseconds: 90);
|
||||||
|
const _finish = Duration(milliseconds: 300);
|
||||||
|
|
||||||
|
Widget _pager(HomeTabMotionController controller, List<int> accepted) =>
|
||||||
|
MaterialApp(
|
||||||
|
home: PageView(
|
||||||
|
controller: controller,
|
||||||
|
onPageChanged: (index) {
|
||||||
|
if (controller.acceptsSelection(index)) accepted.add(index);
|
||||||
|
},
|
||||||
|
children: const [
|
||||||
|
ColoredBox(color: Colors.black),
|
||||||
|
ColoredBox(color: Colors.red),
|
||||||
|
ColoredBox(color: Colors.grey),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets(
|
||||||
|
'should_ignore_intermediate_page_when_clicking_across_two_pages',
|
||||||
|
(tester) async {
|
||||||
|
int? settled;
|
||||||
|
final controller = HomeTabMotionController(
|
||||||
|
onSettled: (index) => settled = index,
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
final accepted = <int>[];
|
||||||
|
await tester.pumpWidget(_pager(controller, accepted));
|
||||||
|
final transition = controller.selectPage(2, reduceMotion: false);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(_midpoint);
|
||||||
|
expect(controller.pagePosition, inExclusiveRange(0.0, 2.0));
|
||||||
|
expect(controller.acceptsSelection(1), isFalse);
|
||||||
|
expect(accepted, isNot(contains(1)));
|
||||||
|
await tester.pump(_finish);
|
||||||
|
await transition;
|
||||||
|
expect(controller.pagePosition, 2);
|
||||||
|
expect(settled, 2);
|
||||||
|
expect(controller.acceptsSelection(1), isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('should_keep_last_target_when_clicks_interrupt_each_other', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
int? settled;
|
||||||
|
final controller = HomeTabMotionController(
|
||||||
|
onSettled: (index) => settled = index,
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
await tester.pumpWidget(_pager(controller, []));
|
||||||
|
final first = controller.selectPage(2, reduceMotion: false);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(_midpoint);
|
||||||
|
final last = controller.selectPage(0, reduceMotion: false);
|
||||||
|
await tester.pump();
|
||||||
|
expect(controller.acceptsSelection(2), isFalse);
|
||||||
|
await tester.pump(_finish);
|
||||||
|
await Future.wait([first, last]);
|
||||||
|
expect(controller.pagePosition, 0);
|
||||||
|
expect(settled, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_jump_to_target_when_reduced_motion_is_enabled', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
int? settled;
|
||||||
|
final controller = HomeTabMotionController(
|
||||||
|
onSettled: (index) => settled = index,
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
await tester.pumpWidget(_pager(controller, []));
|
||||||
|
await controller.selectPage(2, reduceMotion: true);
|
||||||
|
expect(controller.pagePosition, 2);
|
||||||
|
expect(settled, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_release_target_when_user_drag_interrupts_animation', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final controller = HomeTabMotionController(onSettled: (_) {});
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
await tester.pumpWidget(_pager(controller, []));
|
||||||
|
final transition = controller.selectPage(2, reduceMotion: false);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(_midpoint);
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(find.byType(PageView)),
|
||||||
|
);
|
||||||
|
await gesture.moveBy(const Offset(60, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await transition;
|
||||||
|
expect(controller.acceptsSelection(0), isTrue);
|
||||||
|
expect(controller.acceptsSelection(1), isTrue);
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -44,11 +44,11 @@ void main() {
|
|||||||
final recommendText = tester.widget<Text>(
|
final recommendText = tester.widget<Text>(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
of: find.byKey(const Key('recommend-tab-recommend')),
|
of: find.byKey(const Key('recommend-tab-recommend')),
|
||||||
matching: find.text('Recommend'),
|
matching: find.text('Featured'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(recommendText.style?.fontSize, 17);
|
expect(recommendText.style?.fontSize, 18);
|
||||||
expect(recommendText.style?.fontWeight, FontWeight.w700);
|
expect(recommendText.style?.fontWeight, FontWeight.w400);
|
||||||
expect(recommendText.style?.color, AppColors.textPrimary);
|
expect(recommendText.style?.color, AppColors.textPrimary);
|
||||||
final playlistText = tester.widget<Text>(
|
final playlistText = tester.widget<Text>(
|
||||||
find.descendant(
|
find.descendant(
|
||||||
@@ -56,10 +56,17 @@ void main() {
|
|||||||
matching: find.text('Playlists'),
|
matching: find.text('Playlists'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(playlistText.style?.fontSize, 17);
|
expect(playlistText.style?.fontSize, 18);
|
||||||
|
// 字形度量固定,未选中态靠绘制缩放和灰色区分,切换时不重新排版。
|
||||||
expect(playlistText.style?.fontWeight, FontWeight.w400);
|
expect(playlistText.style?.fontWeight, FontWeight.w400);
|
||||||
// 新版顶部 tab 未选中态也用主文字色,选中态只靠字重和下划线区分。
|
expect(playlistText.style?.color, AppColors.homeTabInactive);
|
||||||
expect(playlistText.style?.color, AppColors.textPrimary);
|
final playlistScale = tester.widget<Transform>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('recommend-tab-playlist')),
|
||||||
|
matching: find.byType(Transform),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(playlistScale.transform.entry(0, 0), closeTo(0.9, 0.0001));
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
.widget<IconButton>(find.byKey(const Key('recommend-search')))
|
.widget<IconButton>(find.byKey(const Key('recommend-search')))
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
// 职责:验证首页三标签文字和下划线随页面进度连续变化。
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||||
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
|
import 'package:fruitshorts/pages/recommend/recommend_types.dart';
|
||||||
|
import 'package:fruitshorts/pages/recommend/widgets/recommend_top_nav.dart';
|
||||||
|
|
||||||
|
const _pagePosition = 0.35;
|
||||||
|
const _duration = Duration(milliseconds: 90);
|
||||||
|
const _epsilon = 0.0001;
|
||||||
|
|
||||||
|
Widget _host({
|
||||||
|
double? position,
|
||||||
|
RecommendInnerTab tab = RecommendInnerTab.feed,
|
||||||
|
}) {
|
||||||
|
return MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: RecommendTopNav(
|
||||||
|
selected: tab,
|
||||||
|
pagePosition: position,
|
||||||
|
onSelected: (_) {},
|
||||||
|
onSearch: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _scale(WidgetTester tester, String tab) => tester
|
||||||
|
.widget<Transform>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(Key('recommend-tab-$tab')),
|
||||||
|
matching: find.byType(Transform),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.transform
|
||||||
|
.entry(0, 0);
|
||||||
|
|
||||||
|
Color _underline(WidgetTester tester, String tab) {
|
||||||
|
final line = tester.widget<Container>(
|
||||||
|
find.byKey(Key('recommend-tab-$tab-underline')),
|
||||||
|
);
|
||||||
|
return (line.decoration! as BoxDecoration).color!;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets(
|
||||||
|
'should_brighten_only_inactive_home_tabs_when_selection_changes',
|
||||||
|
(tester) async {
|
||||||
|
const inactive = Color(0xFFC7C7CC);
|
||||||
|
const codes = ['recommend', 'playlist', 'all'];
|
||||||
|
for (final tab in RecommendInnerTab.values) {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_host(position: tab.index.toDouble(), tab: tab),
|
||||||
|
);
|
||||||
|
for (final (index, code) in codes.indexed) {
|
||||||
|
final text = tester.widget<Text>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(Key('recommend-tab-$code')),
|
||||||
|
matching: find.byType(Text),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
text.style?.color,
|
||||||
|
index == tab.index ? AppColors.textPrimary : inactive,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await tester.pumpWidget(_host(position: _pagePosition));
|
||||||
|
final text = tester.widget<Text>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('recommend-tab-playlist')),
|
||||||
|
matching: find.byType(Text),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
text.style?.color,
|
||||||
|
Color.lerp(inactive, AppColors.textPrimary, _pagePosition),
|
||||||
|
);
|
||||||
|
expect(AppColors.textTabInactive, const Color(0xFFB0B0B0));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('should_keep_regular_enlarged_labels_for_every_selection', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
for (final tab in RecommendInnerTab.values) {
|
||||||
|
await tester.pumpWidget(_host(position: tab.index.toDouble(), tab: tab));
|
||||||
|
for (final code in ['recommend', 'playlist', 'all']) {
|
||||||
|
final text = tester.widget<Text>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(Key('recommend-tab-$code')),
|
||||||
|
matching: find.byType(Text),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(text.style?.fontSize, 18);
|
||||||
|
expect(text.style?.fontWeight, FontWeight.w400);
|
||||||
|
}
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_follow_page_position_without_delayed_label_animation', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_host(position: 0));
|
||||||
|
final first = tester.getRect(
|
||||||
|
find.byKey(const Key('recommend-tab-recommend')),
|
||||||
|
);
|
||||||
|
final second = tester.getRect(
|
||||||
|
find.byKey(const Key('recommend-tab-playlist')),
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_host(position: _pagePosition, tab: RecommendInnerTab.playlists),
|
||||||
|
);
|
||||||
|
expect(_scale(tester, 'recommend'), closeTo(0.965, _epsilon));
|
||||||
|
expect(_scale(tester, 'playlist'), closeTo(0.935, _epsilon));
|
||||||
|
expect(_underline(tester, 'recommend').a, closeTo(0.65, _epsilon));
|
||||||
|
expect(_underline(tester, 'playlist').a, closeTo(0.35, _epsilon));
|
||||||
|
expect(_underline(tester, 'all').a, 0);
|
||||||
|
expect(
|
||||||
|
tester.getRect(find.byKey(const Key('recommend-tab-recommend'))),
|
||||||
|
first,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.getRect(find.byKey(const Key('recommend-tab-playlist'))),
|
||||||
|
second,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(_host(position: 0));
|
||||||
|
expect(_scale(tester, 'recommend'), 1);
|
||||||
|
expect(_underline(tester, 'recommend'), AppColors.brandRed);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('should_fade_both_underlines_when_used_without_page_position', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_host());
|
||||||
|
await tester.pumpWidget(_host(tab: RecommendInnerTab.playlists));
|
||||||
|
await tester.pump(_duration);
|
||||||
|
expect(_underline(tester, 'recommend').a, inExclusiveRange(0.0, 1.0));
|
||||||
|
expect(_underline(tester, 'playlist').a, inExclusiveRange(0.0, 1.0));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user