tv: scrollable policy, focus improvements, pick page review

This commit is contained in:
Thibault Deckers 2023-01-18 11:52:39 +01:00
parent f09f3ede28
commit 7cf5538408
23 changed files with 313 additions and 167 deletions

View file

@ -5,6 +5,7 @@ import 'package:aves/widgets/about/credits.dart';
import 'package:aves/widgets/about/licenses.dart'; import 'package:aves/widgets/about/licenses.dart';
import 'package:aves/widgets/about/translators.dart'; import 'package:aves/widgets/about/translators.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/common/behaviour/pop/scope.dart'; import 'package:aves/widgets/common/behaviour/pop/scope.dart';
import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart'; import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
@ -28,7 +29,8 @@ class AboutPage extends StatelessWidget {
sliver: SliverList( sliver: SliverList(
delegate: SliverChildListDelegate( delegate: SliverChildListDelegate(
[ [
AppReference(showLogo: !useTvLayout), const TvEdgeFocus(),
const AppReference(),
if (!settings.useTvLayout) ...[ if (!settings.useTvLayout) ...[
const Divider(), const Divider(),
const BugReport(), const BugReport(),

View file

@ -10,12 +10,7 @@ import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
class AppReference extends StatefulWidget { class AppReference extends StatefulWidget {
final bool showLogo; const AppReference({super.key});
const AppReference({
super.key,
required this.showLogo,
});
@override @override
State<AppReference> createState() => _AppReferenceState(); State<AppReference> createState() => _AppReferenceState();
@ -24,6 +19,13 @@ class AppReference extends StatefulWidget {
class _AppReferenceState extends State<AppReference> { class _AppReferenceState extends State<AppReference> {
late Future<PackageInfo> _packageInfoLoader; late Future<PackageInfo> _packageInfoLoader;
static const _appTitleStyle = TextStyle(
fontSize: 20,
fontWeight: FontWeight.normal,
letterSpacing: 1.0,
fontFeatures: [FontFeature.enable('smcp')],
);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -44,28 +46,19 @@ class _AppReferenceState extends State<AppReference> {
} }
Widget _buildAvesLine() { Widget _buildAvesLine() {
const style = TextStyle(
fontSize: 20,
fontWeight: FontWeight.normal,
letterSpacing: 1.0,
fontFeatures: [FontFeature.enable('smcp')],
);
return FutureBuilder<PackageInfo>( return FutureBuilder<PackageInfo>(
future: _packageInfoLoader, future: _packageInfoLoader,
builder: (context, snapshot) { builder: (context, snapshot) {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (widget.showLogo) ...[ AvesLogo(
AvesLogo( size: _appTitleStyle.fontSize! * MediaQuery.textScaleFactorOf(context) * 1.3,
size: style.fontSize! * MediaQuery.textScaleFactorOf(context) * 1.3, ),
), const SizedBox(width: 8),
const SizedBox(width: 8),
],
Text( Text(
'${context.l10n.appName} ${snapshot.data?.version}', '${context.l10n.appName} ${snapshot.data?.version}',
style: style, style: _appTitleStyle,
), ),
], ],
); );

View file

@ -1,4 +1,4 @@
import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/about/title.dart';
import 'package:aves/widgets/common/basic/link_chip.dart'; import 'package:aves/widgets/common/basic/link_chip.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -14,13 +14,7 @@ class AboutCredits extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ConstrainedBox( AboutSectionTitle(text: l10n.aboutCreditsSectionTitle),
constraints: const BoxConstraints(minHeight: kMinInteractiveDimension),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(l10n.aboutCreditsSectionTitle, style: Constants.knownTitleTextStyle),
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text.rich( Text.rich(
TextSpan( TextSpan(

View file

@ -1,8 +1,9 @@
import 'package:aves/app_flavor.dart'; import 'package:aves/app_flavor.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/ref/brand_colors.dart'; import 'package:aves/ref/brand_colors.dart';
import 'package:aves/theme/colors.dart'; import 'package:aves/theme/colors.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/dependencies.dart'; import 'package:aves/utils/dependencies.dart';
import 'package:aves/widgets/about/title.dart';
import 'package:aves/widgets/common/basic/link_chip.dart'; import 'package:aves/widgets/common/basic/link_chip.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_expansion_tile.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
@ -50,30 +51,32 @@ class _LicensesState extends State<Licenses> {
[ [
_buildHeader(), _buildHeader(),
const SizedBox(height: 16), const SizedBox(height: 16),
AvesExpansionTile( if (!settings.useTvLayout) ...[
title: context.l10n.aboutLicensesAndroidLibrariesSectionTitle, AvesExpansionTile(
highlightColor: colors.fromBrandColor(BrandColors.android), title: context.l10n.aboutLicensesAndroidLibrariesSectionTitle,
expandedNotifier: _expandedNotifier, highlightColor: colors.fromBrandColor(BrandColors.android),
children: _platform.map((package) => LicenseRow(package: package)).toList(), expandedNotifier: _expandedNotifier,
), children: _platform.map((package) => LicenseRow(package: package)).toList(),
AvesExpansionTile( ),
title: context.l10n.aboutLicensesFlutterPluginsSectionTitle, AvesExpansionTile(
highlightColor: colors.fromBrandColor(BrandColors.flutter), title: context.l10n.aboutLicensesFlutterPluginsSectionTitle,
expandedNotifier: _expandedNotifier, highlightColor: colors.fromBrandColor(BrandColors.flutter),
children: _flutterPlugins.map((package) => LicenseRow(package: package)).toList(), expandedNotifier: _expandedNotifier,
), children: _flutterPlugins.map((package) => LicenseRow(package: package)).toList(),
AvesExpansionTile( ),
title: context.l10n.aboutLicensesFlutterPackagesSectionTitle, AvesExpansionTile(
highlightColor: colors.fromBrandColor(BrandColors.flutter), title: context.l10n.aboutLicensesFlutterPackagesSectionTitle,
expandedNotifier: _expandedNotifier, highlightColor: colors.fromBrandColor(BrandColors.flutter),
children: _flutterPackages.map((package) => LicenseRow(package: package)).toList(), expandedNotifier: _expandedNotifier,
), children: _flutterPackages.map((package) => LicenseRow(package: package)).toList(),
AvesExpansionTile( ),
title: context.l10n.aboutLicensesDartPackagesSectionTitle, AvesExpansionTile(
highlightColor: colors.fromBrandColor(BrandColors.flutter), title: context.l10n.aboutLicensesDartPackagesSectionTitle,
expandedNotifier: _expandedNotifier, highlightColor: colors.fromBrandColor(BrandColors.flutter),
children: _dartPackages.map((package) => LicenseRow(package: package)).toList(), expandedNotifier: _expandedNotifier,
), children: _dartPackages.map((package) => LicenseRow(package: package)).toList(),
),
],
Center( Center(
child: AvesOutlinedButton( child: AvesOutlinedButton(
label: context.l10n.aboutLicensesShowAllButtonLabel, label: context.l10n.aboutLicensesShowAllButtonLabel,
@ -104,13 +107,7 @@ class _LicensesState extends State<Licenses> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ConstrainedBox( AboutSectionTitle(text: context.l10n.aboutLicensesSectionTitle),
constraints: const BoxConstraints(minHeight: kMinInteractiveDimension),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(context.l10n.aboutLicensesSectionTitle, style: Constants.knownTitleTextStyle),
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(context.l10n.aboutLicensesBanner), Text(context.l10n.aboutLicensesBanner),
], ],

View file

@ -1,3 +1,4 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/basic/markdown_container.dart'; import 'package:aves/widgets/common/basic/markdown_container.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -14,6 +15,7 @@ class PolicyPage extends StatefulWidget {
class _PolicyPageState extends State<PolicyPage> { class _PolicyPageState extends State<PolicyPage> {
late Future<String> _termsLoader; late Future<String> _termsLoader;
final ScrollController _scrollController = ScrollController();
static const termsPath = 'assets/terms.md'; static const termsPath = 'assets/terms.md';
static const termsDirection = TextDirection.ltr; static const termsDirection = TextDirection.ltr;
@ -28,26 +30,72 @@ class _PolicyPageState extends State<PolicyPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !settings.useTvLayout,
title: Text(context.l10n.policyPageTitle), title: Text(context.l10n.policyPageTitle),
), ),
body: SafeArea( body: SafeArea(
child: Center( child: FocusableActionDetector(
child: FutureBuilder<String>( autofocus: true,
future: _termsLoader, shortcuts: const {
builder: (context, snapshot) { SingleActivator(LogicalKeyboardKey.arrowUp): _ScrollIntent.up(),
if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return const SizedBox(); SingleActivator(LogicalKeyboardKey.arrowDown): _ScrollIntent.down(),
final terms = snapshot.data!; },
return Padding( actions: {
padding: const EdgeInsets.symmetric(vertical: 8), _ScrollIntent: CallbackAction<_ScrollIntent>(onInvoke: _onScrollIntent),
child: MarkdownContainer( },
data: terms, child: Center(
textDirection: termsDirection, child: FutureBuilder<String>(
), future: _termsLoader,
); builder: (context, snapshot) {
}, if (snapshot.hasError || snapshot.connectionState != ConnectionState.done) return const SizedBox();
final terms = snapshot.data!;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: MarkdownContainer(
scrollController: _scrollController,
data: terms,
textDirection: termsDirection,
),
);
},
),
), ),
), ),
), ),
); );
} }
void _onScrollIntent(_ScrollIntent intent) {
late int factor;
switch (intent.type) {
case _ScrollDirection.up:
factor = -1;
break;
case _ScrollDirection.down:
factor = 1;
break;
}
_scrollController.animateTo(
_scrollController.offset + factor * 150,
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutCubic,
);
}
}
class _ScrollIntent extends Intent {
const _ScrollIntent({
required this.type,
});
const _ScrollIntent.up() : type = _ScrollDirection.up;
const _ScrollIntent.down() : type = _ScrollDirection.down;
final _ScrollDirection type;
}
enum _ScrollDirection {
up,
down,
} }

View file

@ -0,0 +1,37 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/constants.dart';
import 'package:flutter/material.dart';
class AboutSectionTitle extends StatelessWidget {
final String text;
const AboutSectionTitle({
super.key,
required this.text,
});
@override
Widget build(BuildContext context) {
Widget child = Container(
alignment: AlignmentDirectional.centerStart,
constraints: const BoxConstraints(minHeight: kMinInteractiveDimension),
child: Text(text, style: Constants.knownTitleTextStyle),
);
if (settings.useTvLayout) {
child = InkWell(
borderRadius: const BorderRadius.all(Radius.circular(123)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
child,
],
),
),
);
}
return child;
}
}

View file

@ -1,6 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/about/title.dart';
import 'package:aves/widgets/common/basic/text/change_highlight.dart'; import 'package:aves/widgets/common/basic/text/change_highlight.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -55,23 +56,16 @@ class AboutTranslators extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ConstrainedBox( AboutSectionTitle(text: context.l10n.aboutTranslatorsSectionTitle),
constraints: const BoxConstraints(minHeight: kMinInteractiveDimension),
child: Align(
alignment: AlignmentDirectional.centerStart,
child: Text(l10n.aboutTranslatorsSectionTitle, style: Constants.knownTitleTextStyle),
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
_RandomTextSpanHighlighter( _RandomTextSpanHighlighter(
spans: translators.map((v) => v.name).toList(), spans: translators.map((v) => v.name).toList(),
highlightColor: Theme.of(context).colorScheme.onPrimary, color: Theme.of(context).colorScheme.onPrimary,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
@ -82,11 +76,11 @@ class AboutTranslators extends StatelessWidget {
class _RandomTextSpanHighlighter extends StatefulWidget { class _RandomTextSpanHighlighter extends StatefulWidget {
final List<String> spans; final List<String> spans;
final Color highlightColor; final Color color;
const _RandomTextSpanHighlighter({ const _RandomTextSpanHighlighter({
required this.spans, required this.spans,
required this.highlightColor, required this.color,
}); });
@override @override
@ -103,18 +97,21 @@ class _RandomTextSpanHighlighterState extends State<_RandomTextSpanHighlighter>
void initState() { void initState() {
super.initState(); super.initState();
final color = widget.color;
_baseStyle = TextStyle( _baseStyle = TextStyle(
color: color.withOpacity(.7),
shadows: [ shadows: [
Shadow( Shadow(
color: widget.highlightColor.withOpacity(0), color: color.withOpacity(0),
blurRadius: 0, blurRadius: 0,
) )
], ],
); );
final highlightStyle = TextStyle( final highlightStyle = TextStyle(
color: color.withOpacity(1),
shadows: [ shadows: [
Shadow( Shadow(
color: widget.highlightColor, color: color.withOpacity(1),
blurRadius: 3, blurRadius: 3,
) )
], ],
@ -133,7 +130,7 @@ class _RandomTextSpanHighlighterState extends State<_RandomTextSpanHighlighter>
..repeat(reverse: true); ..repeat(reverse: true);
_animatedStyle = ShadowedTextStyleTween(begin: _baseStyle, end: highlightStyle).animate(CurvedAnimation( _animatedStyle = ShadowedTextStyleTween(begin: _baseStyle, end: highlightStyle).animate(CurvedAnimation(
parent: _controller, parent: _controller,
curve: Curves.linear, curve: Curves.easeInOutCubic,
)); ));
} }

View file

@ -540,6 +540,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
? 'profile' ? 'profile'
: 'debug', : 'debug',
'has_mobile_services': mobileServices.isServiceAvailable, 'has_mobile_services': mobileServices.isServiceAvailable,
'is_television': device.isTelevision,
'locales': WidgetsBinding.instance.window.locales.join(', '), 'locales': WidgetsBinding.instance.window.locales.join(', '),
'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})', 'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})',
}); });

View file

@ -58,6 +58,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
}) { }) {
final canWrite = !settings.isReadOnly; final canWrite = !settings.isReadOnly;
final isMain = appMode == AppMode.main; final isMain = appMode == AppMode.main;
final useTvLayout = settings.useTvLayout;
switch (action) { switch (action) {
// general // general
case EntrySetAction.configureView: case EntrySetAction.configureView:
@ -70,9 +71,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
return isSelecting && selectedItemCount == itemCount; return isSelecting && selectedItemCount == itemCount;
// browsing // browsing
case EntrySetAction.searchCollection: case EntrySetAction.searchCollection:
return !settings.useTvLayout && appMode.canNavigate && !isSelecting; return !useTvLayout && appMode.canNavigate && !isSelecting;
case EntrySetAction.toggleTitleSearch: case EntrySetAction.toggleTitleSearch:
return !isSelecting; return !useTvLayout && !isSelecting;
case EntrySetAction.addShortcut: case EntrySetAction.addShortcut:
return isMain && !isSelecting && device.canPinShortcut && !isTrash; return isMain && !isSelecting && device.canPinShortcut && !isTrash;
case EntrySetAction.emptyBin: case EntrySetAction.emptyBin:
@ -83,7 +84,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
case EntrySetAction.stats: case EntrySetAction.stats:
return isMain; return isMain;
case EntrySetAction.rescan: case EntrySetAction.rescan:
return !settings.useTvLayout && isMain && !isTrash; return !useTvLayout && isMain && !isTrash;
// selecting // selecting
case EntrySetAction.share: case EntrySetAction.share:
case EntrySetAction.toggleFavourite: case EntrySetAction.toggleFavourite:

View file

@ -27,7 +27,7 @@ import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/dialogs/pick_dialogs/album_pick_page.dart';
import 'package:aves/widgets/viewer/notifications.dart'; import 'package:aves/widgets/viewer/notifications.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -6,11 +6,13 @@ import 'package:flutter_markdown/flutter_markdown.dart';
class MarkdownContainer extends StatelessWidget { class MarkdownContainer extends StatelessWidget {
final String data; final String data;
final TextDirection? textDirection; final TextDirection? textDirection;
final ScrollController? scrollController;
const MarkdownContainer({ const MarkdownContainer({
super.key, super.key,
required this.data, required this.data,
this.textDirection, this.textDirection,
this.scrollController,
}); });
static const double maxWidth = 460; static const double maxWidth = 460;
@ -44,6 +46,7 @@ class MarkdownContainer extends StatelessWidget {
data: data, data: data,
selectable: true, selectable: true,
onTapLink: (text, href, title) => AvesApp.launchUrl(href), onTapLink: (text, href, title) => AvesApp.launchUrl(href),
controller: scrollController,
shrinkWrap: true, shrinkWrap: true,
), ),
), ),

View file

@ -0,0 +1,15 @@
import 'package:aves/model/settings/settings.dart';
import 'package:flutter/material.dart';
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});
@override
Widget build(BuildContext context) {
final useTvLayout = context.select<Settings, bool>((s) => s.useTvLayout);
return useTvLayout ? const Focus(child: SizedBox()) : const SizedBox();
}
}

View file

@ -34,18 +34,10 @@ class SectionHeader<T> extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget child = _buildContent(context); Widget child = _buildContent(context);
if (settings.useTvLayout) { if (settings.useTvLayout) {
final primaryColor = Theme.of(context).colorScheme.primary; child = InkWell(
child = Material( onTap: _onTap(context),
type: MaterialType.transparency, borderRadius: const BorderRadius.all(Radius.circular(123)),
child: InkResponse( child: child,
onTap: _onTap(context),
containedInkWell: true,
highlightShape: BoxShape.rectangle,
borderRadius: const BorderRadius.all(Radius.circular(123)),
hoverColor: primaryColor.withOpacity(0.04),
splashColor: primaryColor.withOpacity(0.12),
child: child,
),
); );
} }
return Container( return Container(

View file

@ -8,7 +8,7 @@ class CaptionedButton extends StatefulWidget {
final Animation<double> scale; final Animation<double> scale;
final Widget captionText; final Widget captionText;
final CaptionedIconButtonBuilder iconButtonBuilder; final CaptionedIconButtonBuilder iconButtonBuilder;
final bool showCaption; final bool autofocus, showCaption;
final VoidCallback? onPressed; final VoidCallback? onPressed;
static const EdgeInsets padding = EdgeInsets.symmetric(horizontal: 8); static const EdgeInsets padding = EdgeInsets.symmetric(horizontal: 8);
@ -21,6 +21,7 @@ class CaptionedButton extends StatefulWidget {
CaptionedIconButtonBuilder? iconButtonBuilder, CaptionedIconButtonBuilder? iconButtonBuilder,
String? caption, String? caption,
Widget? captionText, Widget? captionText,
this.autofocus = false,
this.showCaption = true, this.showCaption = true,
required this.onPressed, required this.onPressed,
}) : assert(icon != null || iconButtonBuilder != null), }) : assert(icon != null || iconButtonBuilder != null),
@ -57,6 +58,7 @@ class CaptionedButton extends StatefulWidget {
class _CaptionedButtonState extends State<CaptionedButton> { class _CaptionedButtonState extends State<CaptionedButton> {
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
final ValueNotifier<bool> _focusedNotifier = ValueNotifier(false); final ValueNotifier<bool> _focusedNotifier = ValueNotifier(false);
bool _didAutofocus = false;
@override @override
void initState() { void initState() {
@ -65,12 +67,21 @@ class _CaptionedButtonState extends State<CaptionedButton> {
_focusNode.addListener(_onFocusChanged); _focusNode.addListener(_onFocusChanged);
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
_handleAutofocus();
}
@override @override
void didUpdateWidget(covariant CaptionedButton oldWidget) { void didUpdateWidget(covariant CaptionedButton oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.onPressed != widget.onPressed) { if (oldWidget.onPressed != widget.onPressed) {
_updateTraversal(); _updateTraversal();
} }
if (oldWidget.autofocus != widget.autofocus) {
_handleAutofocus();
}
} }
@override @override
@ -120,6 +131,13 @@ class _CaptionedButtonState extends State<CaptionedButton> {
); );
} }
void _handleAutofocus() {
if (!_didAutofocus && widget.autofocus) {
FocusScope.of(context).autofocus(_focusNode);
_didAutofocus = true;
}
}
void _onFocusChanged() => _focusedNotifier.value = _focusNode.hasFocus; void _onFocusChanged() => _focusedNotifier.value = _focusNode.hasFocus;
void _updateTraversal() { void _updateTraversal() {

View file

@ -13,6 +13,7 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/basic/menu.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';
import 'package:aves/widgets/common/identity/buttons/captioned_button.dart';
import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart';
@ -128,6 +129,53 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
Selection<FilterGridItem<AlbumFilter>> selection, Selection<FilterGridItem<AlbumFilter>> selection,
AlbumChipSetActionDelegate actionDelegate, AlbumChipSetActionDelegate actionDelegate,
) { ) {
final itemCount = actionDelegate.allItems.length;
final isSelecting = selection.isSelecting;
final selectedItems = selection.selectedItems;
final selectedFilters = selectedItems.map((v) => v.filter).toSet();
bool isVisible(ChipSetAction action) => actionDelegate.isVisible(
action,
appMode: appMode,
isSelecting: isSelecting,
itemCount: itemCount,
selectedFilters: selectedFilters,
);
return settings.useTvLayout
? _buildTelevisionActions(
context: context,
isVisible: isVisible,
actionDelegate: actionDelegate,
)
: _buildMobileActions(
context: context,
isVisible: isVisible,
actionDelegate: actionDelegate,
);
}
List<Widget> _buildTelevisionActions({
required BuildContext context,
required bool Function(ChipSetAction action) isVisible,
required AlbumChipSetActionDelegate actionDelegate,
}) {
return [
...ChipSetActions.general,
].where(isVisible).map((action) {
return CaptionedButton(
icon: action.getIcon(),
caption: action.getText(context),
onPressed: () => actionDelegate.onActionSelected(context, {}, action),
);
}).toList();
}
List<Widget> _buildMobileActions({
required BuildContext context,
required bool Function(ChipSetAction action) isVisible,
required AlbumChipSetActionDelegate actionDelegate,
}) {
return [ return [
if (widget.moveType != null) if (widget.moveType != null)
IconButton( IconButton(
@ -149,7 +197,7 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
child: PopupMenuButton<ChipSetAction>( child: PopupMenuButton<ChipSetAction>(
itemBuilder: (context) { itemBuilder: (context) {
return [ return [
FilterGridAppBar.toMenuItem(context, ChipSetAction.configureView, enabled: true), ...ChipSetActions.general.where(isVisible).map((action) => FilterGridAppBar.toMenuItem(context, action, enabled: true)),
const PopupMenuDivider(), const PopupMenuDivider(),
FilterGridAppBar.toMenuItem(context, ChipSetAction.toggleTitleSearch, enabled: true), FilterGridAppBar.toMenuItem(context, ChipSetAction.toggleTitleSearch, enabled: true),
]; ];

View file

@ -1,4 +1,5 @@
import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/image_providers/app_icon_image_provider.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/basic/query_bar.dart'; import 'package:aves/widgets/common/basic/query_bar.dart';
@ -37,8 +38,10 @@ class _AppPickPageState extends State<AppPickPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final useTvLayout = settings.useTvLayout;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !useTvLayout,
title: Text(context.l10n.appPickDialogTitle), title: Text(context.l10n.appPickDialogTitle),
), ),
body: SafeArea( body: SafeArea(
@ -57,7 +60,7 @@ class _AppPickPageState extends State<AppPickPage> {
final packages = allPackages.where((package) => package.categoryLauncher).toList()..sort((a, b) => compareAsciiUpperCase(_displayName(a), _displayName(b))); final packages = allPackages.where((package) => package.categoryLauncher).toList()..sort((a, b) => compareAsciiUpperCase(_displayName(a), _displayName(b)));
return Column( return Column(
children: [ children: [
QueryBar(queryNotifier: _queryNotifier), if (!useTvLayout) QueryBar(queryNotifier: _queryNotifier),
ValueListenableBuilder<String>( ValueListenableBuilder<String>(
valueListenable: _queryNotifier, valueListenable: _queryNotifier,
builder: (context, query, child) { builder: (context, query, child) {

View file

@ -70,6 +70,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
final selectedItemCount = selectedFilters.length; final selectedItemCount = selectedFilters.length;
final hasSelection = selectedFilters.isNotEmpty; final hasSelection = selectedFilters.isNotEmpty;
final isMain = appMode == AppMode.main; final isMain = appMode == AppMode.main;
final useTvLayout = settings.useTvLayout;
switch (action) { switch (action) {
// general // general
case ChipSetAction.configureView: case ChipSetAction.configureView:
@ -82,9 +83,9 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
return isSelecting && selectedItemCount == itemCount; return isSelecting && selectedItemCount == itemCount;
// browsing // browsing
case ChipSetAction.search: case ChipSetAction.search:
return !settings.useTvLayout && appMode.canNavigate && !isSelecting; return !useTvLayout && appMode.canNavigate && !isSelecting;
case ChipSetAction.toggleTitleSearch: case ChipSetAction.toggleTitleSearch:
return !isSelecting; return !useTvLayout && !isSelecting;
case ChipSetAction.createAlbum: case ChipSetAction.createAlbum:
return false; return false;
// browsing or selecting // browsing or selecting

View file

@ -115,15 +115,23 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
); );
if (useTvLayout) { if (useTvLayout) {
final canNavigate = context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate);
return Scaffold( return Scaffold(
body: Row( body: canNavigate
children: [ ? Row(
TvRail( children: [
controller: context.read<TvRailController>(), TvRail(
), controller: context.read<TvRailController>(),
Expanded(child: body), ),
], Expanded(child: body),
), ],
)
: DirectionalSafeArea(
top: false,
end: false,
bottom: false,
child: body,
),
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
extendBody: true, extendBody: true,
); );

View file

@ -277,11 +277,12 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
MapAction.zoomIn, MapAction.zoomIn,
MapAction.zoomOut, MapAction.zoomOut,
] ]
.map((action) => Padding( .mapIndexed((i, action) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: CaptionedButton( child: CaptionedButton(
icon: action.getIcon(), icon: action.getIcon(),
caption: action.getText(context), caption: action.getText(context),
autofocus: i == 0,
onPressed: () => MapActionDelegate(_mapController).onActionSelected(context, action), onPressed: () => MapActionDelegate(_mapController).onActionSelected(context, action),
), ),
)) ))

View file

@ -3,7 +3,7 @@ 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';
import 'package:aves/widgets/common/identity/buttons/outlined_button.dart'; import 'package:aves/widgets/common/identity/buttons/outlined_button.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/dialogs/pick_dialogs/album_pick_page.dart';
import 'package:aves/widgets/navigation/drawer/tile.dart'; import 'package:aves/widgets/navigation/drawer/tile.dart';
import 'package:aves/widgets/settings/navigation/drawer_editor_banner.dart'; import 'package:aves/widgets/settings/navigation/drawer_editor_banner.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -111,45 +111,37 @@ class _MimeDonutState extends State<MimeDonut> with AutomaticKeepAliveClientMixi
], ],
), ),
); );
final primaryColor = Theme.of(context).colorScheme.primary;
final legend = SizedBox( final legend = SizedBox(
width: dim, width: dim,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: seriesData children: seriesData
.map((d) => Material( .map((d) => InkWell(
type: MaterialType.transparency, onTap: () => widget.onFilterSelection(MimeFilter(d.mimeType)),
child: InkResponse( borderRadius: const BorderRadius.all(Radius.circular(123)),
onTap: () => widget.onFilterSelection(MimeFilter(d.mimeType)), child: Row(
containedInkWell: true, mainAxisSize: MainAxisSize.min,
highlightShape: BoxShape.rectangle, children: [
borderRadius: const BorderRadius.all(Radius.circular(123)), Icon(AIcons.disc, color: d.color),
hoverColor: primaryColor.withOpacity(0.04), const SizedBox(width: 8),
splashColor: primaryColor.withOpacity(0.12), Flexible(
child: Row( child: Text(
mainAxisSize: MainAxisSize.min, d.displayText,
children: [ overflow: TextOverflow.fade,
Icon(AIcons.disc, color: d.color), softWrap: false,
const SizedBox(width: 8), maxLines: 1,
Flexible(
child: Text(
d.displayText,
overflow: TextOverflow.fade,
softWrap: false,
maxLines: 1,
),
), ),
const SizedBox(width: 8), ),
Text( const SizedBox(width: 8),
numberFormat.format(d.entryCount), Text(
style: TextStyle( numberFormat.format(d.entryCount),
color: Theme.of(context).textTheme.bodySmall!.color, style: TextStyle(
), color: Theme.of(context).textTheme.bodySmall!.color,
), ),
const SizedBox(width: 4), ),
], const SizedBox(width: 4),
), ],
), ),
)) ))
.toList(), .toList(),

View file

@ -15,6 +15,7 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.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/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
@ -96,6 +97,7 @@ class _StatsPageState extends State<StatsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final useTvLayout = settings.useTvLayout;
return ValueListenableBuilder<bool>( return ValueListenableBuilder<bool>(
valueListenable: _isPageAnimatingNotifier, valueListenable: _isPageAnimatingNotifier,
builder: (context, animating, child) { builder: (context, animating, child) {
@ -196,6 +198,7 @@ class _StatsPageState extends State<StatsPage> {
), ),
), ),
children: [ children: [
const TvEdgeFocus(),
mimeDonuts, mimeDonuts,
Histogram( Histogram(
entries: entries, entries: entries,
@ -218,7 +221,7 @@ class _StatsPageState extends State<StatsPage> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !settings.useTvLayout, automaticallyImplyLeading: !useTvLayout,
title: Text(l10n.statsPageTitle), title: Text(l10n.statsPageTitle),
), ),
body: GestureAreaProtectorStack( body: GestureAreaProtectorStack(
@ -274,23 +277,15 @@ class _StatsPageState extends State<StatsPage> {
style: Constants.knownTitleTextStyle, style: Constants.knownTitleTextStyle,
); );
if (settings.useTvLayout) { if (settings.useTvLayout) {
final primaryColor = Theme.of(context).colorScheme.primary;
header = Container( header = Container(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
child: Material( child: InkWell(
type: MaterialType.transparency, onTap: onHeaderPressed,
child: InkResponse( borderRadius: const BorderRadius.all(Radius.circular(123)),
onTap: onHeaderPressed, child: Padding(
containedInkWell: true, padding: const EdgeInsets.all(16),
highlightShape: BoxShape.rectangle, child: header,
borderRadius: const BorderRadius.all(Radius.circular(123)),
hoverColor: primaryColor.withOpacity(0.04),
splashColor: primaryColor.withOpacity(0.12),
child: Padding(
padding: const EdgeInsets.all(16),
child: header,
),
), ),
), ),
); );

View file

@ -31,7 +31,7 @@ import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart';
import 'package:aves/widgets/dialogs/export_entry_dialog.dart'; import 'package:aves/widgets/dialogs/export_entry_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/dialogs/pick_dialogs/album_pick_page.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/action/printer.dart'; import 'package:aves/widgets/viewer/action/printer.dart';
import 'package:aves/widgets/viewer/action/single_entry_editor.dart'; import 'package:aves/widgets/viewer/action/single_entry_editor.dart';