diff --git a/lib/widgets/common/basic/tv_edge_focus.dart b/lib/widgets/common/basic/tv_edge_focus.dart index 2c6ddad87..9e624a030 100644 --- a/lib/widgets/common/basic/tv_edge_focus.dart +++ b/lib/widgets/common/basic/tv_edge_focus.dart @@ -5,11 +5,21 @@ import 'package:provider/provider.dart'; // to be placed at the edges of lists and grids, // so that TV can reach them with D-pad class TvEdgeFocus extends StatelessWidget { - const TvEdgeFocus({super.key}); + final FocusNode? focusNode; + + const TvEdgeFocus({ + super.key, + this.focusNode, + }); @override Widget build(BuildContext context) { final useTvLayout = context.select((s) => s.useTvLayout); - return useTvLayout ? const Focus(child: SizedBox()) : const SizedBox(); + return useTvLayout + ? Focus( + focusNode: focusNode, + child: const SizedBox(), + ) + : const SizedBox(); } } diff --git a/lib/widgets/common/expandable_filter_row.dart b/lib/widgets/common/expandable_filter_row.dart index 54d0ebf63..3557e651c 100644 --- a/lib/widgets/common/expandable_filter_row.dart +++ b/lib/widgets/common/expandable_filter_row.dart @@ -1,4 +1,5 @@ import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; @@ -31,26 +32,49 @@ class TitledExpandableFilterRow extends StatelessWidget { final isExpanded = expandedNotifier.value == title; + Widget header = Text( + title, + style: Constants.knownTitleTextStyle, + ); + void toggle() => expandedNotifier.value = isExpanded ? null : title; + if (settings.useTvLayout) { + header = Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: InkWell( + onTap: toggle, + borderRadius: const BorderRadius.all(Radius.circular(123)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + header, + ], + ), + ), + ), + ); + } else { + header = Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + header, + const Spacer(), + IconButton( + icon: Icon(isExpanded ? AIcons.collapse : AIcons.expand), + onPressed: toggle, + tooltip: isExpanded ? MaterialLocalizations.of(context).expandedIconTapHint : MaterialLocalizations.of(context).collapsedIconTapHint, + ), + ], + ), + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Text( - title, - style: Constants.knownTitleTextStyle, - ), - const Spacer(), - IconButton( - icon: Icon(isExpanded ? AIcons.collapse : AIcons.expand), - onPressed: () => expandedNotifier.value = isExpanded ? null : title, - tooltip: isExpanded ? MaterialLocalizations.of(context).expandedIconTapHint : MaterialLocalizations.of(context).collapsedIconTapHint, - ), - ], - ), - ), + header, ExpandableFilterRow( filters: filters, isExpanded: isExpanded, diff --git a/lib/widgets/common/search/delegate.dart b/lib/widgets/common/search/delegate.dart index 282c38aa1..26dd06c57 100644 --- a/lib/widgets/common/search/delegate.dart +++ b/lib/widgets/common/search/delegate.dart @@ -18,6 +18,9 @@ abstract class AvesSearchDelegate extends SearchDelegate { query = initialQuery ?? ''; } + @mustCallSuper + void dispose() {} + @override Widget? buildLeading(BuildContext context) { if (settings.useTvLayout) { @@ -44,7 +47,7 @@ abstract class AvesSearchDelegate extends SearchDelegate { @override List? buildActions(BuildContext context) { return [ - if (query.isNotEmpty) + if (!settings.useTvLayout && query.isNotEmpty) IconButton( icon: const Icon(AIcons.clear), onPressed: () { @@ -63,28 +66,40 @@ abstract class AvesSearchDelegate extends SearchDelegate { void clean() { currentBody = null; - focusNode?.unfocus(); + searchFieldFocusNode?.unfocus(); } // adapted from Flutter `SearchDelegate` in `/material/search.dart` @override void showResults(BuildContext context) { - focusNode?.unfocus(); - currentBody = SearchBody.results; + if (settings.useTvLayout) { + suggestionsScrollController?.jumpTo(0); + WidgetsBinding.instance.addPostFrameCallback((_) { + suggestionsFocusNode?.requestFocus(); + FocusScope.of(context).nextFocus(); + }); + } else { + searchFieldFocusNode?.unfocus(); + currentBody = SearchBody.results; + } } @override void showSuggestions(BuildContext context) { - assert(focusNode != null, '_focusNode must be set by route before showSuggestions is called.'); - focusNode!.requestFocus(); + assert(searchFieldFocusNode != null, '_focusNode must be set by route before showSuggestions is called.'); + searchFieldFocusNode!.requestFocus(); currentBody = SearchBody.suggestions; } @override Animation get transitionAnimation => proxyAnimation; - FocusNode? focusNode; + FocusNode? searchFieldFocusNode; + + FocusNode? get suggestionsFocusNode => null; + + ScrollController? get suggestionsScrollController => null; final TextEditingController queryTextController = TextEditingController(); diff --git a/lib/widgets/common/search/page.dart b/lib/widgets/common/search/page.dart index 4e31065fb..58ebf7e9d 100644 --- a/lib/widgets/common/search/page.dart +++ b/lib/widgets/common/search/page.dart @@ -29,7 +29,7 @@ class SearchPage extends StatefulWidget { class _SearchPageState extends State { final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay); - final FocusNode _focusNode = FocusNode(); + final FocusNode _searchFieldFocusNode = FocusNode(); final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler(); @override @@ -37,7 +37,7 @@ class _SearchPageState extends State { super.initState(); _registerWidget(widget); widget.animation.addStatusListener(_onAnimationStatusChanged); - _focusNode.addListener(_onFocusChanged); + _searchFieldFocusNode.addListener(_onFocusChanged); } @override @@ -53,21 +53,22 @@ class _SearchPageState extends State { void dispose() { _unregisterWidget(widget); widget.animation.removeStatusListener(_onAnimationStatusChanged); - _focusNode.dispose(); + _searchFieldFocusNode.dispose(); _doubleBackPopHandler.dispose(); + widget.delegate.dispose(); super.dispose(); } void _registerWidget(SearchPage widget) { widget.delegate.queryTextController.addListener(_onQueryChanged); widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged); - widget.delegate.focusNode = _focusNode; + widget.delegate.searchFieldFocusNode = _searchFieldFocusNode; } void _unregisterWidget(SearchPage widget) { widget.delegate.queryTextController.removeListener(_onQueryChanged); widget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged); - widget.delegate.focusNode = null; + widget.delegate.searchFieldFocusNode = null; } void _onAnimationStatusChanged(AnimationStatus status) { @@ -77,12 +78,12 @@ class _SearchPageState extends State { widget.animation.removeStatusListener(_onAnimationStatusChanged); Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) { if (!mounted) return; - _focusNode.requestFocus(); + _searchFieldFocusNode.requestFocus(); }); } void _onFocusChanged() { - if (_focusNode.hasFocus && widget.delegate.currentBody != SearchBody.suggestions) { + if (_searchFieldFocusNode.hasFocus && widget.delegate.currentBody != SearchBody.suggestions) { widget.delegate.showSuggestions(context); } } @@ -136,7 +137,7 @@ class _SearchPageState extends State { style: const TextStyle(fontFeatures: [FontFeature.disable('smcp')]), child: TextField( controller: widget.delegate.queryTextController, - focusNode: _focusNode, + focusNode: _searchFieldFocusNode, decoration: InputDecoration( border: InputBorder.none, hintText: widget.delegate.searchFieldLabel, diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 9ce560a16..ab6f21f13 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -19,6 +19,7 @@ import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/basic/tv_edge_focus.dart'; import 'package:aves/widgets/common/expandable_filter_row.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; @@ -33,6 +34,14 @@ class CollectionSearchDelegate extends AvesSearchDelegate { final CollectionSource source; final CollectionLens? parentCollection; final ValueNotifier _expandedSectionNotifier = ValueNotifier(null); + final FocusNode _suggestionsTopFocusNode = FocusNode(); + final ScrollController _suggestionsScrollController = ScrollController(); + + @override + FocusNode? get suggestionsFocusNode => _suggestionsTopFocusNode; + + @override + ScrollController get suggestionsScrollController => _suggestionsScrollController; static const int searchHistoryCount = 10; static final typeFilters = [ @@ -64,6 +73,14 @@ class CollectionSearchDelegate extends AvesSearchDelegate { query = initialQuery ?? ''; } + @override + void dispose() { + _expandedSectionNotifier.dispose(); + _suggestionsTopFocusNode.dispose(); + _suggestionsScrollController.dispose(); + super.dispose(); + } + @override Widget buildSuggestions(BuildContext context) { final upQuery = query.trim().toUpperCase(); @@ -91,8 +108,12 @@ class CollectionSearchDelegate extends AvesSearchDelegate { final history = settings.searchHistory.where(notHidden).toList(); return ListView( + controller: _suggestionsScrollController, padding: const EdgeInsets.only(top: 8), children: [ + TvEdgeFocus( + focusNode: _suggestionsTopFocusNode, + ), _buildFilterRow( context: context, filters: [ diff --git a/lib/widgets/settings/navigation/drawer_tab_albums.dart b/lib/widgets/settings/navigation/drawer_tab_albums.dart index 2f4cf2f02..1b62dcd2a 100644 --- a/lib/widgets/settings/navigation/drawer_tab_albums.dart +++ b/lib/widgets/settings/navigation/drawer_tab_albums.dart @@ -1,4 +1,5 @@ import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -29,8 +30,10 @@ class _DrawerAlbumTabState extends State { final source = context.read(); return Column( children: [ - const DrawerEditorBanner(), - const Divider(height: 0), + if (!settings.useTvLayout) ...[ + const DrawerEditorBanner(), + const Divider(height: 0), + ], Flexible( child: ReorderableListView.builder( itemBuilder: (context, index) { diff --git a/lib/widgets/settings/navigation/drawer_tab_fixed.dart b/lib/widgets/settings/navigation/drawer_tab_fixed.dart index 0ee54de7b..1069693a3 100644 --- a/lib/widgets/settings/navigation/drawer_tab_fixed.dart +++ b/lib/widgets/settings/navigation/drawer_tab_fixed.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/settings/navigation/drawer_editor_banner.dart'; @@ -30,8 +31,10 @@ class _DrawerFixedListTabState extends State> { Widget build(BuildContext context) { return Column( children: [ - const DrawerEditorBanner(), - const Divider(height: 0), + if (!settings.useTvLayout) ...[ + const DrawerEditorBanner(), + const Divider(height: 0), + ], Flexible( child: ReorderableListView.builder( itemBuilder: (context, index) { diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index dd7c02801..e19a65eaf 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -138,10 +138,12 @@ class _ViewerVerticalPageViewState extends State { child: child!, ); }, - child: InfoPage( - collection: collection, - entryNotifier: widget.entryNotifier, - isScrollingNotifier: _isVerticallyScrollingNotifier, + child: FocusScope( + child: InfoPage( + collection: collection, + entryNotifier: widget.entryNotifier, + isScrollingNotifier: _isVerticallyScrollingNotifier, + ), ), ), ); diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index e5b2b5c43..58e56006f 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -7,6 +7,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/basic/insets.dart'; +import 'package:aves/widgets/common/basic/tv_edge_focus.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart'; import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart'; @@ -281,6 +282,9 @@ class _InfoPageContentState extends State<_InfoPageContent> { child: CustomScrollView( controller: widget.scrollController, slivers: [ + const SliverToBoxAdapter( + child: TvEdgeFocus(), + ), InfoAppBar( entry: entry, collection: collection,