feat(recommend): 轮播只保留标题简介并重做顶部导航
This commit is contained in:
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user