#437 tv: default column count, color picker, wheel selector, slideshow captioned buttons

This commit is contained in:
Thibault Deckers 2022-12-16 20:10:33 +01:00
parent 6f17fbcb7e
commit e8bb1a77f0
7 changed files with 259 additions and 106 deletions

View file

@ -235,7 +235,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
final lightTheme = Themes.lightTheme(lightAccent, initialized); final lightTheme = Themes.lightTheme(lightAccent, initialized);
final darkTheme = themeBrightness == AvesThemeBrightness.black ? Themes.blackTheme(darkAccent, initialized) : Themes.darkTheme(darkAccent, initialized); final darkTheme = themeBrightness == AvesThemeBrightness.black ? Themes.blackTheme(darkAccent, initialized) : Themes.darkTheme(darkAccent, initialized);
return Shortcuts( return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{ shortcuts: {
// handle Android TV remote `select` button // handle Android TV remote `select` button
LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(), LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(),
}, },

View file

@ -52,12 +52,13 @@ import 'package:tuple/tuple.dart';
class CollectionGrid extends StatefulWidget { class CollectionGrid extends StatefulWidget {
final String settingsRouteKey; final String settingsRouteKey;
static const int columnCountDefault = 4;
static const double extentMin = 46; static const double extentMin = 46;
static const double extentMax = 300; static const double extentMax = 300;
static const double fixedExtentLayoutSpacing = 2; static const double fixedExtentLayoutSpacing = 2;
static const double mosaicLayoutSpacing = 4; static const double mosaicLayoutSpacing = 4;
static int get columnCountDefault => device.isTelevision ? 6 : 4;
const CollectionGrid({ const CollectionGrid({
super.key, super.key,
required this.settingsRouteKey, required this.settingsRouteKey,

View file

@ -1,3 +1,4 @@
import 'package:aves/model/device.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/common/fx/borders.dart';
@ -36,7 +37,6 @@ class ColorListTile extends StatelessWidget {
onTap: () async { onTap: () async {
final color = await showDialog<Color>( final color = await showDialog<Color>(
context: context, context: context,
// TODO TLAD [tv] color pick
builder: (context) => ColorPickerDialog( builder: (context) => ColorPickerDialog(
initialValue: value, initialValue: value,
), ),
@ -72,18 +72,25 @@ class _ColorPickerDialogState extends State<ColorPickerDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isTelevision = device.isTelevision;
return AvesDialog( return AvesDialog(
scrollableContent: [ scrollableContent: [
ColorPicker( ColorPicker(
color: color, color: color,
onColorChanged: (v) => color = v, onColorChanged: (v) => color = v,
pickersEnabled: const { pickersEnabled: isTelevision
? const {
ColorPickerType.primary: true,
ColorPickerType.accent: false,
}
: const {
ColorPickerType.primary: false, ColorPickerType.primary: false,
ColorPickerType.accent: false, ColorPickerType.accent: false,
ColorPickerType.wheel: true, ColorPickerType.wheel: true,
}, },
hasBorder: true, hasBorder: true,
borderRadius: 20, borderRadius: 20,
subheading: isTelevision ? const SizedBox(height: 16) : null,
) )
], ],
actions: [ actions: [

View file

@ -1,4 +1,7 @@
import 'package:aves/theme/durations.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
class WheelSelector<T> extends StatefulWidget { class WheelSelector<T> extends StatefulWidget {
final ValueNotifier<T> valueNotifier; final ValueNotifier<T> valueNotifier;
@ -19,7 +22,8 @@ class WheelSelector<T> extends StatefulWidget {
} }
class _WheelSelectorState<T> extends State<WheelSelector<T>> { class _WheelSelectorState<T> extends State<WheelSelector<T>> {
late final ScrollController _controller; late final FixedExtentScrollController _controller;
final ValueNotifier<bool> _focusedNotifier = ValueNotifier(false);
static const itemSize = Size(40, 40); static const itemSize = Size(40, 40);
@ -30,25 +34,59 @@ class _WheelSelectorState<T> extends State<WheelSelector<T>> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
var indexOf = values.indexOf(valueNotifier.value);
_controller = FixedExtentScrollController( _controller = FixedExtentScrollController(
initialItem: indexOf, initialItem: values.indexOf(valueNotifier.value),
); );
} }
@override
void dispose() {
_controller.dispose();
_focusedNotifier.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const background = Colors.transparent; const background = Colors.transparent;
final foreground = DefaultTextStyle.of(context).style.color!; final foreground = DefaultTextStyle.of(context).style.color!;
final transitionDuration = context.select<DurationsData, Duration>((v) => v.formTransition);
// TODO TLAD [tv] wheel traversal return FocusableActionDetector(
return NotificationListener<ScrollNotification>( shortcuts: const {
SingleActivator(LogicalKeyboardKey.arrowUp): _AdjustValueIntent.up(),
SingleActivator(LogicalKeyboardKey.arrowDown): _AdjustValueIntent.down(),
},
actions: {
_AdjustValueIntent: CallbackAction<_AdjustValueIntent>(onInvoke: _onAdjustValueIntent),
},
onShowFocusHighlight: (v) => _focusedNotifier.value = v,
child: NotificationListener<ScrollNotification>(
// cancel notification bubbling so that the dialog scroll bar // cancel notification bubbling so that the dialog scroll bar
// does not misinterpret wheel scrolling for dialog content scrolling // does not misinterpret wheel scrolling for dialog content scrolling
onNotification: (notification) => true, onNotification: (notification) => true,
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: SizedBox( child: Stack(
children: [
Positioned.fill(
child: Center(
child: ValueListenableBuilder<bool>(
valueListenable: _focusedNotifier,
builder: (context, focused, child) {
return AnimatedContainer(
width: itemSize.width,
height: itemSize.height,
duration: transitionDuration,
decoration: BoxDecoration(
color: foreground.withOpacity(focused ? .2 : 0),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
);
}),
),
),
SizedBox(
width: itemSize.width, width: itemSize.width,
height: itemSize.height * 3, height: itemSize.height * 3,
child: ShaderMask( child: ShaderMask(
@ -89,7 +127,46 @@ class _WheelSelectorState<T> extends State<WheelSelector<T>> {
), ),
), ),
), ),
],
),
),
), ),
); );
} }
void _onAdjustValueIntent(_AdjustValueIntent intent) {
late int delta;
switch (intent.type) {
case _ValueAdjustmentType.up:
delta = -1;
break;
case _ValueAdjustmentType.down:
delta = 1;
break;
}
final targetItem = _controller.selectedItem + delta;
final duration = context.read<DurationsData>().formTransition;
if (duration > Duration.zero) {
_controller.animateToItem(targetItem, duration: duration, curve: Curves.easeInOutCubic);
} else {
_controller.jumpToItem(targetItem);
}
}
}
class _AdjustValueIntent extends Intent {
const _AdjustValueIntent({
required this.type,
});
const _AdjustValueIntent.up() : type = _ValueAdjustmentType.up;
const _AdjustValueIntent.down() : type = _ValueAdjustmentType.down;
final _ValueAdjustmentType type;
}
enum _ValueAdjustmentType {
up,
down,
} }

View file

@ -211,7 +211,7 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
Widget build(BuildContext context) { Widget build(BuildContext context) {
_tileExtentController ??= TileExtentController( _tileExtentController ??= TileExtentController(
settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName!, settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName!,
columnCountDefault: 3, columnCountDefault: device.isTelevision ? 4 : 3,
extentMin: 60, extentMin: 60,
extentMax: 300, extentMax: 300,
spacing: 8, spacing: 8,

View file

@ -283,7 +283,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
onImagePageRequested: () => _goToVerticalPage(imagePage), onImagePageRequested: () => _goToVerticalPage(imagePage),
onViewDisposed: (mainEntry, pageEntry) => viewStateConductor.reset(pageEntry ?? mainEntry), onViewDisposed: (mainEntry, pageEntry) => viewStateConductor.reset(pageEntry ?? mainEntry),
), ),
..._buildOverlays(), ..._buildOverlays().map(_decorateOverlay),
const TopGestureAreaProtector(), const TopGestureAreaProtector(),
const SideGestureAreaProtector(), const SideGestureAreaProtector(),
const BottomGestureAreaProtector(), const BottomGestureAreaProtector(),
@ -294,6 +294,19 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
); );
} }
Widget _decorateOverlay(Widget overlay) {
return ValueListenableBuilder<double>(
valueListenable: _overlayAnimationController,
builder: (context, animation, child) {
return Visibility(
visible: !_overlayAnimationController.isDismissed,
child: child!,
);
},
child: overlay,
);
}
List<Widget> _buildOverlays() { List<Widget> _buildOverlays() {
final appMode = context.read<ValueNotifier<AppMode>>().value; final appMode = context.read<ValueNotifier<AppMode>>().value;
switch (appMode) { switch (appMode) {
@ -324,7 +337,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
preferBelow: false, preferBelow: false,
), ),
child: SlideshowButtons( child: SlideshowButtons(
scale: _overlayButtonScale, animationController: _overlayAnimationController,
), ),
), ),
), ),
@ -365,17 +378,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
child: child, child: child,
); );
child = ValueListenableBuilder<double>(
valueListenable: _overlayAnimationController,
builder: (context, animation, child) {
return Visibility(
visible: !_overlayAnimationController.isDismissed,
child: child!,
);
},
child: child,
);
return child; return child;
} }
@ -474,16 +476,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
child: child, child: child,
); );
return ValueListenableBuilder<double>( return child;
valueListenable: _overlayAnimationController,
builder: (context, animation, child) {
return Visibility(
visible: !_overlayAnimationController.isDismissed,
child: child!,
);
},
child: child,
);
} }
Future<void> _onVideoAction(BuildContext context, AvesVideoController controller, EntryAction action) async { Future<void> _onVideoAction(BuildContext context, AvesVideoController controller, EntryAction action) async {

View file

@ -1,37 +1,103 @@
import 'package:aves/model/actions/slideshow_actions.dart'; import 'package:aves/model/actions/slideshow_actions.dart';
import 'package:aves/model/device.dart';
import 'package:aves/widgets/common/identity/buttons/captioned_button.dart';
import 'package:aves/widgets/common/identity/buttons/overlay_button.dart'; import 'package:aves/widgets/common/identity/buttons/overlay_button.dart';
import 'package:aves/widgets/viewer/entry_vertical_pager.dart';
import 'package:aves/widgets/viewer/notifications.dart';
import 'package:aves/widgets/viewer/overlay/viewer_buttons.dart'; import 'package:aves/widgets/viewer/overlay/viewer_buttons.dart';
import 'package:aves/widgets/viewer/slideshow_page.dart'; import 'package:aves/widgets/viewer/slideshow_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class SlideshowButtons extends StatelessWidget { class SlideshowButtons extends StatefulWidget {
final Animation<double> scale; final AnimationController animationController;
const SlideshowButtons({ const SlideshowButtons({
super.key, super.key,
required this.scale, required this.animationController,
}); });
@override @override
Widget build(BuildContext context) { State<SlideshowButtons> createState() => _SlideshowButtonsState();
// TODO TLAD [tv] captioned buttons }
const padding = ViewerButtonRowContent.padding;
return SafeArea( class _SlideshowButtonsState extends State<SlideshowButtons> {
child: Padding( final FocusScopeNode _buttonRowFocusScopeNode = FocusScopeNode();
padding: const EdgeInsets.only(left: padding / 2, right: padding / 2, bottom: padding), late Animation<double> _buttonScale;
child: Row(
mainAxisSize: MainAxisSize.min, static const List<SlideshowAction> _actions = [
children: [
SlideshowAction.resume, SlideshowAction.resume,
SlideshowAction.showInCollection, SlideshowAction.showInCollection,
] ];
static const double _padding = ViewerButtonRowContent.padding;
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant SlideshowButtons oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
_buttonRowFocusScopeNode.dispose();
super.dispose();
}
void _registerWidget(SlideshowButtons widget) {
final animationController = widget.animationController;
_buttonScale = CurvedAnimation(
parent: animationController,
// a little bounce at the top
curve: Curves.easeOutBack,
);
animationController.addStatusListener(_onAnimationStatusChanged);
}
void _unregisterWidget(SlideshowButtons widget) {
widget.animationController.removeStatusListener(_onAnimationStatusChanged);
}
@override
Widget build(BuildContext context) {
return FocusableActionDetector(
focusNode: _buttonRowFocusScopeNode,
shortcuts: device.isTelevision ? const {SingleActivator(LogicalKeyboardKey.arrowUp): TvShowLessInfoIntent()} : null,
actions: {TvShowLessInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => TvShowLessInfoNotification().dispatch(context))},
child: device.isTelevision
? Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: _actions.map((action) {
return CaptionedButton(
scale: _buttonScale,
icon: action.getIcon(),
caption: action.getText(context),
onPressed: () => _onAction(context, action),
);
}).toList(),
)
: SafeArea(
child: Padding(
padding: const EdgeInsets.only(left: _padding / 2, right: _padding / 2, bottom: _padding),
child: Row(
mainAxisSize: MainAxisSize.min,
children: _actions
.map((action) => Padding( .map((action) => Padding(
padding: const EdgeInsets.symmetric(horizontal: padding / 2), padding: const EdgeInsets.symmetric(horizontal: _padding / 2),
child: OverlayButton( child: OverlayButton(
scale: scale, scale: _buttonScale,
child: IconButton( child: IconButton(
icon: action.getIcon(), icon: action.getIcon(),
onPressed: () => SlideshowActionNotification(action).dispatch(context), onPressed: () => _onAction(context, action),
tooltip: action.getText(context), tooltip: action.getText(context),
), ),
), ),
@ -39,6 +105,15 @@ class SlideshowButtons extends StatelessWidget {
.toList(), .toList(),
), ),
), ),
),
); );
} }
void _onAction(BuildContext context, SlideshowAction action) => SlideshowActionNotification(action).dispatch(context);
void _onAnimationStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.completed) {
_buttonRowFocusScopeNode.children.firstOrNull?.requestFocus();
}
}
} }