#1080 scrolling quick action selector for tag/move/copy
This commit is contained in:
parent
dc061e89e6
commit
afc09e2ab4
14 changed files with 316 additions and 85 deletions
|
@ -6,7 +6,7 @@ import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
mixin AppSettings on SettingsAccess {
|
mixin AppSettings on SettingsAccess {
|
||||||
static const int _recentFilterHistoryMax = 10;
|
static const int recentFilterHistoryMax = 20;
|
||||||
|
|
||||||
bool get hasAcceptedTerms => getBool(SettingKeys.hasAcceptedTermsKey) ?? SettingsDefaults.hasAcceptedTerms;
|
bool get hasAcceptedTerms => getBool(SettingKeys.hasAcceptedTermsKey) ?? SettingsDefaults.hasAcceptedTerms;
|
||||||
|
|
||||||
|
@ -105,9 +105,9 @@ mixin AppSettings on SettingsAccess {
|
||||||
|
|
||||||
List<String> get recentDestinationAlbums => getStringList(SettingKeys.recentDestinationAlbumsKey) ?? [];
|
List<String> get recentDestinationAlbums => getStringList(SettingKeys.recentDestinationAlbumsKey) ?? [];
|
||||||
|
|
||||||
set recentDestinationAlbums(List<String> newValue) => set(SettingKeys.recentDestinationAlbumsKey, newValue.take(_recentFilterHistoryMax).toList());
|
set recentDestinationAlbums(List<String> newValue) => set(SettingKeys.recentDestinationAlbumsKey, newValue.take(recentFilterHistoryMax).toList());
|
||||||
|
|
||||||
List<CollectionFilter> get recentTags => (getStringList(SettingKeys.recentTagsKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toList();
|
List<CollectionFilter> get recentTags => (getStringList(SettingKeys.recentTagsKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toList();
|
||||||
|
|
||||||
set recentTags(List<CollectionFilter> newValue) => set(SettingKeys.recentTagsKey, newValue.take(_recentFilterHistoryMax).map((filter) => filter.toJson()).toList());
|
set recentTags(List<CollectionFilter> newValue) => set(SettingKeys.recentTagsKey, newValue.take(recentFilterHistoryMax).map((filter) => filter.toJson()).toList());
|
||||||
}
|
}
|
||||||
|
|
|
@ -162,6 +162,8 @@ class AIcons {
|
||||||
static const zoomOut = Icons.remove_outlined;
|
static const zoomOut = Icons.remove_outlined;
|
||||||
static const collapse = Icons.expand_less_outlined;
|
static const collapse = Icons.expand_less_outlined;
|
||||||
static const expand = Icons.expand_more_outlined;
|
static const expand = Icons.expand_more_outlined;
|
||||||
|
static const up = Icons.keyboard_arrow_up_outlined;
|
||||||
|
static const down = Icons.keyboard_arrow_down_outlined;
|
||||||
static const previous = Icons.chevron_left_outlined;
|
static const previous = Icons.chevron_left_outlined;
|
||||||
static const next = Icons.chevron_right_outlined;
|
static const next = Icons.chevron_right_outlined;
|
||||||
|
|
||||||
|
|
|
@ -142,7 +142,7 @@ class _Chip extends StatelessWidget {
|
||||||
key: ValueKey(filter),
|
key: ValueKey(filter),
|
||||||
filter: filter,
|
filter: filter,
|
||||||
maxWidth: single
|
maxWidth: single
|
||||||
? AvesFilterChip.computeMaxWidth(
|
? AvesFilterChip.computeMaxWidthForRow(
|
||||||
context,
|
context,
|
||||||
minChipPerRow: 1,
|
minChipPerRow: 1,
|
||||||
chipPadding: FilterBar.chipPadding.horizontal,
|
chipPadding: FilterBar.chipPadding.horizontal,
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/filters/album.dart';
|
||||||
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/widgets/common/action_controls/quick_choosers/common/menu.dart';
|
import 'package:aves/widgets/common/action_controls/quick_choosers/common/menu.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
import 'package:aves/widgets/common/action_controls/quick_choosers/filter_quick_chooser_mixin.dart';
|
||||||
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class AlbumQuickChooser extends StatelessWidget {
|
class AlbumQuickChooser extends StatelessWidget with FilterQuickChooserMixin<String> {
|
||||||
final ValueNotifier<String?> valueNotifier;
|
final ValueNotifier<String?> valueNotifier;
|
||||||
|
@override
|
||||||
final List<String> options;
|
final List<String> options;
|
||||||
final bool blurred;
|
final bool blurred;
|
||||||
final PopupMenuPosition chooserPosition;
|
final PopupMenuPosition chooserPosition;
|
||||||
|
@ -25,7 +28,6 @@ class AlbumQuickChooser extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final source = context.read<CollectionSource>();
|
|
||||||
return MenuQuickChooser<String>(
|
return MenuQuickChooser<String>(
|
||||||
valueNotifier: valueNotifier,
|
valueNotifier: valueNotifier,
|
||||||
options: options,
|
options: options,
|
||||||
|
@ -33,10 +35,17 @@ class AlbumQuickChooser extends StatelessWidget {
|
||||||
blurred: blurred,
|
blurred: blurred,
|
||||||
chooserPosition: chooserPosition,
|
chooserPosition: chooserPosition,
|
||||||
pointerGlobalPosition: pointerGlobalPosition,
|
pointerGlobalPosition: pointerGlobalPosition,
|
||||||
itemBuilder: (context, album) => AvesFilterChip(
|
maxTotalOptionCount: FilterQuickChooserMixin.maxTotalOptionCount,
|
||||||
filter: AlbumFilter(album, source.getAlbumDisplayName(context, album)),
|
itemHeight: computeItemHeight(context),
|
||||||
allowGenericIcon: false,
|
contentWidth: computeLargestItemWidth,
|
||||||
),
|
itemBuilder: itemBuilder,
|
||||||
|
emptyBuilder: (context) => Text(context.l10n.albumEmpty),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
CollectionFilter buildFilter(BuildContext context, String option) {
|
||||||
|
final source = context.read<CollectionSource>();
|
||||||
|
return AlbumFilter(option, source.getAlbumDisplayName(context, option));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/common/action_controls/quick_choosers/common/quick_chooser.dart';
|
import 'package:aves/widgets/common/action_controls/quick_choosers/common/quick_chooser.dart';
|
||||||
import 'package:aves_ui/aves_ui.dart';
|
import 'package:aves_ui/aves_ui.dart';
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
|
@ -16,9 +17,13 @@ class MenuQuickChooser<T> extends StatefulWidget {
|
||||||
final bool blurred;
|
final bool blurred;
|
||||||
final PopupMenuPosition chooserPosition;
|
final PopupMenuPosition chooserPosition;
|
||||||
final Stream<Offset> pointerGlobalPosition;
|
final Stream<Offset> pointerGlobalPosition;
|
||||||
|
final int maxTotalOptionCount;
|
||||||
|
final double itemHeight;
|
||||||
|
final double? Function(BuildContext context)? contentWidth;
|
||||||
final Widget Function(BuildContext context, T menuItem) itemBuilder;
|
final Widget Function(BuildContext context, T menuItem) itemBuilder;
|
||||||
|
final WidgetBuilder? emptyBuilder;
|
||||||
|
|
||||||
static const int maxOptionCount = 5;
|
static const int maxVisibleOptionCount = 5;
|
||||||
|
|
||||||
MenuQuickChooser({
|
MenuQuickChooser({
|
||||||
super.key,
|
super.key,
|
||||||
|
@ -28,8 +33,12 @@ class MenuQuickChooser<T> extends StatefulWidget {
|
||||||
required this.blurred,
|
required this.blurred,
|
||||||
required this.chooserPosition,
|
required this.chooserPosition,
|
||||||
required this.pointerGlobalPosition,
|
required this.pointerGlobalPosition,
|
||||||
|
this.maxTotalOptionCount = maxVisibleOptionCount,
|
||||||
|
this.itemHeight = kMinInteractiveDimension,
|
||||||
|
this.contentWidth,
|
||||||
required this.itemBuilder,
|
required this.itemBuilder,
|
||||||
}) : options = options.take(maxOptionCount).toList();
|
this.emptyBuilder,
|
||||||
|
}) : options = options.take(maxTotalOptionCount).toList();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MenuQuickChooser<T>> createState() => _MenuQuickChooserState<T>();
|
State<MenuQuickChooser<T>> createState() => _MenuQuickChooserState<T>();
|
||||||
|
@ -38,6 +47,10 @@ class MenuQuickChooser<T> extends StatefulWidget {
|
||||||
class _MenuQuickChooserState<T> extends State<MenuQuickChooser<T>> {
|
class _MenuQuickChooserState<T> extends State<MenuQuickChooser<T>> {
|
||||||
final List<StreamSubscription> _subscriptions = [];
|
final List<StreamSubscription> _subscriptions = [];
|
||||||
final ValueNotifier<Rect> _selectedRowRect = ValueNotifier(Rect.zero);
|
final ValueNotifier<Rect> _selectedRowRect = ValueNotifier(Rect.zero);
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
int _scrollDirection = 0;
|
||||||
|
Timer? _scrollUpdateTimer;
|
||||||
|
Offset _globalPosition = Offset.zero;
|
||||||
|
|
||||||
ValueNotifier<T?> get valueNotifier => widget.valueNotifier;
|
ValueNotifier<T?> get valueNotifier => widget.valueNotifier;
|
||||||
|
|
||||||
|
@ -45,12 +58,26 @@ class _MenuQuickChooserState<T> extends State<MenuQuickChooser<T>> {
|
||||||
|
|
||||||
bool get reversed => widget.autoReverse && widget.chooserPosition == PopupMenuPosition.over;
|
bool get reversed => widget.autoReverse && widget.chooserPosition == PopupMenuPosition.over;
|
||||||
|
|
||||||
static const double intraPadding = 8;
|
bool get scrollable => options.length > MenuQuickChooser.maxVisibleOptionCount;
|
||||||
|
|
||||||
|
int get visibleOptionCount => min(MenuQuickChooser.maxVisibleOptionCount, options.length);
|
||||||
|
|
||||||
|
double get itemHeight => widget.itemHeight;
|
||||||
|
|
||||||
|
double get contentHeight => max(0, itemHeight * visibleOptionCount + _intraPadding * (visibleOptionCount - 1));
|
||||||
|
|
||||||
|
static const double _selectorMargin = 24;
|
||||||
|
static const double _intraPadding = 8;
|
||||||
|
static const double _nonScrollablePaddingHeight = _intraPadding;
|
||||||
|
static const double _scrollerAreaHeight = kMinInteractiveDimension;
|
||||||
|
static const double scrollMaxPixelPerSecond = 600.0;
|
||||||
|
static const Duration scrollUpdateInterval = Duration(milliseconds: 100);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_registerWidget(widget);
|
_registerWidget(widget);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => setState(() {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -69,6 +96,9 @@ class _MenuQuickChooserState<T> extends State<MenuQuickChooser<T>> {
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_unregisterWidget(widget);
|
_unregisterWidget(widget);
|
||||||
|
_selectedRowRect.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
_scrollUpdateTimer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,28 +121,11 @@ class _MenuQuickChooserState<T> extends State<MenuQuickChooser<T>> {
|
||||||
builder: (context, selectedValue, child) {
|
builder: (context, selectedValue, child) {
|
||||||
final durations = context.watch<DurationsData>();
|
final durations = context.watch<DurationsData>();
|
||||||
|
|
||||||
List<Widget> optionChildren = options.mapIndexed((index, value) {
|
if (options.isEmpty) {
|
||||||
final isFirst = index == (reversed ? options.length - 1 : 0);
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(top: isFirst ? intraPadding : 0, bottom: intraPadding),
|
padding: const EdgeInsets.all(16),
|
||||||
child: widget.itemBuilder(context, value),
|
child: widget.emptyBuilder?.call(context) ?? const SizedBox(),
|
||||||
);
|
);
|
||||||
}).toList();
|
|
||||||
|
|
||||||
optionChildren = AnimationConfiguration.toStaggeredList(
|
|
||||||
duration: durations.staggeredAnimation * .5,
|
|
||||||
delay: durations.staggeredAnimationDelay * .5 * timeDilation,
|
|
||||||
childAnimationBuilder: (child) => SlideAnimation(
|
|
||||||
verticalOffset: 50.0 * (widget.chooserPosition == PopupMenuPosition.over ? 1 : -1),
|
|
||||||
child: FadeInAnimation(
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
children: optionChildren,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (reversed) {
|
|
||||||
optionChildren = optionChildren.reversed.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
|
@ -137,12 +150,67 @@ class _MenuQuickChooserState<T> extends State<MenuQuickChooser<T>> {
|
||||||
return child;
|
return child;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Padding(
|
Container(
|
||||||
padding: const EdgeInsetsDirectional.only(start: 24),
|
width: widget.contentWidth?.call(context),
|
||||||
|
margin: const EdgeInsetsDirectional.only(start: _selectorMargin),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: optionChildren,
|
scrollable
|
||||||
|
? ListenableBuilder(
|
||||||
|
listenable: _scrollController,
|
||||||
|
builder: (context, child) => Opacity(
|
||||||
|
opacity: canGoUp ? 1 : .5,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
child: _buildScrollerArea(AIcons.up),
|
||||||
|
)
|
||||||
|
: const SizedBox(height: _nonScrollablePaddingHeight),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints.tightFor(height: contentHeight),
|
||||||
|
child: ListView.separated(
|
||||||
|
reverse: reversed,
|
||||||
|
controller: _scrollController,
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final child = Container(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
constraints: BoxConstraints.tightFor(height: itemHeight),
|
||||||
|
child: widget.itemBuilder(context, options[index]),
|
||||||
|
);
|
||||||
|
if (index < MenuQuickChooser.maxVisibleOptionCount) {
|
||||||
|
// only animate items visible on first render
|
||||||
|
return AnimationConfiguration.staggeredList(
|
||||||
|
position: index,
|
||||||
|
duration: durations.staggeredAnimation * .5,
|
||||||
|
delay: durations.staggeredAnimationDelay * .5 * timeDilation,
|
||||||
|
child: SlideAnimation(
|
||||||
|
verticalOffset: 50.0 * (widget.chooserPosition == PopupMenuPosition.over ? 1 : -1),
|
||||||
|
child: FadeInAnimation(
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: _intraPadding),
|
||||||
|
itemCount: options.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
scrollable
|
||||||
|
? ListenableBuilder(
|
||||||
|
listenable: _scrollController,
|
||||||
|
builder: (context, child) => Opacity(
|
||||||
|
opacity: canGoDown ? 1 : .5,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
child: _buildScrollerArea(AIcons.down),
|
||||||
|
)
|
||||||
|
: const SizedBox(height: _nonScrollablePaddingHeight),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -152,30 +220,97 @@ class _MenuQuickChooserState<T> extends State<MenuQuickChooser<T>> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onPointerMove(Offset globalPosition) {
|
bool get canGoUp {
|
||||||
final padding = QuickChooser.margin.vertical + QuickChooser.padding.vertical;
|
if (!_scrollController.hasClients) return false;
|
||||||
|
final position = _scrollController.position;
|
||||||
|
return reversed ? position.pixels < position.maxScrollExtent : 0 < position.pixels;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get canGoDown {
|
||||||
|
if (!_scrollController.hasClients) return false;
|
||||||
|
final position = _scrollController.position;
|
||||||
|
return reversed ? 0 < position.pixels : position.pixels < position.maxScrollExtent;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildScrollerArea(IconData icon) {
|
||||||
|
return Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
height: _scrollerAreaHeight,
|
||||||
|
margin: const EdgeInsetsDirectional.only(end: _selectorMargin),
|
||||||
|
child: Icon(icon),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerMove(Offset globalPosition) {
|
||||||
|
_globalPosition = globalPosition;
|
||||||
|
final chooserBox = context.findRenderObject() as RenderBox?;
|
||||||
|
if (chooserBox == null) return;
|
||||||
|
|
||||||
final chooserBox = context.findRenderObject() as RenderBox;
|
|
||||||
final chooserSize = chooserBox.size;
|
final chooserSize = chooserBox.size;
|
||||||
final contentWidth = chooserSize.width;
|
final contentWidth = chooserSize.width;
|
||||||
final contentHeight = chooserSize.height - padding;
|
final chooserBoxEdgeHeight = (QuickChooser.margin.vertical + QuickChooser.padding.vertical) / 2;
|
||||||
|
|
||||||
final optionCount = options.length;
|
final localPosition = chooserBox.globalToLocal(globalPosition);
|
||||||
final itemHeight = (contentHeight - (optionCount + 1) * intraPadding) / optionCount;
|
final dx = localPosition.dx;
|
||||||
|
if (!(0 < dx && dx < contentWidth)) {
|
||||||
|
valueNotifier.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final local = chooserBox.globalToLocal(globalPosition);
|
double dy = localPosition.dy - chooserBoxEdgeHeight;
|
||||||
final dx = local.dx;
|
int scrollDirection = 0;
|
||||||
final dy = local.dy - padding / 2;
|
if (scrollable) {
|
||||||
|
dy -= _scrollerAreaHeight;
|
||||||
|
if (-_scrollerAreaHeight < dy && dy < 0) {
|
||||||
|
scrollDirection = reversed ? 1 : -1;
|
||||||
|
} else if (contentHeight < dy && dy < contentHeight + _scrollerAreaHeight) {
|
||||||
|
scrollDirection = reversed ? -1 : 1;
|
||||||
|
}
|
||||||
|
_scroll(scrollDirection);
|
||||||
|
} else {
|
||||||
|
dy -= _nonScrollablePaddingHeight;
|
||||||
|
}
|
||||||
|
|
||||||
T? selectedValue;
|
T? selectedValue;
|
||||||
if (0 < dx && dx < contentWidth && 0 < dy && dy < contentHeight) {
|
if (scrollDirection == 0 && 0 < dy && dy < contentHeight) {
|
||||||
final index = (optionCount * dy / contentHeight).floor();
|
final visibleOffset = reversed ? contentHeight - dy : dy;
|
||||||
if (0 <= index && index < optionCount) {
|
final fullItemHeight = itemHeight + _intraPadding;
|
||||||
selectedValue = options[reversed ? optionCount - 1 - index : index];
|
final scrollOffset = _scrollController.offset;
|
||||||
final top = index * (itemHeight + intraPadding) + intraPadding;
|
final index = (visibleOffset + _intraPadding + scrollOffset) ~/ (fullItemHeight);
|
||||||
|
if (0 <= index && index < options.length) {
|
||||||
|
selectedValue = options[index];
|
||||||
|
double fromEdge = fullItemHeight * index;
|
||||||
|
fromEdge += (scrollable ? _scrollerAreaHeight - scrollOffset : _nonScrollablePaddingHeight);
|
||||||
|
final top = reversed ? chooserSize.height - chooserBoxEdgeHeight - fromEdge - fullItemHeight : fromEdge;
|
||||||
_selectedRowRect.value = Rect.fromLTWH(0, top, contentWidth, itemHeight);
|
_selectedRowRect.value = Rect.fromLTWH(0, top, contentWidth, itemHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
valueNotifier.value = selectedValue;
|
valueNotifier.value = selectedValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _scroll(int scrollDirection) {
|
||||||
|
if (scrollDirection == _scrollDirection) return;
|
||||||
|
_scrollDirection = scrollDirection;
|
||||||
|
_scrollUpdateTimer?.cancel();
|
||||||
|
|
||||||
|
final current = _scrollController.offset;
|
||||||
|
if (scrollDirection == 0) {
|
||||||
|
_scrollController.jumpTo(current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final target = scrollDirection > 0 ? _scrollController.position.maxScrollExtent : .0;
|
||||||
|
if (target != current) {
|
||||||
|
final distance = target - current;
|
||||||
|
final millis = distance * 1000 / scrollMaxPixelPerSecond / scrollDirection;
|
||||||
|
_scrollController.animateTo(
|
||||||
|
target,
|
||||||
|
duration: Duration(milliseconds: millis.round()),
|
||||||
|
curve: Curves.linear,
|
||||||
|
);
|
||||||
|
// use a timer to update the selection, because `_onPointerMove`
|
||||||
|
// is not called when the pointer stays still while the view is scrolling
|
||||||
|
_scrollUpdateTimer = Timer.periodic(scrollUpdateInterval, (_) => _onPointerMove(_globalPosition));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:aves/model/filters/filters.dart';
|
||||||
|
import 'package:aves/model/settings/modules/app.dart';
|
||||||
|
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
|
||||||
|
mixin FilterQuickChooserMixin<T> {
|
||||||
|
List<T> get options;
|
||||||
|
|
||||||
|
static const int maxTotalOptionCount = AppSettings.recentFilterHistoryMax;
|
||||||
|
static const double _chipPadding = AvesFilterChip.defaultPadding;
|
||||||
|
static const bool _chipAllowGenericIcon = false;
|
||||||
|
|
||||||
|
CollectionFilter buildFilter(BuildContext context, T option);
|
||||||
|
|
||||||
|
Widget itemBuilder(BuildContext context, T option) {
|
||||||
|
return AvesFilterChip(
|
||||||
|
filter: buildFilter(context, option),
|
||||||
|
allowGenericIcon: _chipAllowGenericIcon,
|
||||||
|
padding: _chipPadding,
|
||||||
|
maxWidth: double.infinity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double computeItemHeight(BuildContext context) => AvesFilterChip.minChipHeight;
|
||||||
|
|
||||||
|
double? computeLargestItemWidth(BuildContext context) {
|
||||||
|
if (options.isEmpty) return null;
|
||||||
|
|
||||||
|
final textStyle = DefaultTextStyle.of(context).style.copyWith(
|
||||||
|
fontSize: AvesFilterChip.fontSize,
|
||||||
|
);
|
||||||
|
final textDirection = Directionality.of(context);
|
||||||
|
final textScaler = MediaQuery.textScalerOf(context);
|
||||||
|
final iconSize = textScaler.scale(AvesFilterChip.iconSize);
|
||||||
|
|
||||||
|
return options.map((option) {
|
||||||
|
final filter = buildFilter(context, option);
|
||||||
|
final icon = filter.iconBuilder(context, iconSize, allowGenericIcon: _chipAllowGenericIcon);
|
||||||
|
final label = filter.getLabel(context);
|
||||||
|
final paragraph = RenderParagraph(
|
||||||
|
TextSpan(text: label, style: textStyle),
|
||||||
|
textDirection: textDirection,
|
||||||
|
textScaler: textScaler,
|
||||||
|
)..layout(const BoxConstraints(), parentUsesSize: true);
|
||||||
|
final labelWidth = paragraph.getMaxIntrinsicWidth(double.infinity);
|
||||||
|
double chipWidth = labelWidth + _chipPadding * 4;
|
||||||
|
if (icon != null) {
|
||||||
|
chipWidth += iconSize + _chipPadding;
|
||||||
|
}
|
||||||
|
return max(AvesFilterChip.minChipWidth, chipWidth);
|
||||||
|
}).reduce(max);
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,7 +5,7 @@ import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/view/view.dart';
|
import 'package:aves/view/view.dart';
|
||||||
import 'package:aves/widgets/common/action_controls/quick_choosers/album_chooser.dart';
|
import 'package:aves/widgets/common/action_controls/quick_choosers/album_chooser.dart';
|
||||||
import 'package:aves/widgets/common/action_controls/quick_choosers/common/button.dart';
|
import 'package:aves/widgets/common/action_controls/quick_choosers/common/button.dart';
|
||||||
import 'package:aves/widgets/common/action_controls/quick_choosers/common/menu.dart';
|
import 'package:aves/widgets/common/action_controls/quick_choosers/filter_quick_chooser_mixin.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
|
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
|
||||||
import 'package:aves_model/aves_model.dart';
|
import 'package:aves_model/aves_model.dart';
|
||||||
|
@ -42,7 +42,7 @@ class _MoveButtonState extends ChooserQuickButtonState<MoveButton, String> {
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
final rawAlbums = source.rawAlbums;
|
final rawAlbums = source.rawAlbums;
|
||||||
final options = settings.recentDestinationAlbums.where(rawAlbums.contains).toList();
|
final options = settings.recentDestinationAlbums.where(rawAlbums.contains).toList();
|
||||||
final takeCount = MenuQuickChooser.maxOptionCount - options.length;
|
final takeCount = FilterQuickChooserMixin.maxTotalOptionCount - options.length;
|
||||||
if (takeCount > 0) {
|
if (takeCount > 0) {
|
||||||
final filters = rawAlbums.whereNot(options.contains).map((album) => AlbumFilter(album, null)).toSet();
|
final filters = rawAlbums.whereNot(options.contains).map((album) => AlbumFilter(album, null)).toSet();
|
||||||
final allMapEntries = filters.map((filter) => FilterGridItem(filter, source.recentEntry(filter))).toList();
|
final allMapEntries = filters.map((filter) => FilterGridItem(filter, source.recentEntry(filter))).toList();
|
||||||
|
|
|
@ -51,7 +51,6 @@ class _ShareButtonState extends ChooserQuickButtonState<ShareButton, ShareAction
|
||||||
child: ShareQuickChooser(
|
child: ShareQuickChooser(
|
||||||
valueNotifier: chooserValueNotifier,
|
valueNotifier: chooserValueNotifier,
|
||||||
options: options,
|
options: options,
|
||||||
autoReverse: false,
|
|
||||||
blurred: widget.blurred,
|
blurred: widget.blurred,
|
||||||
chooserPosition: chooserPosition,
|
chooserPosition: chooserPosition,
|
||||||
pointerGlobalPosition: pointerGlobalPosition,
|
pointerGlobalPosition: pointerGlobalPosition,
|
||||||
|
|
|
@ -1,24 +1,26 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/view/view.dart';
|
import 'package:aves/view/view.dart';
|
||||||
import 'package:aves/widgets/common/action_controls/quick_choosers/common/menu.dart';
|
import 'package:aves/widgets/common/action_controls/quick_choosers/common/menu.dart';
|
||||||
import 'package:aves/widgets/common/basic/popup/menu_row.dart';
|
import 'package:aves/widgets/common/basic/popup/menu_row.dart';
|
||||||
import 'package:aves_model/aves_model.dart';
|
import 'package:aves_model/aves_model.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
|
||||||
class ShareQuickChooser extends StatelessWidget {
|
class ShareQuickChooser extends StatelessWidget {
|
||||||
final ValueNotifier<ShareAction?> valueNotifier;
|
final ValueNotifier<ShareAction?> valueNotifier;
|
||||||
final List<ShareAction> options;
|
final List<ShareAction> options;
|
||||||
final bool autoReverse;
|
|
||||||
final bool blurred;
|
final bool blurred;
|
||||||
final PopupMenuPosition chooserPosition;
|
final PopupMenuPosition chooserPosition;
|
||||||
final Stream<Offset> pointerGlobalPosition;
|
final Stream<Offset> pointerGlobalPosition;
|
||||||
|
|
||||||
|
static const _itemPadding = EdgeInsetsDirectional.only(end: 8);
|
||||||
|
|
||||||
const ShareQuickChooser({
|
const ShareQuickChooser({
|
||||||
super.key,
|
super.key,
|
||||||
required this.valueNotifier,
|
required this.valueNotifier,
|
||||||
required this.options,
|
required this.options,
|
||||||
required this.autoReverse,
|
|
||||||
required this.blurred,
|
required this.blurred,
|
||||||
required this.chooserPosition,
|
required this.chooserPosition,
|
||||||
required this.pointerGlobalPosition,
|
required this.pointerGlobalPosition,
|
||||||
|
@ -29,20 +31,39 @@ class ShareQuickChooser extends StatelessWidget {
|
||||||
return MenuQuickChooser<ShareAction>(
|
return MenuQuickChooser<ShareAction>(
|
||||||
valueNotifier: valueNotifier,
|
valueNotifier: valueNotifier,
|
||||||
options: options,
|
options: options,
|
||||||
autoReverse: autoReverse,
|
autoReverse: false,
|
||||||
blurred: blurred,
|
blurred: blurred,
|
||||||
chooserPosition: chooserPosition,
|
chooserPosition: chooserPosition,
|
||||||
pointerGlobalPosition: pointerGlobalPosition,
|
pointerGlobalPosition: pointerGlobalPosition,
|
||||||
itemBuilder: (context, action) => ConstrainedBox(
|
itemHeight: kMinInteractiveDimension,
|
||||||
constraints: const BoxConstraints(minHeight: kMinInteractiveDimension),
|
contentWidth: _computeLargestItemWidth,
|
||||||
child: Padding(
|
itemBuilder: (context, action) => Padding(
|
||||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
padding: _itemPadding,
|
||||||
child: MenuRow(
|
child: MenuRow(
|
||||||
text: action.getText(context),
|
text: action.getText(context),
|
||||||
icon: action.getIcon(),
|
icon: action.getIcon(),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double? _computeLargestItemWidth(BuildContext context) {
|
||||||
|
if (options.isEmpty) return null;
|
||||||
|
|
||||||
|
final textStyle = DefaultTextStyle.of(context).style;
|
||||||
|
final textDirection = Directionality.of(context);
|
||||||
|
final textScaler = MediaQuery.textScalerOf(context);
|
||||||
|
final iconSize = IconTheme.of(context).size ?? 24;
|
||||||
|
|
||||||
|
return options.map((action) {
|
||||||
|
final text = action.getText(context);
|
||||||
|
final paragraph = RenderParagraph(
|
||||||
|
TextSpan(text: text, style: textStyle),
|
||||||
|
textDirection: textDirection,
|
||||||
|
textScaler: textScaler,
|
||||||
|
)..layout(const BoxConstraints(), parentUsesSize: true);
|
||||||
|
final labelWidth = paragraph.getMaxIntrinsicWidth(double.infinity);
|
||||||
|
return iconSize + MenuRow.leadingPadding.horizontal + labelWidth + _itemPadding.horizontal;
|
||||||
|
}).reduce(max);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/view/view.dart';
|
import 'package:aves/view/view.dart';
|
||||||
import 'package:aves/widgets/common/action_controls/quick_choosers/common/button.dart';
|
import 'package:aves/widgets/common/action_controls/quick_choosers/common/button.dart';
|
||||||
import 'package:aves/widgets/common/action_controls/quick_choosers/common/menu.dart';
|
import 'package:aves/widgets/common/action_controls/quick_choosers/filter_quick_chooser_mixin.dart';
|
||||||
import 'package:aves/widgets/common/action_controls/quick_choosers/tag_chooser.dart';
|
import 'package:aves/widgets/common/action_controls/quick_choosers/tag_chooser.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
|
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
|
||||||
|
@ -38,7 +38,7 @@ class _TagButtonState extends ChooserQuickButtonState<TagButton, CollectionFilte
|
||||||
@override
|
@override
|
||||||
Widget buildChooser(Animation<double> animation, PopupMenuPosition chooserPosition) {
|
Widget buildChooser(Animation<double> animation, PopupMenuPosition chooserPosition) {
|
||||||
final options = settings.recentTags;
|
final options = settings.recentTags;
|
||||||
final takeCount = MenuQuickChooser.maxOptionCount - options.length;
|
final takeCount = FilterQuickChooserMixin.maxTotalOptionCount - options.length;
|
||||||
if (takeCount > 0) {
|
if (takeCount > 0) {
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
final filters = source.sortedTags.map(TagFilter.new).whereNot(options.contains).toSet();
|
final filters = source.sortedTags.map(TagFilter.new).whereNot(options.contains).toSet();
|
||||||
|
|
|
@ -2,11 +2,13 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/widgets/common/action_controls/quick_choosers/common/menu.dart';
|
import 'package:aves/widgets/common/action_controls/quick_choosers/common/menu.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
import 'package:aves/widgets/common/action_controls/quick_choosers/filter_quick_chooser_mixin.dart';
|
||||||
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class TagQuickChooser extends StatelessWidget {
|
class TagQuickChooser extends StatelessWidget with FilterQuickChooserMixin<CollectionFilter> {
|
||||||
final ValueNotifier<CollectionFilter?> valueNotifier;
|
final ValueNotifier<CollectionFilter?> valueNotifier;
|
||||||
|
@override
|
||||||
final List<CollectionFilter> options;
|
final List<CollectionFilter> options;
|
||||||
final bool blurred;
|
final bool blurred;
|
||||||
final PopupMenuPosition chooserPosition;
|
final PopupMenuPosition chooserPosition;
|
||||||
|
@ -30,10 +32,14 @@ class TagQuickChooser extends StatelessWidget {
|
||||||
blurred: blurred,
|
blurred: blurred,
|
||||||
chooserPosition: chooserPosition,
|
chooserPosition: chooserPosition,
|
||||||
pointerGlobalPosition: pointerGlobalPosition,
|
pointerGlobalPosition: pointerGlobalPosition,
|
||||||
itemBuilder: (context, filter) => AvesFilterChip(
|
maxTotalOptionCount: FilterQuickChooserMixin.maxTotalOptionCount,
|
||||||
filter: filter,
|
itemHeight: computeItemHeight(context),
|
||||||
allowGenericIcon: false,
|
contentWidth: computeLargestItemWidth,
|
||||||
),
|
itemBuilder: itemBuilder,
|
||||||
|
emptyBuilder: (context) => Text(context.l10n.tagEmpty),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
CollectionFilter buildFilter(BuildContext context, CollectionFilter option) => option;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,8 @@ class MenuRow extends StatelessWidget {
|
||||||
this.icon,
|
this.icon,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static const leadingPadding = EdgeInsetsDirectional.only(end: 12);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return Row(
|
||||||
|
@ -17,7 +19,7 @@ class MenuRow extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
if (icon != null)
|
if (icon != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
padding: leadingPadding,
|
||||||
child: IconTheme.merge(
|
child: IconTheme.merge(
|
||||||
data: IconThemeData(
|
data: IconThemeData(
|
||||||
color: ListTileTheme.of(context).iconColor,
|
color: ListTileTheme.of(context).iconColor,
|
||||||
|
|
|
@ -55,9 +55,9 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
|
||||||
return scrollableBox.size.width;
|
return scrollableBox.size.width;
|
||||||
}
|
}
|
||||||
|
|
||||||
static const double scrollEdgeRatio = .15;
|
static const double _scrollEdgeRatio = .15;
|
||||||
static const double scrollMaxPixelPerSecond = 600.0;
|
static const double _scrollMaxPixelPerSecond = 600.0;
|
||||||
static const Duration scrollUpdateInterval = Duration(milliseconds: 100);
|
static const Duration _scrollUpdateInterval = Duration(milliseconds: 100);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -158,7 +158,7 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
|
||||||
final top = dy < height / 2;
|
final top = dy < height / 2;
|
||||||
|
|
||||||
final distanceToEdge = max(0, top ? dy - _scrollableInsets.top : height - dy - _scrollableInsets.bottom);
|
final distanceToEdge = max(0, top ? dy - _scrollableInsets.top : height - dy - _scrollableInsets.bottom);
|
||||||
final threshold = height * scrollEdgeRatio;
|
final threshold = height * _scrollEdgeRatio;
|
||||||
if (distanceToEdge < threshold) {
|
if (distanceToEdge < threshold) {
|
||||||
_setScrollSpeed((top ? -1 : 1) * roundToPrecision((threshold - distanceToEdge) / threshold, decimals: 1));
|
_setScrollSpeed((top ? -1 : 1) * roundToPrecision((threshold - distanceToEdge) / threshold, decimals: 1));
|
||||||
} else {
|
} else {
|
||||||
|
@ -185,7 +185,7 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
|
||||||
final target = speedFactor > 0 ? scrollController.position.maxScrollExtent : .0;
|
final target = speedFactor > 0 ? scrollController.position.maxScrollExtent : .0;
|
||||||
if (target != current) {
|
if (target != current) {
|
||||||
final distance = target - current;
|
final distance = target - current;
|
||||||
final millis = distance * 1000 / scrollMaxPixelPerSecond / speedFactor;
|
final millis = distance * 1000 / _scrollMaxPixelPerSecond / speedFactor;
|
||||||
scrollController.animateTo(
|
scrollController.animateTo(
|
||||||
target,
|
target,
|
||||||
duration: Duration(milliseconds: millis.round()),
|
duration: Duration(milliseconds: millis.round()),
|
||||||
|
@ -193,7 +193,7 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
|
||||||
);
|
);
|
||||||
// use a timer to update the selection, because `onLongPressMoveUpdate`
|
// use a timer to update the selection, because `onLongPressMoveUpdate`
|
||||||
// is not called when the pointer stays still while the view is scrolling
|
// is not called when the pointer stays still while the view is scrolling
|
||||||
_selectionUpdateTimer = Timer.periodic(scrollUpdateInterval, (_) => _onLongPressUpdate());
|
_selectionUpdateTimer = Timer.periodic(_scrollUpdateInterval, (_) => _onLongPressUpdate());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -59,6 +59,7 @@ class AvesFilterChip extends StatefulWidget {
|
||||||
final FilterCallback? onTap, onRemove;
|
final FilterCallback? onTap, onRemove;
|
||||||
final OffsetFilterCallback? onLongPress;
|
final OffsetFilterCallback? onLongPress;
|
||||||
|
|
||||||
|
static const double defaultPadding = 6.0;
|
||||||
static const double defaultRadius = 32;
|
static const double defaultRadius = 32;
|
||||||
static const double outlineWidth = 2;
|
static const double outlineWidth = 2;
|
||||||
static const double minChipHeight = kMinInteractiveDimension;
|
static const double minChipHeight = kMinInteractiveDimension;
|
||||||
|
@ -79,7 +80,7 @@ class AvesFilterChip extends StatefulWidget {
|
||||||
this.banner,
|
this.banner,
|
||||||
this.leadingOverride,
|
this.leadingOverride,
|
||||||
this.details,
|
this.details,
|
||||||
this.padding = 6.0,
|
this.padding = defaultPadding,
|
||||||
this.maxWidth,
|
this.maxWidth,
|
||||||
this.heroType = HeroType.onTap,
|
this.heroType = HeroType.onTap,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
@ -87,7 +88,7 @@ class AvesFilterChip extends StatefulWidget {
|
||||||
this.onLongPress = showDefaultLongPressMenu,
|
this.onLongPress = showDefaultLongPressMenu,
|
||||||
});
|
});
|
||||||
|
|
||||||
static double computeMaxWidth(
|
static double computeMaxWidthForRow(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required int minChipPerRow,
|
required int minChipPerRow,
|
||||||
required double chipPadding,
|
required double chipPadding,
|
||||||
|
@ -347,7 +348,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
||||||
maxWidth: max(
|
maxWidth: max(
|
||||||
AvesFilterChip.minChipWidth,
|
AvesFilterChip.minChipWidth,
|
||||||
widget.maxWidth ??
|
widget.maxWidth ??
|
||||||
AvesFilterChip.computeMaxWidth(
|
AvesFilterChip.computeMaxWidthForRow(
|
||||||
context,
|
context,
|
||||||
minChipPerRow: 2,
|
minChipPerRow: 2,
|
||||||
chipPadding: FilterBar.chipPadding.horizontal,
|
chipPadding: FilterBar.chipPadding.horizontal,
|
||||||
|
|
Loading…
Reference in a new issue