#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';
|
||||
|
||||
mixin AppSettings on SettingsAccess {
|
||||
static const int _recentFilterHistoryMax = 10;
|
||||
static const int recentFilterHistoryMax = 20;
|
||||
|
||||
bool get hasAcceptedTerms => getBool(SettingKeys.hasAcceptedTermsKey) ?? SettingsDefaults.hasAcceptedTerms;
|
||||
|
||||
|
@ -105,9 +105,9 @@ mixin AppSettings on SettingsAccess {
|
|||
|
||||
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();
|
||||
|
||||
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 collapse = Icons.expand_less_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 next = Icons.chevron_right_outlined;
|
||||
|
||||
|
|
|
@ -142,7 +142,7 @@ class _Chip extends StatelessWidget {
|
|||
key: ValueKey(filter),
|
||||
filter: filter,
|
||||
maxWidth: single
|
||||
? AvesFilterChip.computeMaxWidth(
|
||||
? AvesFilterChip.computeMaxWidthForRow(
|
||||
context,
|
||||
minChipPerRow: 1,
|
||||
chipPadding: FilterBar.chipPadding.horizontal,
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
import 'dart:async';
|
||||
|
||||
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/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:provider/provider.dart';
|
||||
|
||||
class AlbumQuickChooser extends StatelessWidget {
|
||||
class AlbumQuickChooser extends StatelessWidget with FilterQuickChooserMixin<String> {
|
||||
final ValueNotifier<String?> valueNotifier;
|
||||
@override
|
||||
final List<String> options;
|
||||
final bool blurred;
|
||||
final PopupMenuPosition chooserPosition;
|
||||
|
@ -25,7 +28,6 @@ class AlbumQuickChooser extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final source = context.read<CollectionSource>();
|
||||
return MenuQuickChooser<String>(
|
||||
valueNotifier: valueNotifier,
|
||||
options: options,
|
||||
|
@ -33,10 +35,17 @@ class AlbumQuickChooser extends StatelessWidget {
|
|||
blurred: blurred,
|
||||
chooserPosition: chooserPosition,
|
||||
pointerGlobalPosition: pointerGlobalPosition,
|
||||
itemBuilder: (context, album) => AvesFilterChip(
|
||||
filter: AlbumFilter(album, source.getAlbumDisplayName(context, album)),
|
||||
allowGenericIcon: false,
|
||||
),
|
||||
maxTotalOptionCount: FilterQuickChooserMixin.maxTotalOptionCount,
|
||||
itemHeight: computeItemHeight(context),
|
||||
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:math';
|
||||
|
||||
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_ui/aves_ui.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
|
@ -16,9 +17,13 @@ class MenuQuickChooser<T> extends StatefulWidget {
|
|||
final bool blurred;
|
||||
final PopupMenuPosition chooserPosition;
|
||||
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 WidgetBuilder? emptyBuilder;
|
||||
|
||||
static const int maxOptionCount = 5;
|
||||
static const int maxVisibleOptionCount = 5;
|
||||
|
||||
MenuQuickChooser({
|
||||
super.key,
|
||||
|
@ -28,8 +33,12 @@ class MenuQuickChooser<T> extends StatefulWidget {
|
|||
required this.blurred,
|
||||
required this.chooserPosition,
|
||||
required this.pointerGlobalPosition,
|
||||
this.maxTotalOptionCount = maxVisibleOptionCount,
|
||||
this.itemHeight = kMinInteractiveDimension,
|
||||
this.contentWidth,
|
||||
required this.itemBuilder,
|
||||
}) : options = options.take(maxOptionCount).toList();
|
||||
this.emptyBuilder,
|
||||
}) : options = options.take(maxTotalOptionCount).toList();
|
||||
|
||||
@override
|
||||
State<MenuQuickChooser<T>> createState() => _MenuQuickChooserState<T>();
|
||||
|
@ -38,6 +47,10 @@ class MenuQuickChooser<T> extends StatefulWidget {
|
|||
class _MenuQuickChooserState<T> extends State<MenuQuickChooser<T>> {
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
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;
|
||||
|
||||
|
@ -45,12 +58,26 @@ class _MenuQuickChooserState<T> extends State<MenuQuickChooser<T>> {
|
|||
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_registerWidget(widget);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -69,6 +96,9 @@ class _MenuQuickChooserState<T> extends State<MenuQuickChooser<T>> {
|
|||
@override
|
||||
void dispose() {
|
||||
_unregisterWidget(widget);
|
||||
_selectedRowRect.dispose();
|
||||
_scrollController.dispose();
|
||||
_scrollUpdateTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
@ -91,28 +121,11 @@ class _MenuQuickChooserState<T> extends State<MenuQuickChooser<T>> {
|
|||
builder: (context, selectedValue, child) {
|
||||
final durations = context.watch<DurationsData>();
|
||||
|
||||
List<Widget> optionChildren = options.mapIndexed((index, value) {
|
||||
final isFirst = index == (reversed ? options.length - 1 : 0);
|
||||
if (options.isEmpty) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: isFirst ? intraPadding : 0, bottom: intraPadding),
|
||||
child: widget.itemBuilder(context, value),
|
||||
padding: const EdgeInsets.all(16),
|
||||
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(
|
||||
|
@ -137,12 +150,67 @@ class _MenuQuickChooserState<T> extends State<MenuQuickChooser<T>> {
|
|||
return child;
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 24),
|
||||
Container(
|
||||
width: widget.contentWidth?.call(context),
|
||||
margin: const EdgeInsetsDirectional.only(start: _selectorMargin),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: optionChildren,
|
||||
children: [
|
||||
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) {
|
||||
final padding = QuickChooser.margin.vertical + QuickChooser.padding.vertical;
|
||||
bool get canGoUp {
|
||||
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 contentWidth = chooserSize.width;
|
||||
final contentHeight = chooserSize.height - padding;
|
||||
final chooserBoxEdgeHeight = (QuickChooser.margin.vertical + QuickChooser.padding.vertical) / 2;
|
||||
|
||||
final optionCount = options.length;
|
||||
final itemHeight = (contentHeight - (optionCount + 1) * intraPadding) / optionCount;
|
||||
final localPosition = chooserBox.globalToLocal(globalPosition);
|
||||
final dx = localPosition.dx;
|
||||
if (!(0 < dx && dx < contentWidth)) {
|
||||
valueNotifier.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final local = chooserBox.globalToLocal(globalPosition);
|
||||
final dx = local.dx;
|
||||
final dy = local.dy - padding / 2;
|
||||
double dy = localPosition.dy - chooserBoxEdgeHeight;
|
||||
int scrollDirection = 0;
|
||||
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;
|
||||
if (0 < dx && dx < contentWidth && 0 < dy && dy < contentHeight) {
|
||||
final index = (optionCount * dy / contentHeight).floor();
|
||||
if (0 <= index && index < optionCount) {
|
||||
selectedValue = options[reversed ? optionCount - 1 - index : index];
|
||||
final top = index * (itemHeight + intraPadding) + intraPadding;
|
||||
if (scrollDirection == 0 && 0 < dy && dy < contentHeight) {
|
||||
final visibleOffset = reversed ? contentHeight - dy : dy;
|
||||
final fullItemHeight = itemHeight + _intraPadding;
|
||||
final scrollOffset = _scrollController.offset;
|
||||
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);
|
||||
}
|
||||
}
|
||||
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/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/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/filter_grids/common/filter_nav_page.dart';
|
||||
import 'package:aves_model/aves_model.dart';
|
||||
|
@ -42,7 +42,7 @@ class _MoveButtonState extends ChooserQuickButtonState<MoveButton, String> {
|
|||
final source = context.read<CollectionSource>();
|
||||
final rawAlbums = source.rawAlbums;
|
||||
final options = settings.recentDestinationAlbums.where(rawAlbums.contains).toList();
|
||||
final takeCount = MenuQuickChooser.maxOptionCount - options.length;
|
||||
final takeCount = FilterQuickChooserMixin.maxTotalOptionCount - options.length;
|
||||
if (takeCount > 0) {
|
||||
final filters = rawAlbums.whereNot(options.contains).map((album) => AlbumFilter(album, null)).toSet();
|
||||
final allMapEntries = filters.map((filter) => FilterGridItem(filter, source.recentEntry(filter))).toList();
|
||||
|
|
|
@ -51,7 +51,6 @@ class _ShareButtonState extends ChooserQuickButtonState<ShareButton, ShareAction
|
|||
child: ShareQuickChooser(
|
||||
valueNotifier: chooserValueNotifier,
|
||||
options: options,
|
||||
autoReverse: false,
|
||||
blurred: widget.blurred,
|
||||
chooserPosition: chooserPosition,
|
||||
pointerGlobalPosition: pointerGlobalPosition,
|
||||
|
|
|
@ -1,24 +1,26 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:aves/view/view.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_model/aves_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
class ShareQuickChooser extends StatelessWidget {
|
||||
final ValueNotifier<ShareAction?> valueNotifier;
|
||||
final List<ShareAction> options;
|
||||
final bool autoReverse;
|
||||
final bool blurred;
|
||||
final PopupMenuPosition chooserPosition;
|
||||
final Stream<Offset> pointerGlobalPosition;
|
||||
|
||||
static const _itemPadding = EdgeInsetsDirectional.only(end: 8);
|
||||
|
||||
const ShareQuickChooser({
|
||||
super.key,
|
||||
required this.valueNotifier,
|
||||
required this.options,
|
||||
required this.autoReverse,
|
||||
required this.blurred,
|
||||
required this.chooserPosition,
|
||||
required this.pointerGlobalPosition,
|
||||
|
@ -29,20 +31,39 @@ class ShareQuickChooser extends StatelessWidget {
|
|||
return MenuQuickChooser<ShareAction>(
|
||||
valueNotifier: valueNotifier,
|
||||
options: options,
|
||||
autoReverse: autoReverse,
|
||||
autoReverse: false,
|
||||
blurred: blurred,
|
||||
chooserPosition: chooserPosition,
|
||||
pointerGlobalPosition: pointerGlobalPosition,
|
||||
itemBuilder: (context, action) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: kMinInteractiveDimension),
|
||||
child: Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||
child: MenuRow(
|
||||
text: action.getText(context),
|
||||
icon: action.getIcon(),
|
||||
),
|
||||
itemHeight: kMinInteractiveDimension,
|
||||
contentWidth: _computeLargestItemWidth,
|
||||
itemBuilder: (context, action) => Padding(
|
||||
padding: _itemPadding,
|
||||
child: MenuRow(
|
||||
text: action.getText(context),
|
||||
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/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/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/providers/media_query_data_provider.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
|
||||
|
@ -38,7 +38,7 @@ class _TagButtonState extends ChooserQuickButtonState<TagButton, CollectionFilte
|
|||
@override
|
||||
Widget buildChooser(Animation<double> animation, PopupMenuPosition chooserPosition) {
|
||||
final options = settings.recentTags;
|
||||
final takeCount = MenuQuickChooser.maxOptionCount - options.length;
|
||||
final takeCount = FilterQuickChooserMixin.maxTotalOptionCount - options.length;
|
||||
if (takeCount > 0) {
|
||||
final source = context.read<CollectionSource>();
|
||||
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/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';
|
||||
|
||||
class TagQuickChooser extends StatelessWidget {
|
||||
class TagQuickChooser extends StatelessWidget with FilterQuickChooserMixin<CollectionFilter> {
|
||||
final ValueNotifier<CollectionFilter?> valueNotifier;
|
||||
@override
|
||||
final List<CollectionFilter> options;
|
||||
final bool blurred;
|
||||
final PopupMenuPosition chooserPosition;
|
||||
|
@ -30,10 +32,14 @@ class TagQuickChooser extends StatelessWidget {
|
|||
blurred: blurred,
|
||||
chooserPosition: chooserPosition,
|
||||
pointerGlobalPosition: pointerGlobalPosition,
|
||||
itemBuilder: (context, filter) => AvesFilterChip(
|
||||
filter: filter,
|
||||
allowGenericIcon: false,
|
||||
),
|
||||
maxTotalOptionCount: FilterQuickChooserMixin.maxTotalOptionCount,
|
||||
itemHeight: computeItemHeight(context),
|
||||
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,
|
||||
});
|
||||
|
||||
static const leadingPadding = EdgeInsetsDirectional.only(end: 12);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
|
@ -17,7 +19,7 @@ class MenuRow extends StatelessWidget {
|
|||
children: [
|
||||
if (icon != null)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
||||
padding: leadingPadding,
|
||||
child: IconTheme.merge(
|
||||
data: IconThemeData(
|
||||
color: ListTileTheme.of(context).iconColor,
|
||||
|
|
|
@ -55,9 +55,9 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
|
|||
return scrollableBox.size.width;
|
||||
}
|
||||
|
||||
static const double scrollEdgeRatio = .15;
|
||||
static const double scrollMaxPixelPerSecond = 600.0;
|
||||
static const Duration scrollUpdateInterval = Duration(milliseconds: 100);
|
||||
static const double _scrollEdgeRatio = .15;
|
||||
static const double _scrollMaxPixelPerSecond = 600.0;
|
||||
static const Duration _scrollUpdateInterval = Duration(milliseconds: 100);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -158,7 +158,7 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
|
|||
final top = dy < height / 2;
|
||||
|
||||
final distanceToEdge = max(0, top ? dy - _scrollableInsets.top : height - dy - _scrollableInsets.bottom);
|
||||
final threshold = height * scrollEdgeRatio;
|
||||
final threshold = height * _scrollEdgeRatio;
|
||||
if (distanceToEdge < threshold) {
|
||||
_setScrollSpeed((top ? -1 : 1) * roundToPrecision((threshold - distanceToEdge) / threshold, decimals: 1));
|
||||
} else {
|
||||
|
@ -185,7 +185,7 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
|
|||
final target = speedFactor > 0 ? scrollController.position.maxScrollExtent : .0;
|
||||
if (target != current) {
|
||||
final distance = target - current;
|
||||
final millis = distance * 1000 / scrollMaxPixelPerSecond / speedFactor;
|
||||
final millis = distance * 1000 / _scrollMaxPixelPerSecond / speedFactor;
|
||||
scrollController.animateTo(
|
||||
target,
|
||||
duration: Duration(milliseconds: millis.round()),
|
||||
|
@ -193,7 +193,7 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
|
|||
);
|
||||
// use a timer to update the selection, because `onLongPressMoveUpdate`
|
||||
// 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 OffsetFilterCallback? onLongPress;
|
||||
|
||||
static const double defaultPadding = 6.0;
|
||||
static const double defaultRadius = 32;
|
||||
static const double outlineWidth = 2;
|
||||
static const double minChipHeight = kMinInteractiveDimension;
|
||||
|
@ -79,7 +80,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
this.banner,
|
||||
this.leadingOverride,
|
||||
this.details,
|
||||
this.padding = 6.0,
|
||||
this.padding = defaultPadding,
|
||||
this.maxWidth,
|
||||
this.heroType = HeroType.onTap,
|
||||
this.onTap,
|
||||
|
@ -87,7 +88,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
this.onLongPress = showDefaultLongPressMenu,
|
||||
});
|
||||
|
||||
static double computeMaxWidth(
|
||||
static double computeMaxWidthForRow(
|
||||
BuildContext context, {
|
||||
required int minChipPerRow,
|
||||
required double chipPadding,
|
||||
|
@ -347,7 +348,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
maxWidth: max(
|
||||
AvesFilterChip.minChipWidth,
|
||||
widget.maxWidth ??
|
||||
AvesFilterChip.computeMaxWidth(
|
||||
AvesFilterChip.computeMaxWidthForRow(
|
||||
context,
|
||||
minChipPerRow: 2,
|
||||
chipPadding: FilterBar.chipPadding.horizontal,
|
||||
|
|
Loading…
Reference in a new issue