feat(flutter): 重做短剧详情页
This commit is contained in:
1 parent
7250b5f0f9
commit
69faa7818c
12 files changed
+1136
-470
No files matched your search
@@ -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' : '',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user