tv: search, info
This commit is contained in:
parent
7cf5538408
commit
54aa981fd1
9 changed files with 125 additions and 42 deletions
|
@ -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<Settings, bool>((s) => s.useTvLayout);
|
||||
return useTvLayout ? const Focus(child: SizedBox()) : const SizedBox();
|
||||
return useTvLayout
|
||||
? Focus(
|
||||
focusNode: focusNode,
|
||||
child: const SizedBox(),
|
||||
)
|
||||
: const SizedBox();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<Widget>? 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<double> get transitionAnimation => proxyAnimation;
|
||||
|
||||
FocusNode? focusNode;
|
||||
FocusNode? searchFieldFocusNode;
|
||||
|
||||
FocusNode? get suggestionsFocusNode => null;
|
||||
|
||||
ScrollController? get suggestionsScrollController => null;
|
||||
|
||||
final TextEditingController queryTextController = TextEditingController();
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ class SearchPage extends StatefulWidget {
|
|||
|
||||
class _SearchPageState extends State<SearchPage> {
|
||||
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<SearchPage> {
|
|||
super.initState();
|
||||
_registerWidget(widget);
|
||||
widget.animation.addStatusListener(_onAnimationStatusChanged);
|
||||
_focusNode.addListener(_onFocusChanged);
|
||||
_searchFieldFocusNode.addListener(_onFocusChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -53,21 +53,22 @@ class _SearchPageState extends State<SearchPage> {
|
|||
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<SearchPage> {
|
|||
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<SearchPage> {
|
|||
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,
|
||||
|
|
|
@ -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<String?> _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: [
|
||||
|
|
|
@ -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<DrawerAlbumTab> {
|
|||
final source = context.read<CollectionSource>();
|
||||
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) {
|
||||
|
|
|
@ -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<T> extends State<DrawerFixedListTab<T>> {
|
|||
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) {
|
||||
|
|
|
@ -138,10 +138,12 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
|||
child: child!,
|
||||
);
|
||||
},
|
||||
child: InfoPage(
|
||||
collection: collection,
|
||||
entryNotifier: widget.entryNotifier,
|
||||
isScrollingNotifier: _isVerticallyScrollingNotifier,
|
||||
child: FocusScope(
|
||||
child: InfoPage(
|
||||
collection: collection,
|
||||
entryNotifier: widget.entryNotifier,
|
||||
isScrollingNotifier: _isVerticallyScrollingNotifier,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue