feat: 统一首页与推荐搜索入口

This commit is contained in:
Zhou Weidong committed 2026-10-22 22:48:21 +08:00
1 parent 933333b0fe
commit 9aa797504b
18 files changed
+325 -107

No files matched your search

+6
View File
@@ -1304,3 +1304,9 @@ $ flutter test
- 移除视频表面点击识别器,双击后增加 300ms 暂停防护;飘心收敛为 3~4 颗、20~26、±18、90~120dp、600~700ms。
- 双击窗口延长至 320ms,单击 Timer 使用点击序号校验,双击后保留 350ms 播放切换保护。
- `double_tap_like_test.dart` 覆盖 120/250/300ms 双击与 500ms 单击恢复:2 项测试全部通过。
## 2026-10-22 统一二级搜索页
- 首页与推荐/剧单/全部短剧搜索入口统一 push SearchPage,不再切换推荐 tab 聚焦。
- 搜索页提供返回、自动聚焦输入框与取消,复用 `/app/search` 逻辑和首页三列卡片。
- 清理旧的 searchRequest/focusSearch/syncSearch 参数;`flutter analyze`:No issues found。
+1
View File
@@ -16,6 +16,7 @@
"currentSeries": "Current series",
"discoverTab": "Theater",
"searchDiscover": "Search dramas, actors, genres...",
"searchCancel": "Cancel",
"featured": "Featured",
"recommended": "Recommended",
"fruitShortsBrand": "FruitShorts",
+1
View File
@@ -16,6 +16,7 @@
"currentSeries": "Serial saat ini",
"discoverTab": "Teater",
"searchDiscover": "Cari drama, aktor, genre...",
"searchCancel": "Batal",
"featured": "Unggulan",
"recommended": "Rekomendasi",
"fruitShortsBrand": "FruitShorts",
+6
View File
@@ -196,6 +196,12 @@ abstract class AppLocalizations {
/// **'Search dramas, actors, genres...'**
String get searchDiscover;
/// No description provided for @searchCancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get searchCancel;
/// No description provided for @featured.
///
/// In en, this message translates to:
+3
View File
@@ -57,6 +57,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get searchDiscover => 'Search dramas, actors, genres...';
@override
String get searchCancel => 'Cancel';
@override
String get featured => 'Featured';
+3
View File
@@ -57,6 +57,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get searchDiscover => 'Cari drama, aktor, genre...';
@override
String get searchCancel => 'Batal';
@override
String get featured => 'Unggulan';
+3
View File
@@ -56,6 +56,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get searchDiscover => '搜索短剧、演员、类型…';
@override
String get searchCancel => '取消';
@override
String get featured => '精选';
+1
View File
@@ -16,6 +16,7 @@
"currentSeries": "当前短剧",
"discoverTab": "剧场",
"searchDiscover": "搜索短剧、演员、类型…",
"searchCancel": "取消",
"featured": "精选",
"recommended": "推荐",
"fruitShortsBrand": "水果短剧",
-14
View File
@@ -118,20 +118,6 @@ class DiscoverLogic extends ChangeNotifier {
searchResults = const [];
}
/// 从播放器发起的搜索请求只在剧场页可见时抢焦点。
void syncSearch({
required bool active,
required int request,
required int previousRequest,
required bool Function() isActive,
}) {
if (!active || request == previousRequest) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isActive()) searchFocus.requestFocus();
});
WidgetsBinding.instance.scheduleFrame();
}
void act(
BuildContext context,
DiscoverPageAction action, {
-22
View File
@@ -56,14 +56,10 @@ class DiscoverPage extends StatefulWidget {
const DiscoverPage({
super.key,
this.active = true,
this.searchRequest = 0,
this.focusSearch = false,
this.callbacks = const DiscoverPageCallbacks(),
});
final bool active;
final int searchRequest;
final bool focusSearch;
final DiscoverPageCallbacks callbacks;
@override
@@ -89,7 +85,6 @@ class _DiscoverPageState extends State<DiscoverPage> {
logic.scroll.addListener(_onScroll);
theater.addListener(_onTheaterChanged);
unawaited(theater.select(null));
if (widget.focusSearch) _focusOnFirstFrame();
}
void _onScroll() {
@@ -106,23 +101,6 @@ class _DiscoverPageState extends State<DiscoverPage> {
if (mounted) setState(() {});
}
void _focusOnFirstFrame() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.active) logic.openSearch();
});
}
@override
void didUpdateWidget(covariant DiscoverPage oldWidget) {
super.didUpdateWidget(oldWidget);
logic.syncSearch(
active: widget.active,
request: widget.searchRequest,
previousRequest: oldWidget.searchRequest,
isActive: () => mounted && widget.active,
);
}
@override
void dispose() {
logic.scroll.removeListener(_onScroll);
+256
View File
@@ -0,0 +1,256 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/data/repositories/search_repository.dart';
import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../home/widgets/home_feed_card.dart';
import '../player/open_player.dart';
import 'discover_binding.dart';
import 'discover_logic.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
late final DiscoverLogic _logic;
@override
void initState() {
super.initState();
_logic = DiscoverBinding().create(search: context.read<SearchRepository>());
_logic.addListener(_rebuild);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _logic.searchFocus.requestFocus();
});
}
void _rebuild() {
if (mounted) setState(() {});
}
@override
void dispose() {
_logic
..removeListener(_rebuild)
..dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Theme(
data: AppTheme.dark(),
child: Scaffold(
key: const Key('search-page'),
backgroundColor: AppColors.appBackground,
body: SafeArea(
child: Column(
children: [
_header(l10n),
Expanded(child: _body(l10n)),
],
),
),
),
),
);
}
Widget _header(AppLocalizations l10n) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 14, 8),
child: SizedBox(
height: AppDimens.searchHeight,
child: Row(
children: [
SizedBox.square(
dimension: AppDimens.buttonMedium,
child: IconButton(
key: const Key('search-back'),
padding: EdgeInsets.zero,
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
color: AppColors.textPrimary,
size: 22,
),
),
),
const SizedBox(width: AppDimens.spaceSm),
Expanded(child: _field(l10n)),
const SizedBox(width: AppDimens.spaceSm),
TextButton(
key: const Key('search-cancel'),
onPressed: () => Navigator.of(context).maybePop(),
child: Text(l10n.searchCancel),
),
],
),
),
);
}
Widget _field(AppLocalizations l10n) {
final hasText = _logic.search.text.isNotEmpty;
return Container(
key: const Key('search-field'),
height: AppDimens.searchHeight,
padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd),
decoration: BoxDecoration(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.searchRadius),
),
child: Row(
children: [
const Icon(
Icons.search_rounded,
color: AppColors.textSecondary,
size: 18,
),
const SizedBox(width: AppDimens.spaceSm),
Expanded(
child: TextField(
key: const Key('discover-search'),
controller: _logic.search,
focusNode: _logic.searchFocus,
autofocus: true,
onChanged: (_) => _logic.onSearchChanged(),
textInputAction: TextInputAction.search,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
),
decoration: InputDecoration(
isDense: true,
hintText: l10n.searchDiscover,
hintStyle: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
),
if (_logic.searchLoading)
const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.textPrimary,
),
)
else if (hasText)
IconButton(
key: const Key('search-clear'),
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 24, height: 24),
onPressed: () {
_logic.search.clear();
_logic.onSearchChanged();
_logic.searchFocus.requestFocus();
},
icon: const Icon(
Icons.close_rounded,
color: AppColors.textSecondary,
size: 16,
),
),
],
),
);
}
Widget _body(AppLocalizations l10n) {
if (!_logic.hasSearchQuery) {
return EmptyState(
key: const Key('search-idle'),
icon: Icons.search_rounded,
title: l10n.searchDiscover,
);
}
if (_logic.searchLoading) {
return const Center(
child: SizedBox.square(
key: Key('search-loading'),
dimension: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.textPrimary,
),
),
);
}
if (_logic.searchError != null) {
return EmptyState(
key: const Key('search-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, _logic.searchError, l10n.searchFailed),
actionLabel: l10n.retry,
onAction: _logic.retrySearch,
);
}
if (_logic.searchResults.isEmpty) {
return EmptyState(
key: const Key('search-empty'),
icon: Icons.search_off_rounded,
title: l10n.noSearchResults,
subtitle: l10n.emptyHint,
);
}
return LayoutBuilder(
builder: (context, constraints) {
final width =
(constraints.maxWidth -
AppDimens.feedPageHorizontal * 2 -
AppDimens.homeGridGap * 2) /
3;
final itemExtent =
width / AppDimens.cardCoverAspectRatio +
AppDimens.homeCardBodyExtent;
return GridView.builder(
key: const Key('search-results-grid'),
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
AppDimens.spaceMd,
AppDimens.feedPageHorizontal,
AppDimens.spaceXl,
),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: AppDimens.homeGridGap,
mainAxisSpacing: AppDimens.homeGridRowGap,
mainAxisExtent: itemExtent,
),
itemCount: _logic.searchResults.length,
itemBuilder: (context, index) {
final drama = _logic.searchResults[index];
return HomeFeedCard(
drama: drama,
onTap: () => unawaited(playDramaFromCatalog(context, drama)),
);
},
);
},
);
}
}
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/error_text.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../l10n/app_localizations.dart';
import 'drama_grid.dart';
@@ -38,7 +39,15 @@ class DiscoverSearchResults extends StatelessWidget {
if (loading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 80),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
child: Center(
child: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.textPrimary,
),
),
),
);
}
final message = error;
+3 -5
View File
@@ -17,7 +17,6 @@ class HomeLogic extends ChangeNotifier {
final scaffoldKey = GlobalKey<ScaffoldState>();
Drama currentDrama;
int tab = 0;
int searchRequest = 0;
int resumeRequest = 0;
int backRequest = 0;
int followingHistoryRequest = 0;
@@ -31,10 +30,9 @@ class HomeLogic extends ChangeNotifier {
if (!_disposed) notifyListeners();
}
void select(int nextTab, {bool search = false, bool resume = false}) {
void select(int nextTab, {bool resume = false}) {
FocusManager.instance.primaryFocus?.unfocus();
tab = nextTab;
if (search) searchRequest++;
if (resume) resumeRequest++;
notify();
}
@@ -81,7 +79,7 @@ class HomeLogic extends ChangeNotifier {
case DiscoverPageAction.home:
select(0);
case DiscoverPageAction.recommend:
select(1, search: true);
select(1);
case DiscoverPageAction.following:
select(2);
case DiscoverPageAction.mine:
@@ -112,7 +110,7 @@ class HomeLogic extends ChangeNotifier {
case MyPageAction.home:
select(0);
case MyPageAction.recommend:
select(1, search: true);
select(1);
case MyPageAction.following:
select(2);
case MyPageAction.mine:
+4 -4
View File
@@ -22,7 +22,7 @@ import '../../core/widgets/app_root_tab.dart';
import '../../core/widgets/catalog_endpoint_editor.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_view.dart';
import '../discover/search_page.dart';
import '../following/following_page.dart';
import '../filter/filter_page.dart';
import '../mine/mine_types.dart';
@@ -292,9 +292,9 @@ class _HomeTabsState extends State<_HomeTabs> {
}
void _openSearch(BuildContext context) {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const DiscoverPage(focusSearch: true)),
);
Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => const SearchPage()));
}
void _pushPage(BuildContext context, Widget page) {
+7 -14
View File
@@ -26,8 +26,7 @@ import '../../core/formatting/localized_content.dart';
import '../../core/theme/app_colors.dart';
import '../../core/navigation/fade_page_route.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_types.dart';
import '../discover/discover_view.dart';
import '../discover/search_page.dart';
import '../drama_detail/drama_detail_view.dart';
import '../mine/mine_types.dart';
import '../mine/mine_view.dart';
@@ -1127,13 +1126,10 @@ class PlayerLogic extends ChangeNotifier {
onSearch?.call();
return;
}
await showDiscover(context, focusSearch: true);
await showDiscover(context);
}
Future<void> showDiscover(
BuildContext context, {
bool focusSearch = false,
}) async {
Future<void> showDiscover(BuildContext context) async {
if (embedded) {
onDiscover?.call();
return;
@@ -1142,13 +1138,10 @@ class PlayerLogic extends ChangeNotifier {
await session.playbackSettled;
await saveCurrentProgress();
if (!context.mounted) return;
final action = await Navigator.of(context).push<DiscoverPageAction>(
MaterialPageRoute(builder: (_) => DiscoverPage(focusSearch: focusSearch)),
);
if (!context.mounted) return;
if (action == DiscoverPageAction.mine) {
await showMy(context);
} else {
await Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => const SearchPage()));
if (context.mounted) {
session.setRouteVisible(true);
}
}
-38
View File
@@ -23,44 +23,6 @@ void main() {
logic.dispose();
});
testWidgets('should_focus_search_only_for_new_request_on_active_page', (
tester,
) async {
final logic = DiscoverLogic();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TextField(
controller: logic.search,
focusNode: logic.searchFocus,
),
),
),
);
logic.syncSearch(
active: false,
request: 1,
previousRequest: 0,
isActive: () => false,
);
await tester.pump();
expect(logic.searchFocus.hasFocus, isFalse);
logic.syncSearch(
active: true,
request: 2,
previousRequest: 1,
isActive: () => true,
);
await tester.pump();
await tester.pump();
expect(logic.searchFocus.hasFocus, isTrue);
await tester.pumpWidget(const SizedBox.shrink());
logic.dispose();
});
testWidgets('should_forward_action_when_embedded_or_pop_when_standalone', (
tester,
) async {
+1 -8
View File
@@ -42,23 +42,17 @@ void main() {
logic.select(1);
expect(logic.tab, 1);
expect(logic.searchRequest, 0);
expect(logic.resumeRequest, 0);
expect(logic.backRequest, 0);
expect(notifications, 1);
logic.dispose();
});
test('should_raise_only_its_own_counter_when_each_select_flag_is_used', () {
test('should_raise_only_resume_counter_when_select_flag_is_used', () {
final logic = HomeLogic(dramas);
logic.select(1, search: true);
expect(logic.searchRequest, 1);
expect(logic.resumeRequest, 0);
logic.select(0, resume: true);
expect(logic.resumeRequest, 1);
expect(logic.searchRequest, 1);
logic.dispose();
});
@@ -161,7 +155,6 @@ void main() {
logic.myAction(MyPageAction.recommend);
expect(logic.tab, 1);
expect(logic.searchRequest, 1);
logic.myAction(MyPageAction.following);
expect(logic.tab, 2);
+20 -1
View File
@@ -118,6 +118,10 @@ void main() {
await _pumpApp(tester);
await tester.tap(find.byKey(const Key('home-search-entry')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('search-page')), findsOneWidget);
expect(find.byKey(const Key('search-back')), findsOneWidget);
expect(find.byKey(const Key('search-cancel')), findsOneWidget);
expect(find.byType(AppBottomNav), findsNothing);
expect(
tester
.widget<TextField>(find.byKey(const Key('discover-search')))
@@ -125,7 +129,7 @@ void main() {
?.hasFocus,
isTrue,
);
await tester.binding.handlePopRoute();
await tester.tap(find.byKey(const Key('search-cancel')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-recommend')));
@@ -133,6 +137,20 @@ void main() {
await tester.tap(find.byKey(const Key('recommend-search')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('discover-search')), findsOneWidget);
await tester.enterText(find.byKey(const Key('discover-search')), 'signal');
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.byKey(const Key('search-results-grid')), findsOneWidget);
final results = tester.widget<GridView>(
find.byKey(const Key('search-results-grid')),
);
final delegate =
results.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisCount, 3);
await tester.tap(find.byKey(const Key('search-back')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('recommend-page')), findsOneWidget);
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
});
testWidgets('should_open_player_from_home_grid_card', (tester) async {
@@ -188,6 +206,7 @@ Future<void> _pumpApp(
playerFactory: createWidgetPlayer,
catalogRepository: catalog,
categoryRepository: FakeCategoryRepository(catalog: catalog),
searchRepository: catalog,
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
playbackRepository: FakePlaybackRepository(),