feat(home): 首页按内容类型分频道 Feed 并支持分页

This commit is contained in:
Zhou Weidong committed 2026-10-21 17:33:52 +08:00
1 parent 4d0be47ee3
commit ae61d9ec4c
22 files changed
+1001 -71

No files matched your search

@@ -4,6 +4,7 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/feed_page.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
@@ -136,6 +137,13 @@ class ScriptedCatalogRepository implements CatalogRepository {
@override
Future<HomeFeed> fetchDiscoverFeed() async => feed;
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? cursor,
int size = 10,
}) async => const FeedPage();
@override
Future<Drama> fetchDrama(String dramaId) async {
detailCalls++;
+78
View File
@@ -0,0 +1,78 @@
// Author: fruit.com.cn
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
import '../support/fake_http_adapter.dart';
void main() {
String envelope(Object? data) =>
jsonEncode({'code': 0, 'message': 'ok', 'data': data});
test('should_send_types_and_cursor_when_feed_query_has_filters', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
envelope({
'list': [
{
'id': '11',
'title': 'Anime',
'cover': '/files/anime.png',
'preview': {
'id': '12',
'number': 1,
'title': 'Episode 1',
'isFree': true,
},
},
],
'nextCursor': 10,
'hasMore': true,
}),
),
);
final api = CatalogApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final page = await api.feed(
types: const ['ANIME', 'LIVE'],
cursor: '9',
size: 20,
);
expect(adapter.requests.single.uri.path, '/app/feed');
expect(adapter.requests.single.queryParameters, {
'types': 'ANIME,LIVE',
'cursor': '9',
'size': '20',
});
expect(page.dramas.single.id, '11');
expect(page.nextCursor, '10');
expect(page.hasMore, isTrue);
});
test(
'should_omit_empty_types_and_treat_null_cursor_string_as_empty',
() async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
envelope({'list': [], 'nextCursor': 'null', 'hasMore': false}),
),
);
final api = CatalogApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
final page = await api.feed();
expect(adapter.requests.single.queryParameters, {'size': '10'});
expect(page.dramas, isEmpty);
expect(page.nextCursor, isNull);
expect(page.hasMore, isFalse);
},
);
}
@@ -0,0 +1,175 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/feed_page.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/services/home_feed_controller.dart';
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
void main() {
test(
'should_seed_recommend_without_requesting_when_initial_feed_is_ready',
() async {
final repository = _ScriptedFeedRepository();
final controller = HomeFeedController(repository);
controller.seedRecommend([_drama('seed')]);
expect(controller.channel, HomeFeedChannel.recommend);
expect(controller.dramas.map((drama) => drama.id), ['seed']);
expect(controller.loaded, isTrue);
expect(repository.calls, isEmpty);
controller.dispose();
},
);
test('should_request_anime_live_and_recommend_with_expected_types', () async {
final repository = _ScriptedFeedRepository()
..enqueue(() async => FeedPage(dramas: [_drama('anime')]))
..enqueue(() async => FeedPage(dramas: [_drama('live')]))
..enqueue(() async => FeedPage(dramas: [_drama('recommend')]));
final controller = HomeFeedController(repository);
controller.seedRecommend([_drama('seed')]);
await controller.selectChannel(HomeFeedChannel.anime);
await controller.selectChannel(HomeFeedChannel.live);
await controller.selectChannel(HomeFeedChannel.recommend);
await pumpEventQueue();
expect(repository.calls.map((call) => call.types), [
['ANIME'],
['LIVE'],
[],
]);
controller.dispose();
});
test(
'should_keep_cached_data_while_silently_refreshing_cached_channel',
() async {
final refresh = Completer<FeedPage>();
final repository = _ScriptedFeedRepository()
..enqueue(() async => FeedPage(dramas: [_drama('anime-old')]))
..enqueue(() async => FeedPage(dramas: [_drama('live')]))
..enqueue(() => refresh.future);
final controller = HomeFeedController(repository);
await controller.selectChannel(HomeFeedChannel.anime);
await controller.selectChannel(HomeFeedChannel.live);
await controller.selectChannel(HomeFeedChannel.anime);
expect(controller.dramas.single.id, 'anime-old');
refresh.complete(FeedPage(dramas: [_drama('anime-new')]));
await pumpEventQueue();
expect(controller.dramas.single.id, 'anime-new');
controller.dispose();
},
);
test('should_append_unique_items_and_stop_when_has_more_is_false', () async {
final repository = _ScriptedFeedRepository()
..enqueue(
() async => FeedPage(
dramas: [_drama('a'), _drama('b')],
nextCursor: 'next',
hasMore: true,
),
)
..enqueue(
() async =>
FeedPage(dramas: [_drama('b'), _drama('c')], hasMore: false),
);
final controller = HomeFeedController(repository);
await controller.selectChannel(HomeFeedChannel.anime);
await controller.loadMore();
await controller.loadMore();
expect(controller.dramas.map((drama) => drama.id), ['a', 'b', 'c']);
expect(repository.calls, hasLength(2));
expect(repository.calls.last.cursor, 'next');
controller.dispose();
});
test('should_ignore_stale_response_when_channels_switch_quickly', () async {
final anime = Completer<FeedPage>();
final live = Completer<FeedPage>();
final repository = _ScriptedFeedRepository()
..enqueue(() => anime.future)
..enqueue(() => live.future);
final controller = HomeFeedController(repository);
final animeLoad = controller.selectChannel(HomeFeedChannel.anime);
final liveLoad = controller.selectChannel(HomeFeedChannel.live);
live.complete(FeedPage(dramas: [_drama('live')]));
await liveLoad;
anime.complete(FeedPage(dramas: [_drama('anime')]));
await animeLoad;
expect(controller.channel, HomeFeedChannel.live);
expect(controller.dramas.single.id, 'live');
controller.dispose();
});
test('should_keep_old_data_when_refresh_fails', () async {
final repository = _ScriptedFeedRepository()
..enqueue(() async => FeedPage(dramas: [_drama('old')]))
..enqueue(() async => throw StateError('offline'));
final controller = HomeFeedController(repository);
await controller.selectChannel(HomeFeedChannel.anime);
final refreshed = await controller.refreshCurrent();
expect(refreshed, isFalse);
expect(controller.dramas.single.id, 'old');
expect(controller.lastError, contains('offline'));
controller.dispose();
});
}
class FeedCall {
const FeedCall({
required this.types,
required this.cursor,
required this.size,
});
final List<String> types;
final String? cursor;
final int size;
}
class _ScriptedFeedRepository implements CatalogRepository {
final List<FeedCall> calls = [];
final List<Future<FeedPage> Function()> _responses = [];
void enqueue(Future<FeedPage> Function() response) {
_responses.add(response);
}
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? cursor,
int size = 10,
}) {
calls.add(FeedCall(types: types, cursor: cursor, size: size));
return _responses.removeAt(0)();
}
@override
Future<HomeFeed> fetchHomeFeed() async => const HomeFeed();
@override
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
@override
Future<Drama> fetchDrama(String dramaId) async => _drama(dramaId);
}
Drama _drama(String id) =>
Drama(id: id, title: id, synopsis: id, posterUrl: '');
@@ -2,6 +2,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/feed_page.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
@@ -59,6 +60,13 @@ class _FakeCatalog implements CatalogRepository {
@override
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? cursor,
int size = 10,
}) async => const FeedPage();
@override
Future<Drama> fetchDrama(String dramaId) async => _drama;
}
+54
View File
@@ -0,0 +1,54 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart';
void main() {
testWidgets('should_emit_channel_callbacks_and_render_selected_channel', (
tester,
) async {
final selected = <HomeFeedChannel>[];
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Builder(
builder: (context) => Scaffold(
body: HomeFeedHeader(
l10n: AppLocalizations.of(context)!,
channel: HomeFeedChannel.anime,
onChannelSelected: selected.add,
onRefresh: () async => true,
onSearch: () {},
onMore: () {},
),
),
),
),
);
expect(find.byKey(const Key('home-feed-tab-anime')), findsOneWidget);
expect(find.byKey(const Key('home-feed-tab-real')), findsOneWidget);
expect(find.byKey(const Key('home-feed-tab-recommend')), findsOneWidget);
expect(
tester.widget<Text>(find.text('Anime')).style?.fontWeight,
FontWeight.w800,
);
await tester.tap(find.byKey(const Key('home-feed-tab-real')));
await tester.tap(find.byKey(const Key('home-feed-tab-recommend')));
await tester.tap(find.byKey(const Key('home-feed-tab-anime')));
expect(selected, [
HomeFeedChannel.live,
HomeFeedChannel.recommend,
HomeFeedChannel.anime,
]);
});
}
+3
View File
@@ -15,6 +15,7 @@ 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_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart';
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
import 'package:video_player/video_player.dart';
@@ -439,6 +440,8 @@ void main() {
builder: (context) => Scaffold(
body: HomeFeedHeader(
l10n: AppLocalizations.of(context)!,
channel: HomeFeedChannel.recommend,
onChannelSelected: (_) {},
onRefresh: () {
calls++;
return completer.future;
+15
View File
@@ -12,6 +12,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/config/app_config.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/feed_page.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
@@ -158,6 +159,13 @@ class _EmptyCatalogRepository implements CatalogRepository {
@override
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? cursor,
int size = 10,
}) async => const FeedPage();
@override
Future<Drama> fetchDrama(String dramaId) async => _feed.dramas.first;
}
@@ -177,6 +185,13 @@ class _PendingCatalogRepository implements CatalogRepository {
@override
Future<HomeFeed> fetchDiscoverFeed() async => _feed;
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? cursor,
int size = 10,
}) async => FeedPage(dramas: _feed.dramas);
@override
Future<Drama> fetchDrama(String dramaId) async => _feed.dramas.first;
}
+9 -3
View File
@@ -116,12 +116,18 @@ void main() {
expect(player.homeMode, isFalse);
});
testWidgets('should_show_coming_soon_for_anime_tab', (tester) async {
testWidgets('should_switch_to_anime_channel', (tester) async {
await _pumpApp(tester);
await tester.tap(find.byKey(const Key('home-feed-tab-anime')));
await tester.pump();
expect(find.text('Coming soon'), findsOneWidget);
await tester.pumpAndSettle();
expect(find.text('Coming soon'), findsNothing);
expect(
tester.widget<Text>(find.text('Anime')).style?.fontWeight,
FontWeight.w800,
);
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
});
testWidgets('should_refresh_feed_without_blank_loading', (tester) async {
+114
View File
@@ -412,6 +412,120 @@ void main() {
});
});
group('updateFeed', () {
test('should_append_feed_without_restarting_current_drama', () async {
final factory = _RecordingFactory();
final feed = [_drama('signal'), _drama('city')];
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(
drama: feed.first,
initialIndex: 0,
homeMode: true,
feedDramas: feed,
),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
final activeBefore = logic.session.active;
logic.updateFeed([...feed, _drama('third')], keepDramaId: 'signal');
await pumpEventQueue();
expect(logic.currentDramaId, 'signal');
expect(logic.visibleIndices, [0, 1, 2]);
expect(logic.sessionEpisodes, hasLength(3));
expect(logic.session.active, same(activeBefore));
logic.dispose();
await _drain(logic);
});
test('should_remap_current_drama_without_restarting_when_order_changes',
() async {
final factory = _RecordingFactory();
final feed = [_drama('signal'), _drama('city')];
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(
drama: feed.first,
initialIndex: 1,
homeMode: true,
feedDramas: feed,
),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
final activeBefore = logic.session.active;
logic.updateFeed(
[_drama('city'), _drama('signal'), _drama('third')],
keepDramaId: 'city',
);
await pumpEventQueue();
expect(logic.currentDramaId, 'city');
expect(logic.session.currentIndex, 0);
expect(logic.session.active, same(activeBefore));
logic.dispose();
await _drain(logic);
});
test('should_return_to_first_drama_when_feed_channel_changes', () async {
final factory = _RecordingFactory();
final feed = [_drama('signal'), _drama('city')];
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(
drama: feed.first,
initialIndex: 0,
homeMode: true,
feedDramas: feed,
),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
logic.updateFeed([_drama('anime-a'), _drama('anime-b')]);
await _drain(logic);
expect(logic.queue.entryAt(0).drama.id, 'anime-a');
expect(logic.currentDramaId, 'anime-a');
expect(logic.session.currentIndex, 0);
logic.dispose();
await _drain(logic);
});
test('should_clear_queue_safely_when_feed_becomes_empty', () async {
final feed = [_drama('signal')];
final logic = _logic(FakeWatchHistory());
logic.attach(
PlayerPage(
drama: feed.first,
initialIndex: 0,
homeMode: true,
feedDramas: feed,
),
isMounted: () => true,
);
logic.start(
factory: _RecordingFactory().create,
coordinator: PlaybackCoordinator(),
);
await _drain(logic);
logic.updateFeed(const []);
expect(logic.queue.length, 0);
expect(logic.visibleIndices, isEmpty);
expect(logic.currentDramaId, isNull);
logic.dispose();
await _drain(logic);
});
});
group('lifecycle', () {
test('should_pause_active_player_when_backgrounded_and_play_it_when_resumed', () async {
final factory = _RecordingFactory();
+18
View File
@@ -1,4 +1,5 @@
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/feed_page.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/models/search_result.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
@@ -18,6 +19,23 @@ class FakeCatalogRepository implements CatalogRepository, SearchRepository {
@override
Future<HomeFeed> fetchDiscoverFeed() async => _feed();
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? cursor,
int size = 10,
}) async {
final filtered = types.isEmpty
? dramas
: dramas.where((drama) => _matchesFeedTypes(drama, types)).toList();
return FeedPage(dramas: filtered.take(size).toList());
}
bool _matchesFeedTypes(Drama drama, List<String> types) {
final dramaType = drama.id == 'the-signal-demo' ? 'ANIME' : 'LIVE';
return types.contains(dramaType);
}
@override
Future<Drama> fetchDrama(String dramaId) async {
return dramas.firstWhere((drama) => drama.id == dramaId);
@@ -4,6 +4,7 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/feed_page.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
@@ -265,6 +266,13 @@ class ScriptedWatchCatalog implements CatalogRepository {
@override
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? cursor,
int size = 10,
}) async => const FeedPage();
@override
Future<Drama> fetchDrama(String dramaId) async {
calls.add(dramaId);
+8
View File
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/feed_page.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
@@ -271,6 +272,13 @@ class _FailingWatchCatalog implements CatalogRepository {
@override
Future<HomeFeed> fetchDiscoverFeed() => _delegate.fetchDiscoverFeed();
@override
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? cursor,
int size = 10,
}) => _delegate.fetchFeed(types: types, cursor: cursor, size: size);
@override
Future<Drama> fetchDrama(String dramaId) async {
if (dramaId == 'broken') throw StateError('missing detail');