diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index dfc986f..9c90a4d 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -31,6 +31,8 @@ const double _categoryTabHeight = 44; const double _categoryTabGap = 20; const double _categorySelectedFontSize = 16; const double _categoryFontSize = 15; +const double _stickyHeaderHeight = 102; +const Duration _stickyHeaderTransition = Duration(milliseconds: 200); /// K28-A2 剧场页:搜索、分类/语言 chips 与双列剧库。 class DiscoverPage extends StatefulWidget { @@ -53,6 +55,7 @@ class DiscoverPage extends StatefulWidget { class _DiscoverPageState extends State { late final DiscoverLogic logic; + bool _headerScrolled = false; bool get embedded => widget.callbacks.embedded; @@ -60,9 +63,17 @@ class _DiscoverPageState extends State { void initState() { super.initState(); logic = DiscoverBinding().create(search: context.read()); + logic.scroll.addListener(_onScroll); if (widget.focusSearch) _focusOnFirstFrame(); } + void _onScroll() { + final scrolled = logic.scroll.hasClients && logic.scroll.offset > 0; + if (scrolled != _headerScrolled) { + setState(() => _headerScrolled = scrolled); + } + } + void _focusOnFirstFrame() { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && widget.active) logic.openSearch(); @@ -82,6 +93,7 @@ class _DiscoverPageState extends State { @override void dispose() { + logic.scroll.removeListener(_onScroll); logic.dispose(); super.dispose(); } @@ -124,20 +136,73 @@ class _DiscoverPageState extends State { } Widget _feed(AppLocalizations l10n, CategoryController categories) { + final showHeader = _showStickyHeader(categories); return GestureDetector( behavior: HitTestBehavior.translucent, onTap: logic.searchFocus.unfocus, - child: RefreshIndicator( - key: const Key('discover-refresh'), - color: AppColors.blue, - backgroundColor: AppColors.card, - onRefresh: categories.load, - child: ListView( - controller: logic.scroll, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - children: _content(l10n, categories), + child: Stack( + children: [ + Positioned.fill( + child: RefreshIndicator( + key: const Key('discover-refresh'), + color: AppColors.blue, + backgroundColor: AppColors.card, + strokeWidth: 2, + edgeOffset: _stickyHeaderHeight + 8, + onRefresh: categories.load, + child: ListView( + controller: logic.scroll, + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.only( + top: showHeader ? _stickyHeaderHeight : 0, + bottom: AppDimens.spaceXl, + ), + children: _content(l10n, categories), + ), + ), + ), + if (showHeader) + Positioned( + top: 0, + left: 0, + right: 0, + child: _stickyHeader(l10n, categories), + ), + ], + ), + ); + } + + bool _showStickyHeader(CategoryController categories) { + if (logic.hasSearchQuery || categories.error != null) return false; + return !(categories.loading && categories.feed.sections.isEmpty); + } + + Widget _stickyHeader(AppLocalizations l10n, CategoryController categories) { + return AnimatedContainer( + key: const Key('theater-sticky-header'), + duration: _stickyHeaderTransition, + curve: Curves.easeOut, + decoration: BoxDecoration( + color: _headerScrolled ? AppColors.ink : Colors.transparent, + border: Border( + bottom: BorderSide( + color: _headerScrolled ? AppColors.border : Colors.transparent, + ), ), + boxShadow: _headerScrolled + ? const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 8, + offset: Offset(0, 2), + ), + ] + : const [], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [_categoryTabs(l10n, categories), _toolbar(l10n, categories)], ), ); } @@ -183,8 +248,6 @@ class _DiscoverPageState extends State { ? categories.feed.dramas : categories.dramasOf(logic.selectedCategoryId!); return [ - _categoryTabs(l10n, categories), - _toolbar(l10n, categories), if (dramas.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 40), diff --git a/lib/pages/discover/widgets/discover_search_field.dart b/lib/pages/discover/widgets/discover_search_field.dart index 74efb8d..3ba4707 100644 --- a/lib/pages/discover/widgets/discover_search_field.dart +++ b/lib/pages/discover/widgets/discover_search_field.dart @@ -6,10 +6,11 @@ import '../../../core/theme/app_colors.dart'; import '../../../l10n/app_localizations.dart'; const double _searchHeight = 40; -const double _searchRadius = 18; +const double _searchRadius = 20; +const Duration _focusTransition = Duration(milliseconds: 160); -/// 剧场顶部整行搜索框。 -class DiscoverSearchField extends StatelessWidget { +/// 剧场顶部整行搜索框:白底细边,聚焦时主题色描边与更深的阴影。 +class DiscoverSearchField extends StatefulWidget { const DiscoverSearchField({ super.key, required this.controller, @@ -25,32 +26,84 @@ class DiscoverSearchField extends StatelessWidget { final bool loading; final double horizontalPadding; + @override + State createState() => _DiscoverSearchFieldState(); +} + +class _DiscoverSearchFieldState extends State { + bool _focused = false; + + @override + void initState() { + super.initState(); + widget.focusNode.addListener(_onFocusChanged); + } + + @override + void didUpdateWidget(covariant DiscoverSearchField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.focusNode != widget.focusNode) { + oldWidget.focusNode.removeListener(_onFocusChanged); + widget.focusNode.addListener(_onFocusChanged); + _focused = widget.focusNode.hasFocus; + } + } + + @override + void dispose() { + widget.focusNode.removeListener(_onFocusChanged); + super.dispose(); + } + + void _onFocusChanged() { + final focused = widget.focusNode.hasFocus; + if (focused != _focused) setState(() => _focused = focused); + } + @override Widget build(BuildContext context) { return Padding( - padding: EdgeInsets.symmetric(horizontal: horizontalPadding), - child: SizedBox( + padding: EdgeInsets.symmetric(horizontal: widget.horizontalPadding), + child: AnimatedContainer( + key: const Key('discover-search-shell'), + duration: _focusTransition, + curve: Curves.easeOut, height: _searchHeight, + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(_searchRadius), + border: Border.all( + color: _focused ? AppColors.blue : AppColors.border, + ), + boxShadow: [ + BoxShadow( + color: _focused + ? AppColors.blue.withValues(alpha: 0.18) + : Colors.black.withValues(alpha: 0.04), + blurRadius: _focused ? 10 : 4, + offset: const Offset(0, 2), + ), + ], + ), child: TextField( key: const Key('discover-search'), - controller: controller, - focusNode: focusNode, - onChanged: onChanged, - onTapOutside: (_) => focusNode.unfocus(), + controller: widget.controller, + focusNode: widget.focusNode, + onChanged: widget.onChanged, + onTapOutside: (_) => widget.focusNode.unfocus(), textInputAction: TextInputAction.search, style: const TextStyle(color: AppColors.onSurface, fontSize: 14), decoration: InputDecoration( isDense: true, - filled: true, - fillColor: AppColors.card, + filled: false, hintText: AppLocalizations.of(context)!.searchDiscover, - hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12), + hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 13.5), prefixIcon: const Icon( Icons.search_rounded, color: AppColors.subtle, size: 17, ), - suffixIcon: loading + suffixIcon: widget.loading ? const SizedBox.square( dimension: 18, child: Center( @@ -63,14 +116,9 @@ class DiscoverSearchField extends StatelessWidget { minHeight: 0, ), contentPadding: const EdgeInsets.symmetric(horizontal: 12), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(_searchRadius), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(_searchRadius), - borderSide: const BorderSide(color: AppColors.blue, width: 1), - ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, ), ), ), diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 98f2ae1..4dc97f7 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -10,6 +10,7 @@ import 'package:fruitshorts/core/data/repositories/category_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; 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_colors.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; @@ -170,6 +171,65 @@ void main() { expect(counter.categoryLoads + counter.feedLoads, greaterThan(before)); expect(find.byType(RefreshProgressIndicator), findsNothing); }); + + testWidgets('should_style_search_shell_and_focus_border', (tester) async { + await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); + BoxDecoration shell() => + tester + .widget( + find.byKey(const Key('discover-search-shell')), + ) + .decoration! + as BoxDecoration; + + expect(shell().borderRadius, BorderRadius.circular(20)); + expect((shell().border! as Border).top.color, AppColors.border); + + await tester.tap(find.byKey(const Key('discover-search'))); + await tester.pump(const Duration(milliseconds: 200)); + + expect((shell().border! as Border).top.color, AppColors.blue); + }); + + testWidgets('should_pin_theater_filters_when_scrolling', (tester) async { + await _pumpTheater( + tester, + categoryRepository: _GridCategoryRepository( + discoverFeed: _tallDiscoverFeed, + ), + ); + BoxDecoration header() => + tester + .widget( + find.byKey(const Key('theater-sticky-header')), + ) + .decoration! + as BoxDecoration; + final before = tester.getRect( + find.byKey(const Key('theater-sticky-header')), + ); + expect(header().color, Colors.transparent); + + await tester.drag(_verticalFeed, const Offset(0, -220)); + await tester.pumpAndSettle(); + + final after = tester.getRect( + find.byKey(const Key('theater-sticky-header')), + ); + expect(after.top, closeTo(before.top, 0.5)); + expect(header().color, AppColors.ink); + }); + + testWidgets('should_use_compact_refresh_indicator', (tester) async { + await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); + final indicator = tester.widget( + find.byKey(const Key('discover-refresh')), + ); + + expect(indicator.color, AppColors.blue); + expect(indicator.strokeWidth, 2); + expect(indicator.edgeOffset, 110); + }); } final Finder _verticalFeed = find.byWidgetPredicate(