merge: 合并 K31-B 首页频道与轮播改版
This commit is contained in:
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'),
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in new issue
Block a user