feat(recommend): 轮播只保留标题简介并重做顶部导航

This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-30 17:14:34 +08:00
1 parent 53e8c924a6
commit 9ef8eb15b0
6 files changed
+180 -205

No files matched your search

@@ -3,16 +3,13 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/models/home_feed.dart';
import '../../../core/data/services/favorite_controller.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
/// 推荐页顶部静态头图轮播:封面 + 居中剧名/简介 + 立即观看/收藏 + 轮播点。
/// 推荐页顶部静态头图轮播:封面 + 底部剧名/简介 + 轮播点。
class HomeHeroCarousel extends StatefulWidget {
const HomeHeroCarousel({
super.key,
@@ -27,7 +24,7 @@ class HomeHeroCarousel extends StatefulWidget {
/// 按 banner 关联到的剧目;为空时只展示头图。
final Drama? Function(FeedBanner banner) dramaFor;
/// 点击「立即观看」或直接点按头图。
/// 点击头图进入播放。
final ValueChanged<Drama> onWatch;
final bool active;
@@ -44,7 +41,12 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
static const Duration _pageDuration = Duration(milliseconds: 480);
static const double _dotSize = 4;
static const double _dotGap = 9;
static const double _ctaHeight = 38;
/// 轮播点距底边的距离。
static const double _dotBottom = 14;
/// 文案区距底边:让出轮播点区域(底距 + 点直径)再留一段呼吸空间。
static const double _copyBottomInset = _dotBottom + _dotSize + 18;
/// 无限轮播:PageView 用虚拟大页码,取模映射到真实 banner,首尾天然相接。
static const int _basePage = 1000;
@@ -164,7 +166,12 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
itemBuilder: (context, page) =>
_bannerPage(context, page % widget.banners.length),
),
Positioned(left: 0, right: 0, bottom: 14, child: _dots(context)),
Positioned(
left: 0,
right: 0,
bottom: _dotBottom,
child: _dots(context),
),
],
),
),
@@ -199,16 +206,13 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
),
),
if (drama != null)
Align(
alignment: const Alignment(0, 0.6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: _HeroCopy(
key: Key('home-hero-copy-$index'),
drama: drama,
ctaHeight: _ctaHeight,
onWatch: () => widget.onWatch(drama),
),
Positioned(
left: 28,
right: 28,
bottom: _copyBottomInset,
child: _HeroCopy(
key: Key('home-hero-copy-$index'),
drama: drama,
),
),
if (drama != null)
@@ -251,30 +255,17 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
}
}
/// Hero 中部居中文案与按钮组。
/// Hero 底部居中文案:剧名 + 简介。
class _HeroCopy extends StatelessWidget {
const _HeroCopy({
super.key,
required this.drama,
required this.ctaHeight,
required this.onWatch,
});
const _HeroCopy({super.key, required this.drama});
final Drama drama;
final double ctaHeight;
final VoidCallback onWatch;
static const Color _titleCream = Color(0xFFE9D3B8);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final lang = Localizations.localeOf(context).languageCode;
final types = drama.localizedTypeLabels(lang);
final meta = [
if (types.isNotEmpty) types.first,
l10n.totalEpisodesShort(drama.episodeCount),
].join(' · ');
return Column(
key: const Key('home-hero-copy'),
@@ -287,24 +278,13 @@ class _HeroCopy extends StatelessWidget {
textAlign: TextAlign.center,
style: const TextStyle(
color: _titleCream,
fontSize: 28,
fontWeight: FontWeight.w600,
fontSize: 22,
fontWeight: FontWeight.w400,
height: 1.15,
shadows: [Shadow(color: Color(0x80000000), blurRadius: 12)],
),
),
const SizedBox(height: 8),
Text(
meta,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.textPrimary.withValues(alpha: 0.92),
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: 10),
Text(
drama.localizedSynopsis(lang),
maxLines: 1,
@@ -317,108 +297,7 @@ class _HeroCopy extends StatelessWidget {
shadows: const [Shadow(color: Color(0x80000000), blurRadius: 8)],
),
),
const SizedBox(height: 20),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_WatchButton(
height: ctaHeight,
label: l10n.watchNowAction,
onTap: onWatch,
),
const SizedBox(width: 12),
_FavoriteCircle(size: ctaHeight, drama: drama),
],
),
],
);
}
}
class _WatchButton extends StatelessWidget {
const _WatchButton({
required this.height,
required this.label,
required this.onTap,
});
final double height;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.brandRed,
borderRadius: BorderRadius.circular(height / 2),
child: InkWell(
key: const Key('home-hero-watch'),
borderRadius: BorderRadius.circular(height / 2),
onTap: onTap,
child: SizedBox(
height: height,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.play_arrow_rounded,
size: 20,
color: AppColors.textPrimary,
),
const SizedBox(width: 2),
Text(
label,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
);
}
}
class _FavoriteCircle extends StatelessWidget {
const _FavoriteCircle({required this.size, required this.drama});
final double size;
final Drama drama;
@override
Widget build(BuildContext context) {
final favorites = context.watch<FavoriteController>();
final saved = favorites.isSaved(drama.id);
return Material(
color: saved ? AppColors.brandRed : AppColors.transparent,
shape: CircleBorder(
side: BorderSide(
color: saved
? AppColors.brandRed
: AppColors.textPrimary.withValues(alpha: 0.85),
width: 1.5,
),
),
child: InkWell(
key: const Key('home-hero-favorite'),
customBorder: const CircleBorder(),
onTap: () => unawaited(favorites.toggle(drama.id, card: drama)),
child: SizedBox(
width: size,
height: size,
child: Icon(
saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
size: 20,
color: AppColors.textPrimary,
),
),
),
);
}
}
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
import '../recommend_types.dart';
import 'search_gift_glyphs.dart';
@@ -10,7 +11,7 @@ import 'search_gift_glyphs.dart';
const double _navHeight = 56;
const double _tabGap = 20;
const double _tabFont = 17;
const double _searchHitSize = 40;
const double _maxNavigationTextScale = 1.3;
const double _searchIconSize = 26;
const double _giftIconSize = 26;
const double _underlineWidth = 26;
@@ -47,68 +48,74 @@ class RecommendTopNav extends StatelessWidget {
final shadows = onVideo
? const [Shadow(color: AppColors.mediaBlack, blurRadius: 6)]
: null;
return SizedBox(
key: const Key('recommend-top-nav'),
height: height,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(left: 20, right: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
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),
),
return MediaQuery.withClampedTextScaling(
maxScaleFactor: _maxNavigationTextScale,
child: SizedBox(
key: const Key('recommend-top-nav'),
height: height,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(left: 20, right: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
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),
),
],
],
],
),
),
),
),
SizedBox.square(
dimension: _searchHitSize,
child: IconButton(
key: const Key('recommend-search'),
tooltip: l10n.search,
onPressed: onSearch,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: _searchHitSize,
height: _searchHitSize,
),
iconSize: _searchIconSize,
color: AppColors.textPrimary,
icon: SearchGlyph(size: _searchIconSize, color: AppColors.textPrimary),
),
),
if (onGift != null)
SizedBox.square(
dimension: _searchHitSize,
dimension: AppDimens.tapTarget,
child: IconButton(
key: const Key('recommend-gift'),
tooltip: l10n.wallet,
onPressed: onGift,
key: const Key('recommend-search'),
tooltip: l10n.search,
onPressed: onSearch,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: _searchHitSize,
height: _searchHitSize,
width: AppDimens.tapTarget,
height: AppDimens.tapTarget,
),
iconSize: _searchIconSize,
color: AppColors.textPrimary,
icon: SearchGlyph(
size: _searchIconSize,
color: AppColors.textPrimary,
),
iconSize: _giftIconSize,
icon: GiftGlyph(size: _giftIconSize),
),
),
const SizedBox(width: 8),
],
if (onGift != null)
SizedBox.square(
dimension: AppDimens.tapTarget,
child: IconButton(
key: const Key('recommend-gift'),
tooltip: l10n.wallet,
onPressed: onGift,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: AppDimens.tapTarget,
height: AppDimens.tapTarget,
),
iconSize: _giftIconSize,
icon: GiftGlyph(size: _giftIconSize),
),
),
const SizedBox(width: 8),
],
),
),
);
}
+2 -2
View File
@@ -90,11 +90,11 @@ void main() {
expect(searchButton.iconSize, 26);
expect(
tester.getSize(find.byKey(const Key('recommend-search'))),
const Size.square(40),
const Size.square(AppDimens.tapTarget),
);
expect(
tester.getSize(find.byKey(const Key('recommend-gift'))),
const Size.square(40),
const Size.square(AppDimens.tapTarget),
);
expect(
tester.getCenter(find.byKey(const Key('recommend-search'))).dy,
+36 -2
View File
@@ -2,6 +2,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
@@ -12,7 +13,14 @@ const List<FeedBanner> _banners = [
FeedBanner(id: '2', imageUrl: 'https://cdn.test/b.png'),
];
Widget _harness(List<FeedBanner> banners) {
const Drama _drama = Drama(
id: 'd1',
title: 'City Lights',
synopsis: 'A quiet city romance.',
posterUrl: 'https://cdn.test/d.png',
);
Widget _harness(List<FeedBanner> banners, {Drama? drama}) {
return MaterialApp(
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
@@ -20,7 +28,7 @@ Widget _harness(List<FeedBanner> banners) {
home: Scaffold(
body: HomeHeroCarousel(
banners: banners,
dramaFor: (_) => null,
dramaFor: (_) => drama,
onWatch: (_) {},
),
),
@@ -69,6 +77,32 @@ void main() {
expect(_dotColor(tester, 0), AppColors.brandRed);
});
testWidgets('should_keep_only_title_and_synopsis_above_dots', (tester) async {
await tester.pumpWidget(_harness(_banners, drama: _drama));
await tester.pumpAndSettle();
final copy = find.byKey(const Key('home-hero-copy-0'));
expect(copy, findsOneWidget);
expect(
find.descendant(of: copy, matching: find.text('City Lights')),
findsOneWidget,
);
expect(
find.descendant(
of: copy,
matching: find.text('A quiet city romance.'),
),
findsOneWidget,
);
expect(find.byKey(const Key('home-hero-watch')), findsNothing);
expect(find.byKey(const Key('home-hero-favorite')), findsNothing);
// 文案块整体位于轮播点上方,给进度点留出间距。
final copyRect = tester.getRect(copy);
final dotRect = tester.getRect(find.byKey(const Key('home-hero-dot-0')));
expect(copyRect.bottom, lessThanOrEqualTo(dotRect.top));
});
testWidgets('should_not_loop_when_only_one_banner', (tester) async {
await tester.pumpWidget(
_harness(const [FeedBanner(id: '1', imageUrl: 'https://cdn.test/a.png')]),
@@ -60,7 +60,7 @@ void main() {
expect(fixture.controller.refreshCalls, 2);
});
testWidgets('should_auto_retry_three_times_then_show_network_error', (
testWidgets('should_allow_manual_retry_after_auto_retries_are_exhausted', (
tester,
) async {
final fixture = await _pumpFailingRecommend(tester);
@@ -89,7 +89,14 @@ void main() {
expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing);
expect(find.byKey(const Key('recommend-feed-empty')), findsOneWidget);
expect(find.text(l10n.networkError), findsOneWidget);
expect(find.text(l10n.retry), findsNothing);
expect(find.text(l10n.retry), findsOneWidget);
await tester.tap(find.byKey(const Key('recommend-feed-retry')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 1));
expect(fixture.controller.refreshCalls, 5);
expect(find.byKey(const Key('recommend-feed-skeleton')), findsOneWidget);
});
testWidgets('should_cancel_pending_auto_retry_when_disposed', (tester) async {
@@ -0,0 +1,48 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/theme/app_dimens.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';
void main() {
testWidgets('should_keep_large_text_and_actions_inside_narrow_top_nav', (
tester,
) async {
tester.view.physicalSize = const Size(320, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
locale: const Locale('id'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: MediaQuery(
data: const MediaQueryData(textScaler: TextScaler.linear(2)),
child: Scaffold(
body: RecommendTopNav(
selected: RecommendInnerTab.feed,
onSelected: (_) {},
onSearch: () {},
onGift: () {},
),
),
),
),
);
expect(tester.takeException(), isNull);
expect(
tester.getSize(find.byKey(const Key('recommend-search'))),
const Size.square(AppDimens.tapTarget),
);
expect(
tester.getSize(find.byKey(const Key('recommend-gift'))),
const Size.square(AppDimens.tapTarget),
);
});
}