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,
|
// to be placed at the edges of lists and grids,
|
||||||
// so that TV can reach them with D-pad
|
// so that TV can reach them with D-pad
|
||||||
class TvEdgeFocus extends StatelessWidget {
|
class TvEdgeFocus extends StatelessWidget {
|
||||||
const TvEdgeFocus({super.key});
|
final FocusNode? focusNode;
|
||||||
|
|
||||||
|
const TvEdgeFocus({
|
||||||
|
super.key,
|
||||||
|
this.focusNode,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final useTvLayout = context.select<Settings, bool>((s) => s.useTvLayout);
|
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/filters/filters.dart';
|
||||||
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/utils/constants.dart';
|
import 'package:aves/utils/constants.dart';
|
||||||
|
@ -31,26 +32,49 @@ class TitledExpandableFilterRow extends StatelessWidget {
|
||||||
|
|
||||||
final isExpanded = expandedNotifier.value == title;
|
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(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
header,
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ExpandableFilterRow(
|
ExpandableFilterRow(
|
||||||
filters: filters,
|
filters: filters,
|
||||||
isExpanded: isExpanded,
|
isExpanded: isExpanded,
|
||||||
|
|
|
@ -18,6 +18,9 @@ abstract class AvesSearchDelegate extends SearchDelegate {
|
||||||
query = initialQuery ?? '';
|
query = initialQuery ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mustCallSuper
|
||||||
|
void dispose() {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget? buildLeading(BuildContext context) {
|
Widget? buildLeading(BuildContext context) {
|
||||||
if (settings.useTvLayout) {
|
if (settings.useTvLayout) {
|
||||||
|
@ -44,7 +47,7 @@ abstract class AvesSearchDelegate extends SearchDelegate {
|
||||||
@override
|
@override
|
||||||
List<Widget>? buildActions(BuildContext context) {
|
List<Widget>? buildActions(BuildContext context) {
|
||||||
return [
|
return [
|
||||||
if (query.isNotEmpty)
|
if (!settings.useTvLayout && query.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(AIcons.clear),
|
icon: const Icon(AIcons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
@ -63,28 +66,40 @@ abstract class AvesSearchDelegate extends SearchDelegate {
|
||||||
|
|
||||||
void clean() {
|
void clean() {
|
||||||
currentBody = null;
|
currentBody = null;
|
||||||
focusNode?.unfocus();
|
searchFieldFocusNode?.unfocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// adapted from Flutter `SearchDelegate` in `/material/search.dart`
|
// adapted from Flutter `SearchDelegate` in `/material/search.dart`
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void showResults(BuildContext context) {
|
void showResults(BuildContext context) {
|
||||||
focusNode?.unfocus();
|
if (settings.useTvLayout) {
|
||||||
currentBody = SearchBody.results;
|
suggestionsScrollController?.jumpTo(0);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
suggestionsFocusNode?.requestFocus();
|
||||||
|
FocusScope.of(context).nextFocus();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
searchFieldFocusNode?.unfocus();
|
||||||
|
currentBody = SearchBody.results;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void showSuggestions(BuildContext context) {
|
void showSuggestions(BuildContext context) {
|
||||||
assert(focusNode != null, '_focusNode must be set by route before showSuggestions is called.');
|
assert(searchFieldFocusNode != null, '_focusNode must be set by route before showSuggestions is called.');
|
||||||
focusNode!.requestFocus();
|
searchFieldFocusNode!.requestFocus();
|
||||||
currentBody = SearchBody.suggestions;
|
currentBody = SearchBody.suggestions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Animation<double> get transitionAnimation => proxyAnimation;
|
Animation<double> get transitionAnimation => proxyAnimation;
|
||||||
|
|
||||||
FocusNode? focusNode;
|
FocusNode? searchFieldFocusNode;
|
||||||
|
|
||||||
|
FocusNode? get suggestionsFocusNode => null;
|
||||||
|
|
||||||
|
ScrollController? get suggestionsScrollController => null;
|
||||||
|
|
||||||
final TextEditingController queryTextController = TextEditingController();
|
final TextEditingController queryTextController = TextEditingController();
|
||||||
|
|
||||||
|
|
|
@ -29,7 +29,7 @@ class SearchPage extends StatefulWidget {
|
||||||
|
|
||||||
class _SearchPageState extends State<SearchPage> {
|
class _SearchPageState extends State<SearchPage> {
|
||||||
final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay);
|
final Debouncer _debouncer = Debouncer(delay: Durations.searchDebounceDelay);
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _searchFieldFocusNode = FocusNode();
|
||||||
final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler();
|
final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -37,7 +37,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||||
super.initState();
|
super.initState();
|
||||||
_registerWidget(widget);
|
_registerWidget(widget);
|
||||||
widget.animation.addStatusListener(_onAnimationStatusChanged);
|
widget.animation.addStatusListener(_onAnimationStatusChanged);
|
||||||
_focusNode.addListener(_onFocusChanged);
|
_searchFieldFocusNode.addListener(_onFocusChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -53,21 +53,22 @@ class _SearchPageState extends State<SearchPage> {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_unregisterWidget(widget);
|
_unregisterWidget(widget);
|
||||||
widget.animation.removeStatusListener(_onAnimationStatusChanged);
|
widget.animation.removeStatusListener(_onAnimationStatusChanged);
|
||||||
_focusNode.dispose();
|
_searchFieldFocusNode.dispose();
|
||||||
_doubleBackPopHandler.dispose();
|
_doubleBackPopHandler.dispose();
|
||||||
|
widget.delegate.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _registerWidget(SearchPage widget) {
|
void _registerWidget(SearchPage widget) {
|
||||||
widget.delegate.queryTextController.addListener(_onQueryChanged);
|
widget.delegate.queryTextController.addListener(_onQueryChanged);
|
||||||
widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged);
|
widget.delegate.currentBodyNotifier.addListener(_onSearchBodyChanged);
|
||||||
widget.delegate.focusNode = _focusNode;
|
widget.delegate.searchFieldFocusNode = _searchFieldFocusNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _unregisterWidget(SearchPage widget) {
|
void _unregisterWidget(SearchPage widget) {
|
||||||
widget.delegate.queryTextController.removeListener(_onQueryChanged);
|
widget.delegate.queryTextController.removeListener(_onQueryChanged);
|
||||||
widget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged);
|
widget.delegate.currentBodyNotifier.removeListener(_onSearchBodyChanged);
|
||||||
widget.delegate.focusNode = null;
|
widget.delegate.searchFieldFocusNode = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAnimationStatusChanged(AnimationStatus status) {
|
void _onAnimationStatusChanged(AnimationStatus status) {
|
||||||
|
@ -77,12 +78,12 @@ class _SearchPageState extends State<SearchPage> {
|
||||||
widget.animation.removeStatusListener(_onAnimationStatusChanged);
|
widget.animation.removeStatusListener(_onAnimationStatusChanged);
|
||||||
Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) {
|
Future.delayed(Durations.pageTransitionAnimation * timeDilation).then((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_focusNode.requestFocus();
|
_searchFieldFocusNode.requestFocus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onFocusChanged() {
|
void _onFocusChanged() {
|
||||||
if (_focusNode.hasFocus && widget.delegate.currentBody != SearchBody.suggestions) {
|
if (_searchFieldFocusNode.hasFocus && widget.delegate.currentBody != SearchBody.suggestions) {
|
||||||
widget.delegate.showSuggestions(context);
|
widget.delegate.showSuggestions(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -136,7 +137,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||||
style: const TextStyle(fontFeatures: [FontFeature.disable('smcp')]),
|
style: const TextStyle(fontFeatures: [FontFeature.disable('smcp')]),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: widget.delegate.queryTextController,
|
controller: widget.delegate.queryTextController,
|
||||||
focusNode: _focusNode,
|
focusNode: _searchFieldFocusNode,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
hintText: widget.delegate.searchFieldLabel,
|
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/model/source/tag.dart';
|
||||||
import 'package:aves/ref/mime_types.dart';
|
import 'package:aves/ref/mime_types.dart';
|
||||||
import 'package:aves/widgets/collection/collection_page.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/expandable_filter_row.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||||
|
@ -33,6 +34,14 @@ class CollectionSearchDelegate extends AvesSearchDelegate {
|
||||||
final CollectionSource source;
|
final CollectionSource source;
|
||||||
final CollectionLens? parentCollection;
|
final CollectionLens? parentCollection;
|
||||||
final ValueNotifier<String?> _expandedSectionNotifier = ValueNotifier(null);
|
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 const int searchHistoryCount = 10;
|
||||||
static final typeFilters = [
|
static final typeFilters = [
|
||||||
|
@ -64,6 +73,14 @@ class CollectionSearchDelegate extends AvesSearchDelegate {
|
||||||
query = initialQuery ?? '';
|
query = initialQuery ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_expandedSectionNotifier.dispose();
|
||||||
|
_suggestionsTopFocusNode.dispose();
|
||||||
|
_suggestionsScrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget buildSuggestions(BuildContext context) {
|
Widget buildSuggestions(BuildContext context) {
|
||||||
final upQuery = query.trim().toUpperCase();
|
final upQuery = query.trim().toUpperCase();
|
||||||
|
@ -91,8 +108,12 @@ class CollectionSearchDelegate extends AvesSearchDelegate {
|
||||||
final history = settings.searchHistory.where(notHidden).toList();
|
final history = settings.searchHistory.where(notHidden).toList();
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
|
controller: _suggestionsScrollController,
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
children: [
|
children: [
|
||||||
|
TvEdgeFocus(
|
||||||
|
focusNode: _suggestionsTopFocusNode,
|
||||||
|
),
|
||||||
_buildFilterRow(
|
_buildFilterRow(
|
||||||
context: context,
|
context: context,
|
||||||
filters: [
|
filters: [
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:aves/model/filters/album.dart';
|
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/model/source/collection_source.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
@ -29,8 +30,10 @@ class _DrawerAlbumTabState extends State<DrawerAlbumTab> {
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
const DrawerEditorBanner(),
|
if (!settings.useTvLayout) ...[
|
||||||
const Divider(height: 0),
|
const DrawerEditorBanner(),
|
||||||
|
const Divider(height: 0),
|
||||||
|
],
|
||||||
Flexible(
|
Flexible(
|
||||||
child: ReorderableListView.builder(
|
child: ReorderableListView.builder(
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/settings/navigation/drawer_editor_banner.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) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
const DrawerEditorBanner(),
|
if (!settings.useTvLayout) ...[
|
||||||
const Divider(height: 0),
|
const DrawerEditorBanner(),
|
||||||
|
const Divider(height: 0),
|
||||||
|
],
|
||||||
Flexible(
|
Flexible(
|
||||||
child: ReorderableListView.builder(
|
child: ReorderableListView.builder(
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
|
|
@ -138,10 +138,12 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
|
||||||
child: child!,
|
child: child!,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: InfoPage(
|
child: FocusScope(
|
||||||
collection: collection,
|
child: InfoPage(
|
||||||
entryNotifier: widget.entryNotifier,
|
collection: collection,
|
||||||
isScrollingNotifier: _isVerticallyScrollingNotifier,
|
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/model/source/collection_lens.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/widgets/common/basic/insets.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/filter_grids/common/action_delegates/chip.dart';
|
||||||
import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart';
|
import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart';
|
||||||
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
|
import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart';
|
||||||
|
@ -281,6 +282,9 @@ class _InfoPageContentState extends State<_InfoPageContent> {
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: widget.scrollController,
|
controller: widget.scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: TvEdgeFocus(),
|
||||||
|
),
|
||||||
InfoAppBar(
|
InfoAppBar(
|
||||||
entry: entry,
|
entry: entry,
|
||||||
collection: collection,
|
collection: collection,
|
||||||
|
|
Loading…
Reference in a new issue