#406 quick rate
This commit is contained in:
parent
07abd4091c
commit
9fcebd26f6
10 changed files with 386 additions and 10 deletions
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
124
lib/widgets/common/app_bar/quick_choosers/chooser_button.dart
Normal file
124
lib/widgets/common/app_bar/quick_choosers/chooser_button.dart
Normal 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();
|
||||
}
|
||||
}
|
93
lib/widgets/common/app_bar/quick_choosers/rate_chooser.dart
Normal file
93
lib/widgets/common/app_bar/quick_choosers/rate_chooser.dart
Normal 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);
|
||||
}
|
||||
}
|
82
lib/widgets/common/app_bar/quick_choosers/route_layout.dart
Normal file
82
lib/widgets/common/app_bar/quick_choosers/route_layout.dart
Normal 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);
|
||||
}
|
||||
}
|
43
lib/widgets/common/app_bar/rate_button.dart
Normal file
43
lib/widgets/common/app_bar/rate_button.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue