tv: button focus style, stats page

This commit is contained in:
Thibault Deckers 2023-01-06 15:44:31 +01:00
parent 96f72fcdb3
commit b0f613db27
19 changed files with 448 additions and 242 deletions

View file

@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/entry_set_actions.dart';
@ -346,7 +345,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
].where(isVisible).map((action) { ].where(isVisible).map((action) {
final enabled = canApply(action); final enabled = canApply(action);
return CaptionedButton( return CaptionedButton(
iconButton: _buildButtonIcon(context, action, enabled: enabled, selection: selection), iconButtonBuilder: (context, focusNode) => _buildButtonIcon(
context,
action,
enabled: enabled,
selection: selection,
focusNode: focusNode,
),
captionText: _buildButtonCaption(context, action, enabled: enabled), captionText: _buildButtonCaption(context, action, enabled: enabled),
onPressed: enabled ? () => _onActionSelected(action) : null, onPressed: enabled ? () => _onActionSelected(action) : null,
); );
@ -433,6 +438,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
BuildContext context, BuildContext context,
EntrySetAction action, { EntrySetAction action, {
required bool enabled, required bool enabled,
FocusNode? focusNode,
required Selection<AvesEntry> selection, required Selection<AvesEntry> selection,
}) { }) {
final onPressed = enabled ? () => _onActionSelected(action) : null; final onPressed = enabled ? () => _onActionSelected(action) : null;
@ -445,12 +451,14 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
return TitleSearchToggler( return TitleSearchToggler(
queryEnabled: queryEnabled, queryEnabled: queryEnabled,
onPressed: onPressed, onPressed: onPressed,
focusNode: focusNode,
); );
}, },
); );
case EntrySetAction.toggleFavourite: case EntrySetAction.toggleFavourite:
return FavouriteToggler( return FavouriteToggler(
entries: _getExpandedSelectedItems(selection), entries: _getExpandedSelectedItems(selection),
focusNode: focusNode,
onPressed: onPressed, onPressed: onPressed,
); );
default: default:
@ -458,6 +466,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
key: _getActionKey(action), key: _getActionKey(action),
icon: action.getIcon(), icon: action.getIcon(),
onPressed: onPressed, onPressed: onPressed,
focusNode: focusNode,
tooltip: action.getText(context), tooltip: action.getText(context),
); );
} }
@ -581,7 +590,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
void _onQueryFocusRequest() => _queryBarFocusNode.requestFocus(); void _onQueryFocusRequest() => _queryBarFocusNode.requestFocus();
void _updateStatusBarHeight() { void _updateStatusBarHeight() {
_statusBarHeight = EdgeInsets.fromWindowPadding(window.padding, window.devicePixelRatio).top; _statusBarHeight = context.read<MediaQueryData>().padding.top;
_updateAppBarHeight(); _updateAppBarHeight();
} }

View file

@ -8,12 +8,14 @@ import 'package:provider/provider.dart';
abstract class ChooserQuickButton<T> extends StatefulWidget { abstract class ChooserQuickButton<T> extends StatefulWidget {
final bool blurred; final bool blurred;
final ValueSetter<T>? onChooserValue; final ValueSetter<T>? onChooserValue;
final FocusNode? focusNode;
final VoidCallback? onPressed; final VoidCallback? onPressed;
const ChooserQuickButton({ const ChooserQuickButton({
super.key, super.key,
required this.blurred, required this.blurred,
this.onChooserValue, this.onChooserValue,
this.focusNode,
required this.onPressed, required this.onPressed,
}); });
} }
@ -71,6 +73,7 @@ abstract class ChooserQuickButtonState<T extends ChooserQuickButton<U>, U> exten
child: IconButton( child: IconButton(
icon: icon, icon: icon,
onPressed: widget.onPressed, onPressed: widget.onPressed,
focusNode: widget.focusNode,
tooltip: _hasChooser ? null : tooltip, tooltip: _hasChooser ? null : tooltip,
), ),
); );

View file

@ -8,6 +8,7 @@ class RateButton extends ChooserQuickButton<int> {
super.key, super.key,
required super.blurred, required super.blurred,
super.onChooserValue, super.onChooserValue,
super.focusNode,
required super.onPressed, required super.onPressed,
}); });

View file

@ -13,6 +13,7 @@ class ShareButton extends ChooserQuickButton<ShareAction> {
required super.blurred, required super.blurred,
required this.entries, required this.entries,
super.onChooserValue, super.onChooserValue,
super.focusNode,
required super.onPressed, required super.onPressed,
}); });

View file

@ -17,6 +17,7 @@ class TagButton extends ChooserQuickButton<CollectionFilter> {
super.key, super.key,
required super.blurred, required super.blurred,
super.onChooserValue, super.onChooserValue,
super.focusNode,
required super.onPressed, required super.onPressed,
}); });

View file

@ -12,12 +12,14 @@ import 'package:provider/provider.dart';
class FavouriteToggler extends StatefulWidget { class FavouriteToggler extends StatefulWidget {
final Set<AvesEntry> entries; final Set<AvesEntry> entries;
final bool isMenuItem; final bool isMenuItem;
final FocusNode? focusNode;
final VoidCallback? onPressed; final VoidCallback? onPressed;
const FavouriteToggler({ const FavouriteToggler({
super.key, super.key,
required this.entries, required this.entries,
this.isMenuItem = false, this.isMenuItem = false,
this.focusNode,
this.onPressed, this.onPressed,
}); });
@ -76,6 +78,7 @@ class _FavouriteTogglerState extends State<FavouriteToggler> {
IconButton( IconButton(
icon: Icon(isFavourite ? isFavouriteIcon : isNotFavouriteIcon), icon: Icon(isFavourite ? isFavouriteIcon : isNotFavouriteIcon),
onPressed: widget.onPressed, onPressed: widget.onPressed,
focusNode: widget.focusNode,
tooltip: isFavourite ? context.l10n.entryActionRemoveFavourite : context.l10n.entryActionAddFavourite, tooltip: isFavourite ? context.l10n.entryActionRemoveFavourite : context.l10n.entryActionAddFavourite,
), ),
Sweeper( Sweeper(

View file

@ -10,12 +10,14 @@ import 'package:flutter/material.dart';
class MuteToggler extends StatelessWidget { class MuteToggler extends StatelessWidget {
final AvesVideoController? controller; final AvesVideoController? controller;
final bool isMenuItem; final bool isMenuItem;
final FocusNode? focusNode;
final VoidCallback? onPressed; final VoidCallback? onPressed;
const MuteToggler({ const MuteToggler({
super.key, super.key,
required this.controller, required this.controller,
this.isMenuItem = false, this.isMenuItem = false,
this.focusNode,
this.onPressed, this.onPressed,
}); });
@ -40,6 +42,7 @@ class MuteToggler extends StatelessWidget {
: IconButton( : IconButton(
icon: icon, icon: icon,
onPressed: canDo ? onPressed : null, onPressed: canDo ? onPressed : null,
focusNode: focusNode,
tooltip: text, tooltip: text,
); );
}, },

View file

@ -12,12 +12,14 @@ import 'package:provider/provider.dart';
class PlayToggler extends StatefulWidget { class PlayToggler extends StatefulWidget {
final AvesVideoController? controller; final AvesVideoController? controller;
final bool isMenuItem; final bool isMenuItem;
final FocusNode? focusNode;
final VoidCallback? onPressed; final VoidCallback? onPressed;
const PlayToggler({ const PlayToggler({
super.key, super.key,
required this.controller, required this.controller,
this.isMenuItem = false, this.isMenuItem = false,
this.focusNode,
this.onPressed, this.onPressed,
}); });
@ -86,6 +88,7 @@ class _PlayTogglerState extends State<PlayToggler> with SingleTickerProviderStat
progress: _playPauseAnimation, progress: _playPauseAnimation,
), ),
onPressed: widget.onPressed, onPressed: widget.onPressed,
focusNode: widget.focusNode,
tooltip: text, tooltip: text,
); );
} }

View file

@ -8,12 +8,14 @@ import 'package:provider/provider.dart';
class TitleSearchToggler extends StatelessWidget { class TitleSearchToggler extends StatelessWidget {
final bool queryEnabled, isMenuItem; final bool queryEnabled, isMenuItem;
final FocusNode? focusNode;
final VoidCallback? onPressed; final VoidCallback? onPressed;
const TitleSearchToggler({ const TitleSearchToggler({
super.key, super.key,
required this.queryEnabled, required this.queryEnabled,
this.isMenuItem = false, this.isMenuItem = false,
this.focusNode,
this.onPressed, this.onPressed,
}); });
@ -29,6 +31,7 @@ class TitleSearchToggler extends StatelessWidget {
: IconButton( : IconButton(
icon: icon, icon: icon,
onPressed: onPressed, onPressed: onPressed,
focusNode: focusNode,
tooltip: text, tooltip: text,
); );
} }

View file

@ -13,15 +13,15 @@ class AvesBorder {
// 1 device pixel for curves is too thin // 1 device pixel for curves is too thin
static double get curvedBorderWidth => window.devicePixelRatio > 2 ? 0.5 : 1.0; static double get curvedBorderWidth => window.devicePixelRatio > 2 ? 0.5 : 1.0;
static BorderSide straightSide(BuildContext context) => BorderSide( static BorderSide straightSide(BuildContext context, {double? width}) => BorderSide(
color: _borderColor(context), color: _borderColor(context),
width: straightBorderWidth, width: width ?? straightBorderWidth,
); );
static BorderSide curvedSide(BuildContext context) => BorderSide( static BorderSide curvedSide(BuildContext context, {double? width}) => BorderSide(
color: _borderColor(context), color: _borderColor(context),
width: curvedBorderWidth, width: width ?? curvedBorderWidth,
); );
static Border border(BuildContext context) => Border.fromBorderSide(curvedSide(context)); static Border border(BuildContext context, {double? width}) => Border.fromBorderSide(curvedSide(context, width: width));
} }

View file

@ -2,58 +2,39 @@ import 'package:aves/widgets/common/identity/buttons/overlay_button.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
class CaptionedButton extends StatelessWidget { typedef CaptionedIconButtonBuilder = Widget Function(BuildContext context, FocusNode focusNode);
class CaptionedButton extends StatefulWidget {
final Animation<double> scale; final Animation<double> scale;
final Widget captionText; final Widget captionText;
final Widget iconButton; final CaptionedIconButtonBuilder iconButtonBuilder;
final bool showCaption; final bool showCaption;
final VoidCallback? onPressed; final VoidCallback? onPressed;
static const EdgeInsets padding = EdgeInsets.symmetric(horizontal: 8);
static const double iconTextPadding = 8;
CaptionedButton({ CaptionedButton({
super.key, super.key,
this.scale = kAlwaysCompleteAnimation, this.scale = kAlwaysCompleteAnimation,
Widget? icon, Widget? icon,
Widget? iconButton, CaptionedIconButtonBuilder? iconButtonBuilder,
String? caption, String? caption,
Widget? captionText, Widget? captionText,
this.showCaption = true, this.showCaption = true,
required this.onPressed, required this.onPressed,
}) : assert(icon != null || iconButton != null), }) : assert(icon != null || iconButtonBuilder != null),
assert(caption != null || captionText != null), assert(caption != null || captionText != null),
iconButton = iconButton ?? IconButton(icon: icon!, onPressed: onPressed), iconButtonBuilder = iconButtonBuilder ?? ((_, focusNode) => IconButton(icon: icon!, onPressed: onPressed, focusNode: focusNode)),
captionText = captionText ?? CaptionedButtonText(text: caption!, enabled: onPressed != null); captionText = captionText ?? CaptionedButtonText(text: caption!, enabled: onPressed != null);
static const double padding = 8;
@override @override
Widget build(BuildContext context) { State<CaptionedButton> createState() => _CaptionedButtonState();
return SizedBox(
width: _width(context),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: padding),
OverlayButton(
scale: scale,
child: iconButton,
),
if (showCaption) ...[
const SizedBox(height: padding),
ScaleTransition(
scale: scale,
child: captionText,
),
],
const SizedBox(height: padding),
],
),
);
}
static double _width(BuildContext context) => OverlayButton.getSize(context) + padding * 2; static double getWidth(BuildContext context) => OverlayButton.getSize(context) + padding.horizontal;
static Size getSize(BuildContext context, String text, {required bool showCaption}) { static Size getSize(BuildContext context, String text, {required bool showCaption}) {
final width = _width(context); final width = getWidth(context);
var height = width; var height = width;
if (showCaption) { if (showCaption) {
final para = RenderParagraph( final para = RenderParagraph(
@ -62,7 +43,7 @@ class CaptionedButton extends StatelessWidget {
textScaleFactor: MediaQuery.textScaleFactorOf(context), textScaleFactor: MediaQuery.textScaleFactorOf(context),
maxLines: CaptionedButtonText.maxLines, maxLines: CaptionedButtonText.maxLines,
)..layout(const BoxConstraints(), parentUsesSize: true); )..layout(const BoxConstraints(), parentUsesSize: true);
height += para.getMaxIntrinsicHeight(width) + padding; height += para.getMaxIntrinsicHeight(width) + padding.vertical;
} }
return Size(width, height); return Size(width, height);
} }
@ -73,6 +54,81 @@ class CaptionedButton extends StatelessWidget {
} }
} }
class _CaptionedButtonState extends State<CaptionedButton> {
final FocusNode _focusNode = FocusNode();
final ValueNotifier<bool> _focusedNotifier = ValueNotifier(false);
@override
void initState() {
super.initState();
_updateTraversal();
_focusNode.addListener(_onFocusChanged);
}
@override
void didUpdateWidget(covariant CaptionedButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.onPressed != widget.onPressed) {
_updateTraversal();
}
}
@override
void dispose() {
_focusNode.dispose();
_focusedNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: CaptionedButton.getWidth(context),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(height: CaptionedButton.padding.top),
OverlayButton(
scale: widget.scale,
focusNode: _focusNode,
child: widget.iconButtonBuilder(context, _focusNode),
),
if (widget.showCaption) ...[
const SizedBox(height: CaptionedButton.iconTextPadding),
ScaleTransition(
scale: widget.scale,
child: ValueListenableBuilder<bool>(
valueListenable: _focusedNotifier,
builder: (context, focused, child) {
final style = CaptionedButtonText.textStyle(context);
return AnimatedDefaultTextStyle(
style: focused
? style.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
)
: style,
duration: const Duration(milliseconds: 200),
child: widget.captionText,
);
},
),
),
],
SizedBox(height: CaptionedButton.padding.bottom),
],
),
);
}
void _onFocusChanged() => _focusedNotifier.value = _focusNode.hasFocus;
void _updateTraversal() {
final enabled = widget.onPressed != null;
_focusNode.skipTraversal = !enabled;
_focusNode.canRequestFocus = enabled;
}
}
class CaptionedButtonText extends StatelessWidget { class CaptionedButtonText extends StatelessWidget {
final String text; final String text;
final bool enabled; final bool enabled;
@ -87,7 +143,7 @@ class CaptionedButtonText extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var style = textStyle(context); var style = DefaultTextStyle.of(context).style;
if (!enabled) { if (!enabled) {
style = style.copyWith(color: style.color!.withOpacity(.2)); style = style.copyWith(color: style.color!.withOpacity(.2));
} }

View file

@ -4,38 +4,92 @@ import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/common/fx/borders.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class OverlayButton extends StatelessWidget { class OverlayButton extends StatefulWidget {
final Animation<double> scale; final Animation<double> scale;
final BorderRadius? borderRadius; final BorderRadius? borderRadius;
final FocusNode? focusNode;
final Widget child; final Widget child;
const OverlayButton({ const OverlayButton({
super.key, super.key,
this.scale = kAlwaysCompleteAnimation, this.scale = kAlwaysCompleteAnimation,
this.borderRadius, this.borderRadius,
this.focusNode,
required this.child, required this.child,
}); });
@override
State<OverlayButton> createState() => _OverlayButtonState();
// icon (24) + icon padding (8) + button padding (16)
static double getSize(BuildContext context) => 48;
}
class _OverlayButtonState extends State<OverlayButton> {
final ValueNotifier<bool> _focusedNotifier = ValueNotifier(false);
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant OverlayButton oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
_focusedNotifier.dispose();
super.dispose();
}
void _registerWidget(OverlayButton widget) {
widget.focusNode?.addListener(_onFocusChanged);
}
void _unregisterWidget(OverlayButton widget) {
widget.focusNode?.removeListener(_onFocusChanged);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final brightness = Theme.of(context).brightness; final borderRadius = widget.borderRadius;
final blurred = settings.enableBlurEffect; final blurred = settings.enableBlurEffect;
final overlayBackground = Themes.overlayBackgroundColor(
brightness: Theme.of(context).brightness,
blurred: blurred,
);
return ScaleTransition( return ScaleTransition(
scale: scale, scale: widget.scale,
child: borderRadius != null child: ValueListenableBuilder<bool>(
valueListenable: _focusedNotifier,
builder: (context, focused, child) {
final border = AvesBorder.border(
context,
width: AvesBorder.curvedBorderWidth * (focused ? 2 : 1),
);
return borderRadius != null
? BlurredRRect( ? BlurredRRect(
enabled: blurred, enabled: blurred,
borderRadius: borderRadius, borderRadius: borderRadius,
child: Material( child: Material(
type: MaterialType.button, type: MaterialType.button,
borderRadius: borderRadius, borderRadius: borderRadius,
color: Themes.overlayBackgroundColor(brightness: brightness, blurred: blurred), color: overlayBackground,
child: Ink( child: AnimatedContainer(
decoration: BoxDecoration( foregroundDecoration: BoxDecoration(
border: AvesBorder.border(context), border: border,
borderRadius: borderRadius, borderRadius: borderRadius,
), ),
child: child, duration: const Duration(milliseconds: 200),
child: widget.child,
), ),
), ),
) )
@ -43,21 +97,23 @@ class OverlayButton extends StatelessWidget {
enabled: blurred, enabled: blurred,
child: Material( child: Material(
type: MaterialType.circle, type: MaterialType.circle,
color: Themes.overlayBackgroundColor(brightness: brightness, blurred: blurred), color: overlayBackground,
child: Ink( child: AnimatedContainer(
decoration: BoxDecoration( foregroundDecoration: BoxDecoration(
border: AvesBorder.border(context), border: border,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: child, duration: const Duration(milliseconds: 200),
child: widget.child,
), ),
), ),
);
},
), ),
); );
} }
// icon (24) + icon padding (8) + button padding (16) + border (1 or 2) void _onFocusChanged() => _focusedNotifier.value = widget.focusNode?.hasFocus ?? false;
static double getSize(BuildContext context) => 48.0 + AvesBorder.curvedBorderWidth * 2;
} }
class ScalingOverlayTextButton extends StatelessWidget { class ScalingOverlayTextButton extends StatelessWidget {

View file

@ -288,7 +288,13 @@ class _FilterGridAppBarState<T extends CollectionFilter, CSAD extends ChipSetAct
].where(isVisible).map((action) { ].where(isVisible).map((action) {
final enabled = canApply(action); final enabled = canApply(action);
return CaptionedButton( return CaptionedButton(
iconButton: _buildButtonIcon(context, actionDelegate, action, enabled: enabled), iconButtonBuilder: (context, focusNode) => _buildButtonIcon(
context,
actionDelegate,
action,
enabled: enabled,
focusNode: focusNode,
),
captionText: _buildButtonCaption(context, action, enabled: enabled), captionText: _buildButtonCaption(context, action, enabled: enabled),
onPressed: enabled ? () => _onActionSelected(context, action, actionDelegate) : null, onPressed: enabled ? () => _onActionSelected(context, action, actionDelegate) : null,
); );
@ -350,6 +356,7 @@ class _FilterGridAppBarState<T extends CollectionFilter, CSAD extends ChipSetAct
CSAD actionDelegate, CSAD actionDelegate,
ChipSetAction action, { ChipSetAction action, {
required bool enabled, required bool enabled,
FocusNode? focusNode,
}) { }) {
final onPressed = enabled ? () => _onActionSelected(context, action, actionDelegate) : null; final onPressed = enabled ? () => _onActionSelected(context, action, actionDelegate) : null;
switch (action) { switch (action) {
@ -360,6 +367,7 @@ class _FilterGridAppBarState<T extends CollectionFilter, CSAD extends ChipSetAct
builder: (context, queryEnabled, child) { builder: (context, queryEnabled, child) {
return TitleSearchToggler( return TitleSearchToggler(
queryEnabled: queryEnabled, queryEnabled: queryEnabled,
focusNode: focusNode,
onPressed: onPressed, onPressed: onPressed,
); );
}, },
@ -368,6 +376,7 @@ class _FilterGridAppBarState<T extends CollectionFilter, CSAD extends ChipSetAct
return IconButton( return IconButton(
icon: action.getIcon(), icon: action.getIcon(),
onPressed: onPressed, onPressed: onPressed,
focusNode: focusNode,
tooltip: action.getText(context), tooltip: action.getText(context),
); );
} }

View file

@ -57,15 +57,23 @@ class _TvRailState extends State<TvRail> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController = ScrollController(initialScrollOffset: controller.offset); _scrollController = ScrollController(initialScrollOffset: controller.offset);
_scrollController.addListener(_onScrollChanged); _scrollController.addListener(_onScrollChanged);
_registerWidget(widget);
WidgetsBinding.instance.addPostFrameCallback((_) => _initFocus()); WidgetsBinding.instance.addPostFrameCallback((_) => _initFocus());
} }
@override
void didUpdateWidget(covariant TvRail oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override @override
void dispose() { void dispose() {
_unregisterWidget(widget);
_scrollController.removeListener(_onScrollChanged); _scrollController.removeListener(_onScrollChanged);
_scrollController.dispose(); _scrollController.dispose();
_extendedNotifier.dispose(); _extendedNotifier.dispose();
@ -73,6 +81,14 @@ class _TvRailState extends State<TvRail> {
super.dispose(); super.dispose();
} }
void _registerWidget(TvRail widget) {
widget.currentCollection?.filterChangeNotifier.addListener(_onCollectionFilterChanged);
}
void _unregisterWidget(TvRail widget) {
widget.currentCollection?.filterChangeNotifier.removeListener(_onCollectionFilterChanged);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final navEntries = _getNavEntries(context); final navEntries = _getNavEntries(context);
@ -255,6 +271,8 @@ class _TvRailState extends State<TvRail> {
} }
void _onScrollChanged() => controller.offset = _scrollController.offset; void _onScrollChanged() => controller.offset = _scrollController.offset;
void _onCollectionFilterChanged() => setState(() {});
} }
@immutable @immutable

View file

@ -17,7 +17,8 @@ class AvailableActionPanel<T extends Object> extends StatelessWidget {
final String Function(BuildContext context, T action) actionText; final String Function(BuildContext context, T action) actionText;
static const double spacing = 8; static const double spacing = 8;
static const padding = EdgeInsets.all(spacing); static const double runSpacing = 20;
static const padding = EdgeInsets.symmetric(vertical: 16, horizontal: 8);
const AvailableActionPanel({ const AvailableActionPanel({
super.key, super.key,
@ -56,7 +57,7 @@ class AvailableActionPanel<T extends Object> extends StatelessWidget {
child: Wrap( child: Wrap(
alignment: WrapAlignment.spaceEvenly, alignment: WrapAlignment.spaceEvenly,
spacing: spacing, spacing: spacing,
runSpacing: spacing, runSpacing: runSpacing,
children: allActions.map((action) { children: allActions.map((action) {
final dragged = action == draggedAvailableAction.value; final dragged = action == draggedAvailableAction.value;
final enabled = dragged || !quickActions.contains(action); final enabled = dragged || !quickActions.contains(action);
@ -124,11 +125,10 @@ class AvailableActionPanel<T extends Object> extends StatelessWidget {
final buttonSizes = captions.map((v) => CaptionedButton.getSize(context, v, showCaption: true)); final buttonSizes = captions.map((v) => CaptionedButton.getSize(context, v, showCaption: true));
final actionsPerRun = (width - padding.horizontal + spacing) ~/ (buttonSizes.first.width + spacing); final actionsPerRun = (width - padding.horizontal + spacing) ~/ (buttonSizes.first.width + spacing);
final runCount = (captions.length / actionsPerRun).ceil(); final runCount = (captions.length / actionsPerRun).ceil();
var height = .0; var height = runSpacing * (runCount - 1) + padding.vertical / 2;
for (var i = 0; i < runCount; i++) { for (var i = 0; i < runCount; i++) {
height += buttonSizes.skip(i * actionsPerRun).take(actionsPerRun).map((v) => v.height).max; height += buttonSizes.skip(i * actionsPerRun).take(actionsPerRun).map((v) => v.height).max;
} }
height += spacing * (runCount - 1) + padding.vertical;
return height; return height;
} }
} }

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/settings/settings_definition.dart'; import 'package:aves/widgets/settings/settings_definition.dart';
import 'package:aves/widgets/settings/video/video.dart'; import 'package:aves/widgets/settings/video/video.dart';
@ -20,6 +21,7 @@ class _VideoSettingsPageState extends State<VideoSettingsPage> {
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !settings.useTvLayout,
title: Text(context.l10n.settingsVideoPageTitle), title: Text(context.l10n.settingsVideoPageTitle),
), ),
body: Theme( body: Theme(

View file

@ -256,19 +256,7 @@ class _StatsPageState extends State<StatsPage> {
final totalEntryCount = entries.length; final totalEntryCount = entries.length;
final hasMore = maxRowCount != null && entryCountMap.length > maxRowCount; final hasMore = maxRowCount != null && entryCountMap.length > maxRowCount;
return [ final onHeaderPressed = hasMore
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Text(
title,
style: Constants.knownTitleTextStyle,
),
const Spacer(),
IconButton(
icon: const Icon(AIcons.next),
onPressed: hasMore
? () => Navigator.push( ? () => Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -287,12 +275,52 @@ class _StatsPageState extends State<StatsPage> {
), ),
), ),
) )
: null, : null;
Widget header = Text(
title,
style: Constants.knownTitleTextStyle,
);
if (settings.useTvLayout) {
final colors = Theme.of(context).colorScheme;
header = Container(
padding: const EdgeInsets.symmetric(vertical: 12),
alignment: AlignmentDirectional.centerStart,
child: Material(
type: MaterialType.transparency,
child: InkResponse(
onTap: onHeaderPressed,
onHover: (_) {},
highlightShape: BoxShape.rectangle,
borderRadius: const BorderRadius.all(Radius.circular(123)),
containedInkWell: true,
splashColor: colors.primary.withOpacity(0.12),
hoverColor: colors.primary.withOpacity(0.04),
child: Padding(
padding: const EdgeInsets.all(16),
child: header,
),
),
),
);
} else {
header = Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
header,
const Spacer(),
IconButton(
icon: const Icon(AIcons.next),
onPressed: onHeaderPressed,
tooltip: MaterialLocalizations.of(context).moreButtonTooltip, tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
), ),
], ],
), ),
), );
}
return [
header,
FilterTable( FilterTable(
totalEntryCount: totalEntryCount, totalEntryCount: totalEntryCount,
entryCountMap: entryCountMap, entryCountMap: entryCountMap,

View file

@ -87,7 +87,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.print: case EntryAction.print:
return device.canPrint && !targetEntry.isVideo; return device.canPrint && !targetEntry.isVideo;
case EntryAction.openMap: case EntryAction.openMap:
return targetEntry.hasGps; return !settings.useTvLayout && targetEntry.hasGps;
case EntryAction.viewSource: case EntryAction.viewSource:
return targetEntry.isSvg; return targetEntry.isSvg;
case EntryAction.videoCaptureFrame: case EntryAction.videoCaptureFrame:
@ -109,9 +109,9 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.edit: case EntryAction.edit:
return canWrite; return canWrite;
case EntryAction.copyToClipboard: case EntryAction.copyToClipboard:
case EntryAction.open:
return !settings.useTvLayout; return !settings.useTvLayout;
case EntryAction.info: case EntryAction.info:
case EntryAction.open:
case EntryAction.setAs: case EntryAction.setAs:
case EntryAction.share: case EntryAction.share:
return true; return true;

View file

@ -121,13 +121,14 @@ class _TvButtonRowContent extends StatelessWidget {
final enabled = actionDelegate.canApply(action); final enabled = actionDelegate.canApply(action);
return CaptionedButton( return CaptionedButton(
scale: scale, scale: scale,
iconButton: _buildButtonIcon( iconButtonBuilder: (context, focusNode) => ViewerButtonRowContent._buildButtonIcon(
context: context, context: context,
action: action, action: action,
mainEntry: mainEntry, mainEntry: mainEntry,
pageEntry: pageEntry, pageEntry: pageEntry,
videoController: videoController, videoController: videoController,
actionDelegate: actionDelegate, actionDelegate: actionDelegate,
focusNode: focusNode,
), ),
captionText: _buildButtonCaption( captionText: _buildButtonCaption(
context: context, context: context,
@ -144,6 +145,39 @@ class _TvButtonRowContent extends StatelessWidget {
}, },
); );
} }
static Widget _buildButtonCaption({
required BuildContext context,
required EntryAction action,
required AvesEntry mainEntry,
required AvesEntry pageEntry,
required AvesVideoController? videoController,
required bool enabled,
}) {
switch (action) {
case EntryAction.toggleFavourite:
final favouriteTargetEntry = mainEntry.isBurst ? pageEntry : mainEntry;
return FavouriteTogglerCaption(
entries: {favouriteTargetEntry},
enabled: enabled,
);
case EntryAction.videoToggleMute:
return MuteTogglerCaption(
controller: videoController,
enabled: enabled,
);
case EntryAction.videoTogglePlay:
return PlayTogglerCaption(
controller: videoController,
enabled: enabled,
);
default:
return CaptionedButtonText(
text: action.getText(context),
enabled: enabled,
);
}
}
} }
class ViewerButtonRowContent extends StatelessWidget { class ViewerButtonRowContent extends StatelessWidget {
@ -374,16 +408,16 @@ class ViewerButtonRowContent extends StatelessWidget {
), ),
); );
} }
}
Widget _buildButtonIcon({ static Widget _buildButtonIcon({
required BuildContext context, required BuildContext context,
required EntryAction action, required EntryAction action,
required AvesEntry mainEntry, required AvesEntry mainEntry,
required AvesEntry pageEntry, required AvesEntry pageEntry,
required AvesVideoController? videoController, required AvesVideoController? videoController,
required EntryActionDelegate actionDelegate, required EntryActionDelegate actionDelegate,
}) { FocusNode? focusNode,
}) {
Widget? child; Widget? child;
void onPressed() => actionDelegate.onActionSelected(context, action); void onPressed() => actionDelegate.onActionSelected(context, action);
@ -393,6 +427,7 @@ Widget _buildButtonIcon({
builder: (context, canDo, child) => IconButton( builder: (context, canDo, child) => IconButton(
icon: child!, icon: child!,
onPressed: canDo ? onPressed : null, onPressed: canDo ? onPressed : null,
focusNode: focusNode,
tooltip: action.getText(context), tooltip: action.getText(context),
), ),
child: action.getIcon(), child: action.getIcon(),
@ -422,6 +457,7 @@ Widget _buildButtonIcon({
blurred: blurred, blurred: blurred,
entries: {mainEntry}, entries: {mainEntry},
onChooserValue: (action) => actionDelegate.quickShare(context, action), onChooserValue: (action) => actionDelegate.quickShare(context, action),
focusNode: focusNode,
onPressed: onPressed, onPressed: onPressed,
); );
break; break;
@ -429,18 +465,21 @@ Widget _buildButtonIcon({
final favouriteTargetEntry = mainEntry.isBurst ? pageEntry : mainEntry; final favouriteTargetEntry = mainEntry.isBurst ? pageEntry : mainEntry;
child = FavouriteToggler( child = FavouriteToggler(
entries: {favouriteTargetEntry}, entries: {favouriteTargetEntry},
focusNode: focusNode,
onPressed: onPressed, onPressed: onPressed,
); );
break; break;
case EntryAction.videoToggleMute: case EntryAction.videoToggleMute:
child = MuteToggler( child = MuteToggler(
controller: videoController, controller: videoController,
focusNode: focusNode,
onPressed: onPressed, onPressed: onPressed,
); );
break; break;
case EntryAction.videoTogglePlay: case EntryAction.videoTogglePlay:
child = PlayToggler( child = PlayToggler(
controller: videoController, controller: videoController,
focusNode: focusNode,
onPressed: onPressed, onPressed: onPressed,
); );
break; break;
@ -457,6 +496,7 @@ Widget _buildButtonIcon({
child = RateButton( child = RateButton(
blurred: blurred, blurred: blurred,
onChooserValue: (rating) => actionDelegate.quickRate(context, rating), onChooserValue: (rating) => actionDelegate.quickRate(context, rating),
focusNode: focusNode,
onPressed: onPressed, onPressed: onPressed,
); );
break; break;
@ -464,6 +504,7 @@ Widget _buildButtonIcon({
child = TagButton( child = TagButton(
blurred: blurred, blurred: blurred,
onChooserValue: (filter) => actionDelegate.quickTag(context, filter), onChooserValue: (filter) => actionDelegate.quickTag(context, filter),
focusNode: focusNode,
onPressed: onPressed, onPressed: onPressed,
); );
break; break;
@ -471,42 +512,11 @@ Widget _buildButtonIcon({
child = IconButton( child = IconButton(
icon: action.getIcon(), icon: action.getIcon(),
onPressed: onPressed, onPressed: onPressed,
focusNode: focusNode,
tooltip: action.getText(context), tooltip: action.getText(context),
); );
break; break;
} }
return child; return child;
}
Widget _buildButtonCaption({
required BuildContext context,
required EntryAction action,
required AvesEntry mainEntry,
required AvesEntry pageEntry,
required AvesVideoController? videoController,
required bool enabled,
}) {
switch (action) {
case EntryAction.toggleFavourite:
final favouriteTargetEntry = mainEntry.isBurst ? pageEntry : mainEntry;
return FavouriteTogglerCaption(
entries: {favouriteTargetEntry},
enabled: enabled,
);
case EntryAction.videoToggleMute:
return MuteTogglerCaption(
controller: videoController,
enabled: enabled,
);
case EntryAction.videoTogglePlay:
return PlayTogglerCaption(
controller: videoController,
enabled: enabled,
);
default:
return CaptionedButtonText(
text: action.getText(context),
enabled: enabled,
);
} }
} }