#437 tv: default column count, color picker, wheel selector, slideshow captioned buttons
This commit is contained in:
parent
6f17fbcb7e
commit
e8bb1a77f0
7 changed files with 259 additions and 106 deletions
|
@ -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(),
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue