// 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 createState() => _SearchPageState(); } class _SearchPageState extends State { late final DiscoverLogic _logic; @override void initState() { super.initState(); _logic = DiscoverBinding().create(search: context.read()); _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( 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)), ); }, ); }, ); } }