feat(flutter): 重做短剧详情页

This commit is contained in:
Zhou Weidong committed 2026-10-20 15:46:53 +08:00
1 parent 7250b5f0f9
commit 69faa7818c
12 files changed
+1136 -470

No files matched your search

+223 -129
View File
@@ -3,11 +3,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.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';
import 'package:fruitshorts/core/data/models/auth_session.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/download_license.dart';
import 'package:fruitshorts/core/data/models/download_policy.dart';
import 'package:fruitshorts/core/data/models/wallet.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
import 'package:fruitshorts/core/data/repositories/comment_repository.dart';
import 'package:fruitshorts/core/data/repositories/commerce_repository.dart';
import 'package:fruitshorts/core/data/repositories/download_repository.dart';
@@ -15,13 +21,16 @@ import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/interaction_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/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
import 'package:fruitshorts/core/data/services/download_controller.dart';
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
import 'package:fruitshorts/core/data/services/interaction_controller.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/core/data/services/download_controller.dart';
import 'package:fruitshorts/core/data/services/wallet_controller.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart';
@@ -29,94 +38,133 @@ import 'package:fruitshorts/pages/player/player_view.dart';
import '../support/fake_catalog.dart';
import '../support/fake_haptics.dart';
import '../support/fake_http_adapter.dart';
import '../support/fake_interaction_repositories.dart';
import '../support/widget_player.dart';
void main() {
testWidgets('should_render_preview_hero_cta_synopsis_and_episode_chips', (
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
testWidgets('should_render_new_detail_layout_and_existing_menu', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpDetail(tester);
await tester.pumpWidget(_host(_detailDrama()));
await tester.pumpAndSettle();
expect(tester.getSize(find.byKey(const Key('detail-hero'))).height, 190);
expect(find.byKey(const Key('detail-hero')), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('detail-back'))),
const Size(36, 36),
tester.getSize(find.byKey(const Key('detail-poster'))),
const Size(96, 128),
);
expect(
tester
.widget<Icon>(
find.descendant(
of: find.byKey(const Key('detail-back')),
matching: find.byType(Icon),
),
)
.size,
18,
.widget<Text>(find.byKey(const Key('detail-title')))
.style
?.fontSize,
20,
);
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 40);
expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget);
expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget);
final synopsis = tester.widget<Text>(
find.byKey(const Key('detail-synopsis')),
expect(find.byKey(const Key('detail-episode-count')), findsOneWidget);
expect(tester.getSize(find.byKey(const Key('detail-favorite'))).height, 34);
final stats = tester.widget<Container>(
find.byKey(const Key('detail-stats')),
);
expect(synopsis.style?.fontSize, 11);
expect(synopsis.style?.height, 1.6);
final statsDecoration = stats.decoration! as BoxDecoration;
expect(statsDecoration.color, AppColors.card);
expect(statsDecoration.borderRadius, BorderRadius.circular(14));
expect(find.byKey(const Key('detail-synopsis-section')), findsOneWidget);
expect(find.byKey(const Key('detail-tags')), findsOneWidget);
expect(find.byKey(const Key('detail-episodes-section')), findsOneWidget);
expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 46);
expect(find.byKey(const Key('detail-share')), findsOneWidget);
expect(find.byKey(const Key('detail-more')), findsOneWidget);
await tester.tap(find.byKey(const Key('detail-more')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('detail-favorite')), findsOneWidget);
expect(find.byKey(const Key('detail-menu-favorite')), findsOneWidget);
expect(find.byKey(const Key('detail-download')), findsOneWidget);
expect(find.byKey(const Key('detail-share')), findsOneWidget);
expect(find.byKey(const Key('detail-menu-share')), findsOneWidget);
expect(find.byKey(const Key('detail-report')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('should_fit_compact_viewport_when_detail_scrolls', (
testWidgets('should_switch_favorite_button_style_when_tapped', (
tester,
) async {
tester.view.physicalSize = const Size(320, 568);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpDetail(tester);
BoxDecoration decoration() =>
tester
.widget<Container>(find.byKey(const Key('detail-favorite')))
.decoration!
as BoxDecoration;
await tester.pumpWidget(_host(_detailDrama()));
expect(decoration().color, AppColors.card);
expect((decoration().border! as Border).top.color, AppColors.blue);
await tester.tap(find.byKey(const Key('detail-favorite')));
await tester.pumpAndSettle();
final grid = tester.widget<GridView>(
find.byKey(const Key('detail-episode-chips')),
);
expect(decoration().color, AppColors.blue);
expect(
(grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount)
.crossAxisCount,
6,
find.descendant(
of: find.byKey(const Key('detail-favorite')),
matching: find.byIcon(Icons.star_rounded),
),
findsOneWidget,
);
await tester.drag(
find.byKey(const Key('detail-scroll')),
const Offset(0, -520),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('should_render_sorted_six_column_grid_and_open_original_index', (
testWidgets('should_expand_synopsis_when_it_exceeds_three_lines', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await _pumpDetail(tester, drama: _detailDrama(longSynopsis: true));
Text synopsis() =>
tester.widget<Text>(find.byKey(const Key('detail-synopsis')));
await tester.pumpWidget(_host(_detailDrama(count: 80)));
expect(synopsis().maxLines, 3);
final toggle = find.byKey(const Key('detail-synopsis-toggle'));
expect(toggle, findsOneWidget);
await tester.ensureVisible(toggle);
await tester.tap(toggle);
await tester.pumpAndSettle();
expect(synopsis().maxLines, isNull);
expect(
tester.widget<Text>(find.byKey(const Key('detail-synopsis'))).data,
contains('final secret'),
);
});
testWidgets('should_render_stats_and_honest_empty_values', (tester) async {
await _pumpDetail(tester);
expect(find.text('480.0万 heat'), findsOneWidget);
expect(find.text('3200 Favorite'), findsOneWidget);
expect(find.text('1.2万 Like'), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
await _pumpDetail(tester, drama: _detailDrama(withCounts: false));
expect(
tester
.widget<Text>(find.byKey(const Key('detail-stat-favorite-value')))
.data,
'8 episodes',
);
expect(
tester.widget<Text>(find.byKey(const Key('detail-stat-like-value'))).data,
'—',
);
});
testWidgets('should_render_six_column_grid_and_open_original_index', (
tester,
) async {
await _pumpDetail(tester, drama: _detailDrama(count: 80, descending: true));
await tester.ensureVisible(
find.byKey(const Key('detail-episodes-section')),
);
await tester.pumpAndSettle();
final grid = tester.widget<GridView>(
@@ -127,7 +175,7 @@ void main() {
expect(delegate.crossAxisCount, 6);
expect(delegate.mainAxisSpacing, 8);
expect(delegate.crossAxisSpacing, 8);
expect(delegate.childAspectRatio, closeTo(1.35, 0.001));
expect(delegate.mainAxisExtent, 44);
final chip1 = find.byKey(const Key('detail-episode-chip-1'));
final chip2 = find.byKey(const Key('detail-episode-chip-2'));
@@ -139,81 +187,114 @@ void main() {
greaterThan(tester.getTopLeft(chip1).dy),
);
expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx));
expect(tester.getSize(chip1), tester.getSize(chip2));
expect(tester.getSize(chip1).height, 44);
await tester.drag(
find.byKey(const Key('detail-scroll')),
const Offset(0, -420),
);
await tester.pumpAndSettle();
final chip12 = find.byKey(const Key('detail-episode-chip-12'));
expect(chip12, findsOneWidget);
await tester.tap(chip12);
await tester.pumpAndSettle();
expect(find.byKey(const Key('detail-episode-lock-2')), findsOneWidget);
expect(find.byKey(const Key('detail-episode-lock-1')), findsNothing);
await tester.tap(chip1);
await tester.pumpAndSettle();
final player = tester.widget<PlayerPage>(find.byType(PlayerPage));
expect(player.initialIndex, 11);
expect(player.initialIndex, 79);
});
testWidgets('should_sort_episode_chips_ascending_by_number', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(_host(_detailDrama(count: 80, descending: true)));
await tester.pumpAndSettle();
final chip1 = find.byKey(const Key('detail-episode-chip-1'));
final chip2 = find.byKey(const Key('detail-episode-chip-2'));
final chip6 = find.byKey(const Key('detail-episode-chip-6'));
final chip7 = find.byKey(const Key('detail-episode-chip-7'));
expect(tester.getTopLeft(chip1).dy, tester.getTopLeft(chip6).dy);
expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx));
expect(
tester.getTopLeft(chip7).dy,
greaterThan(tester.getTopLeft(chip1).dy),
testWidgets('should_fit_compact_viewport_and_keep_cta_visible', (
tester,
) async {
await _pumpDetail(
tester,
drama: _detailDrama(count: 80),
size: const Size(320, 568),
);
expect(tester.getSize(chip1), tester.getSize(chip2));
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
await tester.ensureVisible(
find.byKey(const Key('detail-episodes-section')),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
Widget _host(Drama drama) {
final catalog = FakeCatalogRepository(dramas: [drama]);
Future<void> _pumpDetail(
WidgetTester tester, {
Drama? drama,
Size size = const Size(390, 844),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final target = drama ?? _detailDrama();
final catalog = FakeCatalogRepository(dramas: [target]);
final interactions = FakeInteractionRepository();
return MultiProvider(
providers: [
Provider<WatchHistoryRepository>.value(
value: InMemoryWatchHistoryRepository(),
final auth = await _signedInAuth();
final favorites = FavoriteController(
InMemoryFavoriteRepository(cards: [target]),
);
await favorites.load();
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<WatchHistoryRepository>.value(
value: InMemoryWatchHistoryRepository(),
),
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
Provider<PlayerFactory>.value(value: createWidgetPlayer),
Provider(create: (_) => PlaybackCoordinator()),
Provider<InteractionRepository>.value(value: interactions),
Provider<CommentRepository>.value(value: FakeCommentRepository()),
Provider<Haptics>.value(value: FakeHaptics()),
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider<FavoriteController>.value(value: favorites),
ChangeNotifierProvider(
create: (_) => InteractionController(interactions),
),
ChangeNotifierProvider(
create: (_) => WalletController(_FakeCommerceRepository()),
),
ChangeNotifierProvider(
create: (_) => DownloadController(_FakeDownloadRepository()),
),
ChangeNotifierProvider(
create: (_) => CatalogController(catalog)..load(),
),
],
child: MaterialApp(
theme: AppTheme.light(),
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: DramaDetailPage(drama: target),
),
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
Provider<PlayerFactory>.value(value: createWidgetPlayer),
Provider(create: (_) => PlaybackCoordinator()),
Provider<InteractionRepository>.value(value: interactions),
Provider<CommentRepository>.value(value: FakeCommentRepository()),
Provider<Haptics>.value(value: FakeHaptics()),
ChangeNotifierProvider(
create: (_) => FavoriteController(InMemoryFavoriteRepository()),
),
ChangeNotifierProvider(
create: (_) => InteractionController(interactions),
),
ChangeNotifierProvider(
create: (_) => WalletController(_FakeCommerceRepository()),
),
ChangeNotifierProvider(
create: (_) => DownloadController(_FakeDownloadRepository()),
),
ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()),
],
child: MaterialApp(
theme: AppTheme.light(),
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: DramaDetailPage(drama: drama),
),
);
await tester.pumpAndSettle();
}
Future<AuthController> _signedInAuth() async {
final preferences = SharedPreferencesAsync();
final store = AuthSessionStore(preferences: preferences);
await store.save(
const AuthSession(
accessToken: 'access',
refreshToken: 'refresh',
user: AuthUser(uid: '9', nickname: 'Viewer'),
),
);
final auth = AuthController(
AuthApi(
ApiClient(
baseUrl: 'http://127.0.0.1:1',
adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)),
),
),
store,
);
await auth.load();
return auth;
}
class _FakeCommerceRepository implements CommerceRepository {
@@ -281,33 +362,46 @@ class _FakeDownloadRepository implements DownloadRepository {
Future<int> clear() async => 0;
}
Drama _detailDrama({int count = 8, bool descending = false}) {
Drama _detailDrama({
int count = 8,
bool descending = false,
bool withCounts = true,
bool longSynopsis = false,
}) {
final numbers = List<int>.generate(
count,
(index) => descending ? count - index : index + 1,
);
final synopsis = longSynopsis
? 'She agreed to marry a mysterious stranger to save her family, '
'until she discovered his secret identity and the city he controls. '
'Every choice pulls her deeper into a dangerous game, and the '
'final secret changes everything she thought she knew about him.'
: 'She agreed to marry a mysterious stranger to save her family, '
'until she discovered his secret identity and the city he controls.';
return Drama(
id: 'detail-k25',
title: 'Detail K25',
synopsis:
'She agreed to marry a mysterious stranger to save her family, '
'until she discovered his secret identity and the city he controls.',
posterUrl: '/files/detail-k25.png',
id: 'detail-k38',
title: 'Detail K38',
synopsis: synopsis,
posterUrl: '/files/detail-k38.png',
genre: 'Urban Romance',
category: 'Romance',
language: 'id',
totalEpisodes: count,
viewCount: 4800000,
favoriteCount: withCounts ? 3200 : null,
likeCount: withCounts ? 12400 : null,
detailLoaded: true,
episodes: [
for (final number in numbers)
Episode(
id: 'detail-k25-$number',
id: 'detail-k38-$number',
number: number,
title: 'Episode $number',
isFree: number == 1,
durationSec: 135,
locked: number > 1,
videoUrl: number == 1 ? '/files/detail-k25-1.mp4' : '',
videoUrl: number == 1 ? '/files/detail-k38-1.mp4' : '',
),
],
);