merge: 合并 K31-B 首页频道与轮播改版

This commit is contained in:
Zhou Weidong committed 2026-10-20 12:45:16 +08:00
commit 290b92c6a7
3 files changed
+176 -65

No files matched your search

@@ -27,6 +27,7 @@ class FeedBannerCarousel extends StatefulWidget {
required this.onOpen,
this.active = true,
this.dramaFor,
this.flush = false,
});
final List<FeedBanner> banners;
@@ -34,6 +35,9 @@ class FeedBannerCarousel extends StatefulWidget {
final bool active;
final Drama? Function(FeedBanner banner)? dramaFor;
/// 首页 hero 铺满屏幕并去掉圆角;发现页保持默认 false。
final bool flush;
@override
State<FeedBannerCarousel> createState() => _FeedBannerCarouselState();
}
@@ -161,10 +165,14 @@ class _FeedBannerCarouselState extends State<FeedBannerCarousel> {
final banner = widget.banners[index];
final dramaId = banner.dramaId;
final drama = widget.dramaFor?.call(banner);
final radius = widget.flush
? 0.0
: (drama == null
? AppDimens.authCardRadius
: AppDimens.heroBannerRadius);
final borderRadius = BorderRadius.circular(radius);
final card = ClipRRect(
borderRadius: BorderRadius.circular(
drama == null ? AppDimens.authCardRadius : AppDimens.heroBannerRadius,
),
borderRadius: borderRadius,
child: Stack(
fit: StackFit.expand,
children: [
@@ -225,11 +233,7 @@ class _FeedBannerCarouselState extends State<FeedBannerCarousel> {
? card
: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(
drama == null
? AppDimens.authCardRadius
: AppDimens.heroBannerRadius,
),
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
child: InkWell(
key: Key('feed-banner-$index'),
+106 -40
View File
@@ -20,6 +20,17 @@ import 'home_browse_logic.dart';
import 'widgets/home_browse_header.dart';
import 'widgets/home_drama_grid.dart';
const double _channelRowHeight = 44;
const double _channelGap = 20;
const double _channelUnderlineWidth = 18;
const double _channelUnderlineHeight = 2;
const double _channelUnderlineGap = 4;
const double _channelOverlayTop = 8;
const double _channelFontSize = 15;
const List<Shadow> _channelTextShadows = [
Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)),
];
/// 浏览型首页:频道、Hero、继续观看与剧目目录。
class HomeBrowseView extends StatefulWidget {
const HomeBrowseView({
@@ -116,8 +127,10 @@ class _HomeBrowseViewState extends State<HomeBrowseView> {
? _fallbackBanners(source)
: catalog.banners;
return [
_channels(l10n),
if (banners.isNotEmpty) _hero(banners, source),
if (banners.isNotEmpty)
_heroWithChannels(l10n, banners, source)
else
_channels(l10n),
if (continueItem != null) ...[
_continueHeader(l10n),
ContinueWatchingCard(
@@ -182,7 +195,30 @@ class _HomeBrowseViewState extends State<HomeBrowseView> {
),
);
Widget _channels(AppLocalizations l10n) {
/// 频道行叠加在 hero 轮播之上;无 banner 时作为普通行显示。
Widget _heroWithChannels(
AppLocalizations l10n,
List<FeedBanner> banners,
List<Drama> dramas,
) {
return SizedBox(
key: const Key('home-hero-section'),
height: AppDimens.heroBannerHeight,
child: Stack(
children: [
Positioned.fill(child: _hero(banners, dramas)),
Positioned(
top: _channelOverlayTop,
left: 0,
right: 0,
child: _channels(l10n, overImage: true),
),
],
),
);
}
Widget _channels(AppLocalizations l10n, {bool overImage = false}) {
final entries = [
(HomeChannel.hot, l10n.hot),
(HomeChannel.free, l10n.free),
@@ -190,60 +226,90 @@ class _HomeBrowseViewState extends State<HomeBrowseView> {
(HomeChannel.ranking, l10n.rankingTab),
];
return SizedBox(
height: AppDimens.buttonMedium,
key: const Key('home-channels'),
height: _channelRowHeight,
child: ListView.separated(
key: const Key('home-channels'),
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.pageHorizontal,
),
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(width: AppDimens.spaceSm),
separatorBuilder: (_, _) => const SizedBox(width: _channelGap),
itemBuilder: (context, index) {
final entry = entries[index];
final selected = logic.channel == entry.$1;
return ChoiceChip(
key: Key('home-channel-${entry.$1.name}'),
label: Text(entry.$2),
return _channelItem(
entry.$1,
entry.$2,
selected: selected,
showCheckmark: false,
onSelected: (_) => logic.selectChannel(entry.$1),
backgroundColor: AppColors.card,
selectedColor: AppColors.blue,
side: BorderSide(
color: selected ? AppColors.blue : AppColors.border,
),
labelStyle: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.onSurface,
fontSize: AppDimens.chipFont,
fontWeight: FontWeight.w700,
),
padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd),
shape: const StadiumBorder(),
overImage: overImage,
);
},
),
);
}
Widget _channelItem(
HomeChannel channel,
String label, {
required bool selected,
required bool overImage,
}) {
final color = overImage
? (selected ? Colors.white : Colors.white70)
: (selected ? AppColors.onSurface : AppColors.muted);
final underlineColor = selected
? (overImage ? Colors.white : AppColors.blue)
: Colors.transparent;
return Semantics(
button: true,
selected: selected,
child: GestureDetector(
key: Key('home-channel-${channel.name}'),
behavior: HitTestBehavior.opaque,
onTap: () => logic.selectChannel(channel),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
maxLines: 1,
style: TextStyle(
color: color,
fontSize: _channelFontSize,
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
shadows: overImage ? _channelTextShadows : null,
),
),
const SizedBox(height: _channelUnderlineGap),
Container(
key: Key('home-channel-underline-${channel.name}'),
width: _channelUnderlineWidth,
height: _channelUnderlineHeight,
decoration: BoxDecoration(
color: underlineColor,
borderRadius: BorderRadius.circular(
_channelUnderlineHeight / 2,
),
),
),
],
),
),
);
}
Widget _hero(List<FeedBanner> banners, List<Drama> dramas) {
return Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.pageHorizontal,
AppDimens.spaceMd,
AppDimens.pageHorizontal,
0,
),
child: FeedBannerCarousel(
key: const Key('home-hero-banner'),
banners: banners,
active: widget.active,
dramaFor: (banner) => _dramaForBanner(banner, dramas),
onOpen: (banner) {
final drama = _dramaForBanner(banner, dramas);
if (drama != null) unawaited(widget.onOpenDrama(drama));
},
),
return FeedBannerCarousel(
key: const Key('home-hero-banner'),
banners: banners,
active: widget.active,
flush: true,
dramaFor: (banner) => _dramaForBanner(banner, dramas),
onOpen: (banner) {
final drama = _dramaForBanner(banner, dramas);
if (drama != null) unawaited(widget.onOpenDrama(drama));
},
);
}
+58 -17
View File
@@ -8,7 +8,6 @@ import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/pages/player/player_view.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';
@@ -49,27 +48,55 @@ void main() {
expect(find.text('Coming soon'), findsOneWidget);
});
testWidgets('should_switch_home_channel_when_chip_is_tapped', (tester) async {
testWidgets('should_switch_home_channel_when_underline_tab_is_tapped', (
tester,
) async {
await _pumpApp(tester);
expect(_chip(tester, 'home-channel-hot').selected, isTrue);
expect(_chip(tester, 'home-channel-free').selected, isFalse);
expect(_chip(tester, 'home-channel-hot').selectedColor, AppColors.blue);
expect(_chip(tester, 'home-channel-free').backgroundColor, AppColors.card);
expect(
_chip(tester, 'home-channel-free').labelStyle?.color,
AppColors.onSurface,
);
// 悬浮在轮播图上:选中白色下划线,未选中透明占位。
expect(_underlineColor(tester, 'hot'), Colors.white);
expect(_underlineColor(tester, 'free'), Colors.transparent);
expect(_labelStyle(tester, 'hot').fontWeight, FontWeight.w800);
expect(_labelStyle(tester, 'free').fontWeight, FontWeight.w600);
expect(_labelStyle(tester, 'hot').color, Colors.white);
expect(_labelStyle(tester, 'free').color, Colors.white70);
await tester.tap(find.byKey(const Key('home-channel-free')));
await tester.pumpAndSettle();
expect(_chip(tester, 'home-channel-hot').selected, isFalse);
expect(_chip(tester, 'home-channel-free').selected, isTrue);
expect(
_chip(tester, 'home-channel-free').labelStyle?.color,
AppColors.onPrimary,
expect(_underlineColor(tester, 'hot'), Colors.transparent);
expect(_underlineColor(tester, 'free'), Colors.white);
expect(_labelStyle(tester, 'free').fontWeight, FontWeight.w800);
});
testWidgets('should_render_flush_hero_with_channels_overlaid', (
tester,
) async {
await _pumpApp(tester);
final hero = tester.getRect(find.byKey(const Key('home-hero-banner')));
expect(hero.width, 390);
expect(hero.left, 0);
final card = tester.widget<ClipRRect>(
find
.descendant(
of: find.byKey(const Key('home-hero-banner')),
matching: find.byType(ClipRRect),
)
.first,
);
expect(card.borderRadius, BorderRadius.zero);
// 频道行在 hero Stack 内、位于轮播顶部 8 的位置上。
expect(
find.ancestor(
of: find.byKey(const Key('home-channels')),
matching: find.byKey(const Key('home-hero-section')),
),
findsOneWidget,
);
final channels = tester.getRect(find.byKey(const Key('home-channels')));
expect(channels.top, closeTo(hero.top + 8, 1));
});
testWidgets('should_open_theater_search_when_home_search_is_tapped', (
@@ -209,8 +236,22 @@ const _tabKeys = [
Key('tab-mine'),
];
ChoiceChip _chip(WidgetTester tester, String key) =>
tester.widget<ChoiceChip>(find.byKey(Key(key)));
Color? _underlineColor(WidgetTester tester, String name) {
final underline = tester.widget<Container>(
find.byKey(Key('home-channel-underline-$name')),
);
return (underline.decoration! as BoxDecoration).color;
}
TextStyle _labelStyle(WidgetTester tester, String name) {
final label = tester.widget<Text>(
find.descendant(
of: find.byKey(Key('home-channel-$name')),
matching: find.byType(Text),
),
);
return label.style!;
}
Future<void> _pumpApp(
WidgetTester tester, {