This commit is contained in:
Thibault Deckers 2022-11-29 12:03:18 +01:00
parent 07abd4091c
commit 9fcebd26f6
10 changed files with 386 additions and 10 deletions

View file

@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
### Added
- Viewer: long press on rating quick action for quicker rating
### Changed
- Viewer: allow setting default outside video player

View file

@ -102,6 +102,7 @@ class DurationsData {
final Duration iconAnimation;
final Duration staggeredAnimation;
final Duration staggeredAnimationPageTarget;
final Duration quickChooserAnimation;
// viewer animations
final Duration viewerVerticalPageScrollAnimation;
@ -119,6 +120,7 @@ class DurationsData {
this.iconAnimation = const Duration(milliseconds: 300),
this.staggeredAnimation = const Duration(milliseconds: 375),
this.staggeredAnimationPageTarget = const Duration(milliseconds: 800),
this.quickChooserAnimation = const Duration(milliseconds: 100),
this.viewerVerticalPageScrollAnimation = const Duration(milliseconds: 500),
this.viewerOverlayAnimation = const Duration(milliseconds: 200),
this.viewerOverlayChangeAnimation = const Duration(milliseconds: 150),
@ -134,6 +136,7 @@ class DurationsData {
iconAnimation: Duration.zero,
staggeredAnimation: Duration.zero,
staggeredAnimationPageTarget: Duration.zero,
quickChooserAnimation: Duration.zero,
viewerVerticalPageScrollAnimation: Duration.zero,
viewerOverlayAnimation: Duration.zero,
viewerOverlayChangeAnimation: Duration.zero,

View file

@ -29,6 +29,10 @@ class _FavouriteTogglerState extends State<FavouriteToggler> {
Set<AvesEntry> get entries => widget.entries;
static const isFavouriteIcon = AIcons.favouriteActive;
static const isNotFavouriteIcon = AIcons.favourite;
static const favouriteSweeperIcon = AIcons.favourite;
@override
void initState() {
super.initState();
@ -57,25 +61,25 @@ class _FavouriteTogglerState extends State<FavouriteToggler> {
return isFavourite
? MenuRow(
text: context.l10n.entryActionRemoveFavourite,
icon: const Icon(AIcons.favouriteActive),
icon: const Icon(isFavouriteIcon),
)
: MenuRow(
text: context.l10n.entryActionAddFavourite,
icon: const Icon(AIcons.favourite),
icon: const Icon(isNotFavouriteIcon),
);
}
return Stack(
alignment: Alignment.center,
children: [
IconButton(
icon: Icon(isFavourite ? AIcons.favouriteActive : AIcons.favourite),
icon: Icon(isFavourite ? isFavouriteIcon : isNotFavouriteIcon),
onPressed: widget.onPressed,
tooltip: isFavourite ? context.l10n.entryActionRemoveFavourite : context.l10n.entryActionAddFavourite,
),
Sweeper(
key: ValueKey(entries.length == 1 ? entries.first : entries.length),
builder: (context) => Icon(
AIcons.favourite,
favouriteSweeperIcon,
color: context.select<AvesColorsData, Color>((v) => v.favourite),
),
toggledNotifier: isFavouriteNotifier,

View file

@ -0,0 +1,124 @@
import 'dart:async';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/app_bar/quick_choosers/route_layout.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
abstract class ChooserQuickButton<T> extends StatefulWidget {
final PopupMenuPosition? chooserPosition;
final ValueSetter<T?>? onChooserValue;
final VoidCallback onPressed;
const ChooserQuickButton({
super.key,
this.chooserPosition,
this.onChooserValue,
required this.onPressed,
}) : assert((chooserPosition == null) == (onChooserValue == null));
}
abstract class ChooserQuickButtonState<T extends ChooserQuickButton<U>, U> extends State<T> with SingleTickerProviderStateMixin {
AnimationController? _animationController;
Animation<double>? _animation;
OverlayEntry? _chooserOverlayEntry;
final ValueNotifier<U?> _chooserValueNotifier = ValueNotifier(null);
final StreamController<LongPressMoveUpdateDetails> _moveUpdateStreamController = StreamController.broadcast();
Widget get icon;
String get tooltip;
U? get defaultValue;
Duration get animationDuration => context.read<DurationsData>().quickChooserAnimation;
Curve get animationCurve => Curves.easeOutQuad;
Widget buildChooser(Animation<double> animation);
ValueNotifier<U?> get chooserValueNotifier => _chooserValueNotifier;
Stream<LongPressMoveUpdateDetails> get moveUpdates => _moveUpdateStreamController.stream;
@override
void dispose() {
_animationController?.dispose();
_clearChooserOverlayEntry();
super.dispose();
}
@override
Widget build(BuildContext context) {
final chooserPosition = widget.chooserPosition;
final onChooserValue = widget.onChooserValue;
final isChooserEnabled = chooserPosition != null && onChooserValue != null;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressStart: isChooserEnabled ? (details) => _showChooser() : null,
onLongPressMoveUpdate: isChooserEnabled ? _moveUpdateStreamController.add : null,
onLongPressEnd: isChooserEnabled
? (details) {
_clearChooserOverlayEntry();
onChooserValue.call(_chooserValueNotifier.value);
}
: null,
onLongPressCancel: _clearChooserOverlayEntry,
child: IconButton(
icon: icon,
onPressed: widget.onPressed,
tooltip: isChooserEnabled ? null : tooltip,
),
);
}
void _clearChooserOverlayEntry() {
if (_chooserOverlayEntry != null) {
_chooserOverlayEntry!.remove();
_chooserOverlayEntry = null;
}
}
void _showChooser() {
final chooserPosition = widget.chooserPosition;
if (chooserPosition == null) return;
final overlay = Overlay.of(context)!;
final triggerBox = context.findRenderObject() as RenderBox;
final overlayBox = overlay.context.findRenderObject() as RenderBox;
final triggerRect = RelativeRect.fromRect(
triggerBox.localToGlobal(Offset.zero, ancestor: overlayBox) & triggerBox.size,
Offset.zero & overlayBox.size,
);
_chooserValueNotifier.value = defaultValue;
_chooserOverlayEntry = OverlayEntry(
builder: (context) {
final mediaQuery = MediaQuery.of(context);
return CustomSingleChildLayout(
delegate: QuickChooserRouteLayout(
triggerRect,
chooserPosition,
mediaQuery.padding,
DisplayFeatureSubScreen.avoidBounds(mediaQuery).toSet(),
),
child: buildChooser(_animation!),
);
},
);
if (_animationController == null) {
_animationController = AnimationController(
duration: animationDuration,
vsync: this,
);
_animation = CurvedAnimation(
parent: _animationController!,
curve: animationCurve,
);
}
_animationController!.reset();
overlay.insert(_chooserOverlayEntry!);
_animationController!.forward();
}
}

View file

@ -0,0 +1,93 @@
import 'dart:async';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:flutter/material.dart';
class RateQuickChooser extends StatefulWidget {
final ValueNotifier<int?> ratingNotifier;
final Stream<LongPressMoveUpdateDetails> moveUpdates;
const RateQuickChooser({
super.key,
required this.ratingNotifier,
required this.moveUpdates,
});
@override
State<RateQuickChooser> createState() => _RateQuickChooserState();
}
class _RateQuickChooserState extends State<RateQuickChooser> {
final List<StreamSubscription> _subscriptions = [];
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant RateQuickChooser oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(RateQuickChooser widget) {
_subscriptions.add(widget.moveUpdates.map((event) => event.globalPosition).listen(_onPointerMove));
}
void _unregisterWidget(RateQuickChooser widget) {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Material(
shape: AvesDialog.shape(context),
child: Padding(
padding: const EdgeInsets.all(8),
child: ValueListenableBuilder<int?>(
valueListenable: widget.ratingNotifier,
builder: (context, rating, child) {
final _rating = rating ?? 0;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
...List.generate(5, (i) {
final thisRating = i + 1;
return Padding(
padding: const EdgeInsets.all(4),
child: Icon(
_rating < thisRating ? AIcons.rating : AIcons.ratingFull,
color: _rating < thisRating ? Colors.grey : Colors.amber,
),
);
})
],
);
},
),
),
),
);
}
void _onPointerMove(Offset globalPosition) {
final rowBox = context.findRenderObject() as RenderBox;
final rowSize = rowBox.size;
final local = rowBox.globalToLocal(globalPosition);
widget.ratingNotifier.value = (5 * local.dx / rowSize.width).ceil().clamp(0, 5);
}
}

View file

@ -0,0 +1,82 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
// adapted from Flutter `_PopupMenuRouteLayout` in `/material/popup_menu.dart`
class QuickChooserRouteLayout extends SingleChildLayoutDelegate {
final RelativeRect triggerRect;
final PopupMenuPosition menuPosition;
final EdgeInsets padding;
final Set<Rect> avoidBounds;
static const double _kMenuScreenPadding = 8.0;
QuickChooserRouteLayout(
this.triggerRect,
this.menuPosition,
this.padding,
this.avoidBounds,
);
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return BoxConstraints.loose(constraints.biggest).deflate(
const EdgeInsets.all(_kMenuScreenPadding) + padding,
);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
// size: The size of the overlay.
// childSize: The size of the menu, when fully open, as determined by getConstraintsForChild.
double y;
switch (menuPosition) {
case PopupMenuPosition.over:
y = triggerRect.top - childSize.height;
break;
case PopupMenuPosition.under:
y = triggerRect.bottom;
break;
}
double x = (triggerRect.left + (size.width - triggerRect.right) - childSize.width) / 2;
final wantedPosition = Offset(x, y);
final originCenter = triggerRect.toRect(Offset.zero & size).center;
final subScreens = DisplayFeatureSubScreen.subScreensInBounds(Offset.zero & size, avoidBounds);
final subScreen = _closestScreen(subScreens, originCenter);
return _fitInsideScreen(subScreen, childSize, wantedPosition);
}
Rect _closestScreen(Iterable<Rect> screens, Offset point) {
Rect closest = screens.first;
for (final Rect screen in screens) {
if ((screen.center - point).distance < (closest.center - point).distance) {
closest = screen;
}
}
return closest;
}
Offset _fitInsideScreen(Rect screen, Size childSize, Offset wantedPosition) {
double x = wantedPosition.dx;
double y = wantedPosition.dy;
// Avoid going outside an area defined as the rectangle 8.0 pixels from the
// edge of the screen in every direction.
if (x < screen.left + _kMenuScreenPadding + padding.left) {
x = screen.left + _kMenuScreenPadding + padding.left;
} else if (x + childSize.width > screen.right - _kMenuScreenPadding - padding.right) {
x = screen.right - childSize.width - _kMenuScreenPadding - padding.right;
}
if (y < screen.top + _kMenuScreenPadding + padding.top) {
y = _kMenuScreenPadding + padding.top;
} else if (y + childSize.height > screen.bottom - _kMenuScreenPadding - padding.bottom) {
y = screen.bottom - childSize.height - _kMenuScreenPadding - padding.bottom;
}
return Offset(x, y);
}
@override
bool shouldRelayout(QuickChooserRouteLayout oldDelegate) {
return triggerRect != oldDelegate.triggerRect || padding != oldDelegate.padding || !setEquals(avoidBounds, oldDelegate.avoidBounds);
}
}

View file

@ -0,0 +1,43 @@
import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/widgets/common/app_bar/quick_choosers/chooser_button.dart';
import 'package:aves/widgets/common/app_bar/quick_choosers/rate_chooser.dart';
import 'package:flutter/material.dart';
class RateButton extends ChooserQuickButton<int> {
const RateButton({
super.key,
super.chooserPosition,
super.onChooserValue,
required super.onPressed,
});
@override
State<RateButton> createState() => _RateQuickButtonState();
}
class _RateQuickButtonState extends ChooserQuickButtonState<RateButton, int> {
static const _action = EntryAction.editRating;
@override
Widget get icon => _action.getIcon();
@override
String get tooltip => _action.getText(context);
@override
int? get defaultValue => 3;
@override
Widget buildChooser(Animation<double> animation) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: animation,
child: RateQuickChooser(
ratingNotifier: chooserValueNotifier,
moveUpdates: moveUpdates,
),
),
);
}
}

View file

@ -149,18 +149,22 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}
}
void onActionSelected(BuildContext context, EntryAction action) {
var targetEntry = mainEntry;
AvesEntry _getTargetEntry(BuildContext context, EntryAction action) {
if (mainEntry.isMultiPage && (mainEntry.isBurst || EntryActions.pageActions.contains(action))) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) {
final multiPageInfo = multiPageController.info;
final pageEntry = multiPageInfo?.getPageEntryByIndex(multiPageController.page);
if (pageEntry != null) {
targetEntry = pageEntry;
return pageEntry;
}
}
}
return mainEntry;
}
void onActionSelected(BuildContext context, EntryAction action) {
final targetEntry = _getTargetEntry(context, action);
switch (action) {
case EntryAction.info:
@ -276,6 +280,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
}
}
void quickRate(BuildContext context, int? rating) {
final targetEntry = _getTargetEntry(context, EntryAction.editRating);
_metadataActionDelegate.quickRate(context, targetEntry, rating);
}
Future<void> _addShortcut(BuildContext context, AvesEntry targetEntry) async {
final result = await showDialog<Tuple2<AvesEntry?, String>>(
context: context,

View file

@ -144,6 +144,10 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
Future<void> _editRating(BuildContext context, AvesEntry targetEntry) async {
final rating = await selectRating(context, {targetEntry});
await quickRate(context, targetEntry, rating);
}
Future<void> quickRate(BuildContext context, AvesEntry targetEntry, int? rating) async {
if (rating == null) return;
await edit(context, targetEntry, () => targetEntry.editRating(rating));

View file

@ -5,6 +5,7 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/app_bar/favourite_toggler.dart';
import 'package:aves/widgets/common/app_bar/rate_button.dart';
import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/basic/popup_menu_button.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
@ -230,6 +231,13 @@ class ViewerButtonRowContent extends StatelessWidget {
case EntryAction.videoSetSpeed:
child = _buildFromListenable(videoController?.canSetSpeedNotifier);
break;
case EntryAction.editRating:
child = RateButton(
chooserPosition: PopupMenuPosition.over,
onChooserValue: (rating) => _quickRate(context, rating),
onPressed: onPressed,
);
break;
default:
child = IconButton(
icon: action.getIcon(),
@ -353,7 +361,9 @@ class ViewerButtonRowContent extends StatelessWidget {
);
}
void _onActionSelected(BuildContext context, EntryAction action) {
EntryActionDelegate(mainEntry, pageEntry, collection).onActionSelected(context, action);
}
EntryActionDelegate get _entryActionDelegate => EntryActionDelegate(mainEntry, pageEntry, collection);
void _onActionSelected(BuildContext context, EntryAction action) => _entryActionDelegate.onActionSelected(context, action);
void _quickRate(BuildContext context, int? rating) => _entryActionDelegate.quickRate(context, rating);
}