328 lines
9.5 KiB
Dart
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: 2),
|
|
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)),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|