feat(flutter): 压缩首页剧卡并把播放次数上封面

This commit is contained in:
Zhou Weidong committed 2026-10-20 13:03:19 +08:00
1 parent 8e2cd5c838
commit 124ba75ab8
3 files changed
+104 -58

No files matched your search

+50 -48
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart'; import '../../../core/data/models/drama.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/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
@@ -12,9 +13,12 @@ import '../../../l10n/app_localizations.dart';
const int _columns = 2; const int _columns = 2;
const double _columnGap = 10; const double _columnGap = 10;
const double _cardGap = 10; const double _cardGap = 10;
const double _coverAspectRatio = 3 / 4; const double _coverAspectRatio = 5 / 6;
const double _addSize = 28; const double _cardPadding = 8;
const double _addIconSize = 18; const double _coverTitleGap = 6;
const double _lineGap = 2;
const double _viewsIconSize = 10;
const double _viewsFontSize = 9;
/// 首页浏览态使用的双列剧目网格。 /// 首页浏览态使用的双列剧目网格。
class HomeDramaGrid extends StatelessWidget { class HomeDramaGrid extends StatelessWidget {
@@ -89,16 +93,16 @@ class HomeDramaCard extends StatelessWidget {
key: Key('home-drama-card-${drama.id}'), key: Key('home-drama-card-${drama.id}'),
onTap: onTap, onTap: onTap,
child: Padding( child: Padding(
padding: const EdgeInsets.all(AppDimens.spaceSm), padding: const EdgeInsets.all(_cardPadding),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_cover(), _cover(),
const SizedBox(height: AppDimens.spaceSm), const SizedBox(height: _coverTitleGap),
_title(), _title(),
const SizedBox(height: AppDimens.spaceXs), const SizedBox(height: _lineGap),
_genre(), _genre(),
const SizedBox(height: AppDimens.spaceXs), const SizedBox(height: _lineGap),
_metadata(), _metadata(),
], ],
), ),
@@ -124,6 +128,7 @@ class HomeDramaCard extends StatelessWidget {
Positioned(top: 8, left: 8, child: _tagBadge()), Positioned(top: 8, left: 8, child: _tagBadge()),
if (_lockedCount > 0) if (_lockedCount > 0)
Positioned(top: 8, right: 8, child: _lockedBadge()), Positioned(top: 8, right: 8, child: _lockedBadge()),
Positioned(left: 8, bottom: 8, child: _viewsBadge()),
Positioned(right: 8, bottom: 8, child: _episodeBadge()), Positioned(right: 8, bottom: 8, child: _episodeBadge()),
], ],
), ),
@@ -210,6 +215,37 @@ class HomeDramaCard extends StatelessWidget {
); );
} }
/// 播放次数角标:深色半透明胶囊 + 白色小播放图标。
Widget _viewsBadge() {
return Container(
key: Key('home-drama-views-${drama.id}'),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.playerOverlayPill,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: _viewsIconSize,
),
const SizedBox(width: 2),
Text(
formatInteractionCount(drama.viewCount),
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: _viewsFontSize,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
Widget _title() { Widget _title() {
return Text( return Text(
drama.title, drama.title,
@@ -239,47 +275,13 @@ class HomeDramaCard extends StatelessWidget {
} }
Widget _metadata() { Widget _metadata() {
return Row( return Text(
children: [ l10n.episodeCount(drama.episodeCount),
Expanded( key: Key('home-drama-episode-count-${drama.id}'),
child: Wrap( style: const TextStyle(
spacing: AppDimens.spaceSm, color: AppColors.subtle,
runSpacing: AppDimens.spaceXs, fontSize: AppDimens.cardViews,
children: [ ),
Text(
l10n.episodeCount(drama.episodeCount),
key: Key('home-drama-episode-count-${drama.id}'),
style: const TextStyle(
color: AppColors.subtle,
fontSize: AppDimens.cardViews,
),
),
Text(
l10n.viewCountLabel(drama.viewCount),
key: Key('home-drama-views-${drama.id}'),
style: const TextStyle(
color: AppColors.subtle,
fontSize: AppDimens.cardViews,
),
),
],
),
),
Container(
key: Key('home-drama-add-${drama.id}'),
width: _addSize,
height: _addSize,
decoration: const BoxDecoration(
color: AppColors.blue,
shape: BoxShape.circle,
),
child: const Icon(
Icons.add_rounded,
color: AppColors.onPrimary,
size: _addIconSize,
),
),
],
); );
} }
} }
+34 -9
View File
@@ -10,7 +10,7 @@ import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/home/widgets/home_drama_grid.dart'; import 'package:fruitshorts/pages/home/widgets/home_drama_grid.dart';
void main() { void main() {
testWidgets('should_render_warm_orange_cover_badges_and_plus_action', ( testWidgets('should_render_compact_cover_badges_without_plus_action', (
tester, tester,
) async { ) async {
await tester.pumpWidget( await tester.pumpWidget(
@@ -20,13 +20,15 @@ void main() {
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Scaffold( home: Scaffold(
body: Builder( body: SingleChildScrollView(
builder: (context) => SizedBox( child: Builder(
width: 180, builder: (context) => SizedBox(
child: HomeDramaCard( width: 180,
l10n: AppLocalizations.of(context)!, child: HomeDramaCard(
drama: _drama, l10n: AppLocalizations.of(context)!,
onTap: () {}, drama: _drama,
onTap: () {},
),
), ),
), ),
), ),
@@ -54,7 +56,29 @@ void main() {
expect(find.text('Free'), findsOneWidget); expect(find.text('Free'), findsOneWidget);
expect(find.text('18 locked'), findsOneWidget); expect(find.text('18 locked'), findsOneWidget);
expect(find.text('All 24'), findsOneWidget); expect(find.text('All 24'), findsOneWidget);
expect(find.byKey(const Key('home-drama-add-style-drama')), findsOneWidget); expect(find.byKey(const Key('home-drama-add-style-drama')), findsNothing);
// 播放次数角标落在封面内,并按万位格式化。
final views = find.byKey(const Key('home-drama-views-style-drama'));
expect(
find.descendant(
of: find.byKey(const Key('home-drama-cover-style-drama')),
matching: views,
),
findsOneWidget,
);
expect(
find.descendant(of: views, matching: find.text('1.2万')),
findsOneWidget,
);
// 压缩后卡片高度接近「卡宽 × 1.5」,明显矮于旧版 3:4 封面。
final cardSize = tester.getSize(card);
expect(cardSize.height, lessThan(cardSize.width * 1.6));
final coverSize = tester.getSize(
find.byKey(const Key('home-drama-cover-style-drama')),
);
expect(coverSize.height / coverSize.width, closeTo(6 / 5, 0.02));
}); });
} }
@@ -66,4 +90,5 @@ const _drama = Drama(
genre: 'Romance', genre: 'Romance',
totalEpisodes: 24, totalEpisodes: 24,
freeEpisodes: 6, freeEpisodes: 6,
viewCount: 12400,
); );
+20 -1
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
@@ -104,7 +105,23 @@ void main() {
}); });
testWidgets('should_pin_home_channels_when_scrolling', (tester) async { testWidgets('should_pin_home_channels_when_scrolling', (tester) async {
await _pumpApp(tester); // 多放几部剧,保证内容高度足够滚过吸顶阈值。
await _pumpApp(
tester,
withBanner: false,
dramas: [
for (var index = 0; index < 10; index++)
Drama(
id: 'scroll-$index',
title: 'Scroll Drama $index',
synopsis: 'synopsis',
posterUrl: '/files/scroll-$index.png',
genre: 'Drama',
totalEpisodes: 10,
freeEpisodes: 10,
),
],
);
final before = tester.getRect(find.byKey(const Key('home-channel-hot'))); final before = tester.getRect(find.byKey(const Key('home-channel-hot')));
expect(find.byKey(const Key('home-channels-pinned')), findsNothing); expect(find.byKey(const Key('home-channels-pinned')), findsNothing);
@@ -278,12 +295,14 @@ Future<void> _pumpApp(
WatchHistoryRepository? history, WatchHistoryRepository? history,
Size size = const Size(390, 844), Size size = const Size(390, 844),
bool withBanner = true, bool withBanner = true,
List<Drama>? dramas,
}) async { }) async {
tester.view.physicalSize = size; tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository( final catalog = FakeCatalogRepository(
dramas: dramas,
banners: withBanner banners: withBanner
? const [ ? const [
FeedBanner( FeedBanner(