#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 darkTheme = themeBrightness == AvesThemeBrightness.black ? Themes.blackTheme(darkAccent, initialized) : Themes.darkTheme(darkAccent, initialized);
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
shortcuts: {
// handle Android TV remote `select` button
LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(),
},

View file

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

View file

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

View file

@ -1,4 +1,7 @@
import 'package:aves/theme/durations.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
class WheelSelector<T> extends StatefulWidget {
final ValueNotifier<T> valueNotifier;
@ -19,7 +22,8 @@ class WheelSelector<T> extends StatefulWidget {
}
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);
@ -30,66 +34,139 @@ class _WheelSelectorState<T> extends State<WheelSelector<T>> {
@override
void initState() {
super.initState();
var indexOf = values.indexOf(valueNotifier.value);
_controller = FixedExtentScrollController(
initialItem: indexOf,
initialItem: values.indexOf(valueNotifier.value),
);
}
@override
void dispose() {
_controller.dispose();
_focusedNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
const background = Colors.transparent;
final foreground = DefaultTextStyle.of(context).style.color!;
final transitionDuration = context.select<DurationsData, Duration>((v) => v.formTransition);
// TODO TLAD [tv] wheel traversal
return NotificationListener<ScrollNotification>(
// cancel notification bubbling so that the dialog scroll bar
// does not misinterpret wheel scrolling for dialog content scrolling
onNotification: (notification) => true,
child: Padding(
padding: const EdgeInsets.all(8),
child: SizedBox(
width: itemSize.width,
height: itemSize.height * 3,
child: ShaderMask(
shaderCallback: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
background,
foreground,
foreground,
background,
],
).createShader,
child: Theme(
data: Theme.of(context).copyWith(
scrollbarTheme: ScrollbarThemeData(
thumbVisibility: MaterialStateProperty.all(false),
return FocusableActionDetector(
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
// does not misinterpret wheel scrolling for dialog content scrolling
onNotification: (notification) => true,
child: Padding(
padding: const EdgeInsets.all(8),
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)),
),
);
}),
),
),
child: ListWheelScrollView(
controller: _controller,
physics: const FixedExtentScrollPhysics(parent: BouncingScrollPhysics()),
diameterRatio: 1.2,
itemExtent: itemSize.height,
squeeze: 1.3,
onSelectedItemChanged: (i) => valueNotifier.value = values[i],
children: values
.map((i) => SizedBox.fromSize(
size: itemSize,
child: Text(
'$i',
textAlign: widget.textAlign,
style: widget.textStyle,
),
))
.toList(),
SizedBox(
width: itemSize.width,
height: itemSize.height * 3,
child: ShaderMask(
shaderCallback: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
background,
foreground,
foreground,
background,
],
).createShader,
child: Theme(
data: Theme.of(context).copyWith(
scrollbarTheme: ScrollbarThemeData(
thumbVisibility: MaterialStateProperty.all(false),
),
),
child: ListWheelScrollView(
controller: _controller,
physics: const FixedExtentScrollPhysics(parent: BouncingScrollPhysics()),
diameterRatio: 1.2,
itemExtent: itemSize.height,
squeeze: 1.3,
onSelectedItemChanged: (i) => valueNotifier.value = values[i],
children: values
.map((i) => SizedBox.fromSize(
size: itemSize,
child: Text(
'$i',
textAlign: widget.textAlign,
style: widget.textStyle,
),
))
.toList(),
),
),
),
),
),
],
),
),
),
);
}
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) {
_tileExtentController ??= TileExtentController(
settingsRouteKey: widget.settingsRouteKey ?? context.currentRouteName!,
columnCountDefault: 3,
columnCountDefault: device.isTelevision ? 4 : 3,
extentMin: 60,
extentMax: 300,
spacing: 8,

View file

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

View file

@ -1,44 +1,119 @@
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/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/slideshow_page.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class SlideshowButtons extends StatelessWidget {
final Animation<double> scale;
class SlideshowButtons extends StatefulWidget {
final AnimationController animationController;
const SlideshowButtons({
super.key,
required this.scale,
required this.animationController,
});
@override
State<SlideshowButtons> createState() => _SlideshowButtonsState();
}
class _SlideshowButtonsState extends State<SlideshowButtons> {
final FocusScopeNode _buttonRowFocusScopeNode = FocusScopeNode();
late Animation<double> _buttonScale;
static const List<SlideshowAction> _actions = [
SlideshowAction.resume,
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) {
// TODO TLAD [tv] captioned buttons
const padding = ViewerButtonRowContent.padding;
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(left: padding / 2, right: padding / 2, bottom: padding),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SlideshowAction.resume,
SlideshowAction.showInCollection,
]
.map((action) => Padding(
padding: const EdgeInsets.symmetric(horizontal: padding / 2),
child: OverlayButton(
scale: scale,
child: IconButton(
icon: action.getIcon(),
onPressed: () => SlideshowActionNotification(action).dispatch(context),
tooltip: action.getText(context),
),
),
))
.toList(),
),
),
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(
padding: const EdgeInsets.symmetric(horizontal: _padding / 2),
child: OverlayButton(
scale: _buttonScale,
child: IconButton(
icon: action.getIcon(),
onPressed: () => _onAction(context, action),
tooltip: action.getText(context),
),
),
))
.toList(),
),
),
),
);
}
void _onAction(BuildContext context, SlideshowAction action) => SlideshowActionNotification(action).dispatch(context);
void _onAnimationStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.completed) {
_buttonRowFocusScopeNode.children.firstOrNull?.requestFocus();
}
}
}