Files
ReelShort/lib/pages/discover/search_page.dart
T

328 lines
9.5 KiB
Dart

// 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/app_pull_to_refresh.dart';
import '../../core/widgets/drama_grid_skeleton.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../player/open_player.dart';
import 'discover_binding.dart';
import 'discover_logic.dart';
import 'widgets/search_result_card.dart';
const double _clearHit = 20;
const double _clearIconSize = 16;
const double _clearEndGap = 10;
const double _loadingInset = 2;
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);
}
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: MediaQuery.removeViewInsets(
context: context,
removeBottom: true,
child: Scaffold(
key: const Key('search-page'),
backgroundColor: AppColors.appBackground,
resizeToAvoidBottomInset: false,
body: SafeArea(
bottom: false,
child: Column(
children: [
_header(l10n),
Expanded(
child: AppPullToRefresh(
key: const Key('search-refresh'),
onRefresh: _logic.refreshSearch,
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) {
return Container(
key: const Key('search-field'),
height: AppDimens.searchHeight,
padding: const EdgeInsets.only(left: 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(),
onTapOutside: (_) => _logic.searchFocus.unfocus(),
textInputAction: TextInputAction.search,
textAlignVertical: TextAlignVertical.center,
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,
),
scrollPadding: EdgeInsets.zero,
),
),
_fieldTrailing(),
],
),
);
}
/// 有内容时把清除按钮放在输入框内部右侧,并留出一点边距。
/// 刷新时不拿转圈替换叉号,避免宽度变化把按钮抖开。
Widget _fieldTrailing() {
final hasText = _logic.search.text.isNotEmpty;
if (!hasText && !_logic.searchLoading) {
return const SizedBox(width: AppDimens.spaceMd);
}
return SizedBox(
width: _clearHit + _clearEndGap,
child: Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: _clearEndGap),
child: hasText ? _clearButton() : _loadingMark(),
),
),
);
}
Widget _clearButton() {
return GestureDetector(
key: const Key('search-clear'),
behavior: HitTestBehavior.opaque,
onTap: () {
_logic.search.clear();
_logic.onSearchChanged();
_logic.searchFocus.requestFocus();
},
child: const SizedBox.square(
dimension: _clearHit,
child: Icon(
Icons.close_rounded,
color: AppColors.textSecondary,
size: _clearIconSize,
),
),
);
}
Widget _loadingMark() {
return const SizedBox.square(
dimension: _clearHit,
child: Padding(
padding: EdgeInsets.all(_loadingInset),
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.textPrimary,
),
),
);
}
Widget _body(AppLocalizations l10n) {
if (!_logic.hasSearchQuery) {
return _stateScroll(
EmptyState(
key: const Key('search-idle'),
icon: Icons.search_rounded,
title: l10n.searchDiscover,
),
);
}
if (_logic.searchResults.isNotEmpty) {
return _resultsGrid();
}
if (_logic.searchLoading) {
return const DramaGridSkeleton(
key: Key('search-loading-skeleton'),
columns: 2,
horizontalPadding: AppDimens.feedPageHorizontal,
crossAxisSpacing: AppDimens.searchResultGap,
mainAxisSpacing: AppDimens.searchResultRowGap,
itemCount: 6,
physics: AppPullToRefresh.physics,
);
}
if (_logic.searchError != null) {
return _stateScroll(
EmptyState(
key: const Key('search-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, _logic.searchError, l10n.searchFailed),
actionLabel: l10n.retry,
onAction: _logic.retrySearch,
),
);
}
return _stateScroll(
EmptyState(
key: const Key('search-empty'),
icon: Icons.search_off_rounded,
title: l10n.noSearchResults,
subtitle: l10n.emptyHint,
),
);
}
Widget _stateScroll(Widget child) {
return LayoutBuilder(
builder: (context, constraints) => ListView(
physics: AppPullToRefresh.physics,
children: [
SizedBox(
height: constraints.maxHeight,
child: Center(child: child),
),
],
),
);
}
Widget _resultsGrid() {
return LayoutBuilder(
builder: (context, constraints) {
final width =
(constraints.maxWidth -
AppDimens.feedPageHorizontal * 2 -
AppDimens.searchResultGap) /
2;
final itemExtent =
width / AppDimens.cardCoverAspectRatio +
AppDimens.searchResultTextExtent;
return GridView.builder(
key: const Key('search-results-grid'),
physics: AppPullToRefresh.physics,
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
AppDimens.spaceMd,
AppDimens.feedPageHorizontal,
AppDimens.spaceXl,
),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: AppDimens.searchResultGap,
mainAxisSpacing: AppDimens.searchResultRowGap,
mainAxisExtent: itemExtent,
),
itemCount: _logic.searchResults.length,
itemBuilder: (context, index) {
final drama = _logic.searchResults[index];
return SearchResultCard(
drama: drama,
onTap: () => unawaited(playDramaFromCatalog(context, drama)),
);
},
);
},
);
}
}