#35 albums/countries/tags: multiple selection

This commit is contained in:
Thibault Deckers 2021-07-12 12:07:22 +09:00
parent 81ab903d39
commit f2270cfb77
45 changed files with 1262 additions and 842 deletions

View file

@ -272,8 +272,14 @@
"renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists",
"@renameAlbumDialogLabelAlreadyExistsHelper": {},
"deleteAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this album and its item?} other{Are you sure you want to delete this album and its {count} items?}}",
"@deleteAlbumConfirmationDialogMessage": {
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this album and its item?} other{Are you sure you want to delete this album and its {count} items?}}",
"@deleteSingleAlbumConfirmationDialogMessage": {
"placeholders": {
"count": {}
}
},
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete these albums and their item?} other{Are you sure you want to delete these albums and their {count} items?}}",
"@deleteMultiAlbumConfirmationDialogMessage": {
"placeholders": {
"count": {}
}
@ -630,11 +636,11 @@
"@settingsSubtitleThemeBackgroundColor": {},
"settingsSubtitleThemeBackgroundOpacity": "Background opacity",
"@settingsSubtitleThemeBackgroundOpacity": {},
"settingsSubtitleThemeTextAlignmentLeft": "Left",
"settingsSubtitleThemeTextAlignmentLeft": "Left",
"@settingsSubtitleThemeTextAlignmentLeft": {},
"settingsSubtitleThemeTextAlignmentCenter": "Center",
"@settingsSubtitleThemeTextAlignmentCenter": {},
"settingsSubtitleThemeTextAlignmentRight": "Right",
"settingsSubtitleThemeTextAlignmentRight": "Right",
"@settingsSubtitleThemeTextAlignmentRight": {},
"settingsSectionPrivacy": "Privacy",

View file

@ -124,7 +124,8 @@
"renameAlbumDialogLabel": "앨범 이름",
"renameAlbumDialogLabelAlreadyExistsHelper": "사용 중인 이름입니다",
"deleteAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}",
"renameEntryDialogLabel": "이름",
@ -299,9 +300,9 @@
"settingsSubtitleThemeTextOpacity": "글자 투명도",
"settingsSubtitleThemeBackgroundColor": "배경 색상",
"settingsSubtitleThemeBackgroundOpacity": "배경 투명도",
"settingsSubtitleThemeTextAlignmentLeft": "왼쪽",
"settingsSubtitleThemeTextAlignmentLeft": "왼쪽",
"settingsSubtitleThemeTextAlignmentCenter": "가운데",
"settingsSubtitleThemeTextAlignmentRight": "오른쪽",
"settingsSubtitleThemeTextAlignmentRight": "오른쪽",
"settingsSectionPrivacy": "개인정보 보호",
"settingsEnableCrashReport": "오류 보고서 보내기",

View file

@ -2,29 +2,16 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
enum ChipSetAction {
group,
sort,
stats,
}
enum ChipAction {
delete,
hide,
pin,
unpin,
rename,
setCover,
goToAlbumPage,
goToCountryPage,
goToTagPage,
hide,
}
extension ExtraChipAction on ChipAction {
String getText(BuildContext context) {
switch (this) {
case ChipAction.delete:
return context.l10n.chipActionDelete;
case ChipAction.goToAlbumPage:
return context.l10n.chipActionGoToAlbumPage;
case ChipAction.goToCountryPage:
@ -33,21 +20,11 @@ extension ExtraChipAction on ChipAction {
return context.l10n.chipActionGoToTagPage;
case ChipAction.hide:
return context.l10n.chipActionHide;
case ChipAction.pin:
return context.l10n.chipActionPin;
case ChipAction.unpin:
return context.l10n.chipActionUnpin;
case ChipAction.rename:
return context.l10n.chipActionRename;
case ChipAction.setCover:
return context.l10n.chipActionSetCover;
}
}
IconData getIcon() {
switch (this) {
case ChipAction.delete:
return AIcons.delete;
case ChipAction.goToAlbumPage:
return AIcons.album;
case ChipAction.goToCountryPage:
@ -56,13 +33,6 @@ extension ExtraChipAction on ChipAction {
return AIcons.tag;
case ChipAction.hide:
return AIcons.hide;
case ChipAction.pin:
case ChipAction.unpin:
return AIcons.pin;
case ChipAction.rename:
return AIcons.rename;
case ChipAction.setCover:
return AIcons.setCover;
}
}
}

View file

@ -0,0 +1,86 @@
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
enum ChipSetAction {
// general
sort,
group,
select,
selectAll,
selectNone,
stats,
// single/multiple filters
delete,
hide,
pin,
unpin,
// single filter
rename,
setCover,
}
extension ExtraChipSetAction on ChipSetAction {
String getText(BuildContext context) {
switch (this) {
// general
case ChipSetAction.sort:
return context.l10n.menuActionSort;
case ChipSetAction.group:
return context.l10n.menuActionGroup;
case ChipSetAction.select:
return context.l10n.collectionActionSelect;
case ChipSetAction.selectAll:
return context.l10n.collectionActionSelectAll;
case ChipSetAction.selectNone:
return context.l10n.collectionActionSelectNone;
case ChipSetAction.stats:
return context.l10n.menuActionStats;
// single/multiple filters
case ChipSetAction.delete:
return context.l10n.chipActionDelete;
case ChipSetAction.hide:
return context.l10n.chipActionHide;
case ChipSetAction.pin:
return context.l10n.chipActionPin;
case ChipSetAction.unpin:
return context.l10n.chipActionUnpin;
// single filter
case ChipSetAction.rename:
return context.l10n.chipActionRename;
case ChipSetAction.setCover:
return context.l10n.chipActionSetCover;
}
}
IconData? getIcon() {
switch (this) {
// general
case ChipSetAction.sort:
return AIcons.sort;
case ChipSetAction.group:
return AIcons.group;
case ChipSetAction.select:
return AIcons.select;
case ChipSetAction.selectAll:
case ChipSetAction.selectNone:
return null;
case ChipSetAction.stats:
return AIcons.stats;
// single/multiple filters
case ChipSetAction.delete:
return AIcons.delete;
case ChipSetAction.hide:
return AIcons.hide;
case ChipSetAction.pin:
return AIcons.pin;
case ChipSetAction.unpin:
return AIcons.unpin;
// single filter
case ChipSetAction.rename:
return AIcons.rename;
case ChipSetAction.setCover:
return AIcons.setCover;
}
}
}

View file

@ -275,13 +275,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return recentEntry(filter);
}
void changeFilterVisibility(CollectionFilter filter, bool visible) {
void changeFilterVisibility(Set<CollectionFilter> filters, bool visible) {
final hiddenFilters = settings.hiddenFilters;
if (visible) {
hiddenFilters.remove(filter);
hiddenFilters.removeAll(filters);
} else {
hiddenFilters.add(filter);
settings.searchHistory = settings.searchHistory..remove(filter);
hiddenFilters.addAll(filters);
settings.searchHistory = settings.searchHistory..removeWhere(filters.contains);
}
settings.hiddenFilters = hiddenFilters;
@ -292,10 +292,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
updateLocations();
updateTags();
eventBus.fire(FilterVisibilityChangedEvent(filter, visible));
eventBus.fire(FilterVisibilityChangedEvent(filters, visible));
if (visible) {
refreshMetadata(visibleEntries.where(filter.test).toSet());
refreshMetadata(visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet());
}
}
}
@ -319,10 +319,10 @@ class EntryMovedEvent {
}
class FilterVisibilityChangedEvent {
final CollectionFilter filter;
final Set<CollectionFilter> filters;
final bool visible;
const FilterVisibilityChangedEvent(this.filter, this.visible);
const FilterVisibilityChangedEvent(this.filters, this.visible);
}
class ProgressEvent {

View file

@ -50,6 +50,7 @@ class AIcons {
static const IconData layers = Icons.layers_outlined;
static const IconData openOutside = Icons.open_in_new_outlined;
static const IconData pin = Icons.push_pin_outlined;
static const IconData unpin = MdiIcons.pinOffOutline;
static const IconData play = Icons.play_arrow;
static const IconData pause = Icons.pause;
static const IconData print = Icons.print_outlined;

View file

@ -46,7 +46,6 @@ class CollectionAppBar extends StatefulWidget {
}
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
final TextEditingController _searchFieldController = TextEditingController();
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
late AnimationController _browseToSelectAnimation;
late Future<bool> _canAddShortcutsLoader;
@ -83,7 +82,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
_unregisterWidget(widget);
_isSelectingNotifier.removeListener(_onActivityChange);
_browseToSelectAnimation.dispose();
_searchFieldController.dispose();
super.dispose();
}
@ -271,7 +269,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
_browseToSelectAnimation.forward();
} else {
_browseToSelectAnimation.reverse();
_searchFieldController.clear();
}
}
@ -370,13 +367,14 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
void _goToSearch() {
Navigator.push(
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
source: collection.source,
parentCollection: collection,
),
));
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
source: collection.source,
parentCollection: collection,
),
),
);
}
void _goToStats() {

View file

@ -14,7 +14,6 @@ import 'package:aves/widgets/collection/draggable_thumb_label.dart';
import 'package:aves/widgets/collection/grid/section_layout.dart';
import 'package:aves/widgets/collection/grid/thumbnail.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/collection/thumbnail/theme.dart';
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
@ -23,6 +22,7 @@ import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/item_tracker.dart';
import 'package:aves/widgets/common/grid/selector.dart';
import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart';
@ -79,7 +79,7 @@ class _CollectionGridContent extends StatelessWidget {
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, tileExtent, child) {
return ThumbnailTheme(
return GridTheme(
extent: tileExtent,
child: Selector<TileExtentController, Tuple3<double, int, double>>(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
@ -173,7 +173,7 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent
final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
final selector = GridSelectionGestureDetector(
selectable: isMainMode,
entries: collection.sortedEntries,
items: collection.sortedEntries,
scrollController: scrollController,
appBarHeightNotifier: appBarHeightNotifier,
child: scaler,
@ -210,14 +210,11 @@ class _CollectionScaler extends StatelessWidget {
),
child: child,
),
scaledBuilder: (entry, extent) => ThumbnailTheme(
extent: extent,
child: DecoratedThumbnail(
entry: entry,
tileExtent: context.read<TileExtentController>().effectiveExtentMax,
selectable: false,
highlightable: false,
),
scaledBuilder: (entry, extent) => DecoratedThumbnail(
entry: entry,
tileExtent: context.read<TileExtentController>().effectiveExtentMax,
selectable: false,
highlightable: false,
),
child: child,
);

View file

@ -1,3 +1,4 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/icons.dart';
@ -33,7 +34,7 @@ class AlbumSectionHeader extends StatelessWidget {
);
}
}
return SectionHeader(
return SectionHeader<AvesEntry>(
sectionKey: EntryAlbumSectionKey(directory),
leading: albumIcon,
title: albumName ?? context.l10n.sectionUnknown,

View file

@ -1,5 +1,6 @@
import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
@ -39,9 +40,9 @@ class CollectionSectionHeader extends StatelessWidget {
case EntryGroupFactor.album:
return _buildAlbumHeader(context);
case EntryGroupFactor.month:
return MonthSectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date);
return MonthSectionHeader<AvesEntry>(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date);
case EntryGroupFactor.day:
return DaySectionHeader(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date);
return DaySectionHeader<AvesEntry>(key: ValueKey(sectionKey), date: (sectionKey as EntryDateSectionKey).date);
case EntryGroupFactor.none:
break;
}

View file

@ -5,7 +5,7 @@ import 'package:aves/widgets/common/grid/header.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class DaySectionHeader extends StatelessWidget {
class DaySectionHeader<T> extends StatelessWidget {
final DateTime? date;
const DaySectionHeader({
@ -45,14 +45,14 @@ class DaySectionHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SectionHeader(
return SectionHeader<T>(
sectionKey: EntryDateSectionKey(date),
title: _formatDate(context, date),
);
}
}
class MonthSectionHeader extends StatelessWidget {
class MonthSectionHeader<T> extends StatelessWidget {
final DateTime? date;
const MonthSectionHeader({
@ -71,7 +71,7 @@ class MonthSectionHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SectionHeader(
return SectionHeader<T>(
sectionKey: EntryDateSectionKey(date),
title: _formatDate(context, date),
);

View file

@ -3,6 +3,7 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/collection/thumbnail/image.dart';
import 'package:aves/widgets/collection/thumbnail/overlay.dart';
import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/grid/overlay.dart';
import 'package:flutter/material.dart';
class DecoratedThumbnail extends StatelessWidget {
@ -46,7 +47,7 @@ class DecoratedThumbnail extends StatelessWidget {
children: [
child,
if (!isSvg) ThumbnailEntryOverlay(entry: entry),
if (selectable) ThumbnailSelectionOverlay(entry: entry),
if (selectable) GridItemSelectionOverlay(item: entry),
if (highlightable) ThumbnailHighlightOverlay(entry: entry),
],
);

View file

@ -2,11 +2,8 @@ import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/thumbnail/theme.dart';
import 'package:aves/widgets/common/fx/sweeper.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/identity/aves_icons.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -22,7 +19,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
@override
Widget build(BuildContext context) {
final children = [
if (entry.hasGps && context.select<ThumbnailThemeData, bool>((t) => t.showLocation)) const GpsIcon(),
if (entry.hasGps && context.select<GridThemeData, bool>((t) => t.showLocation)) const GpsIcon(),
if (entry.isVideo)
VideoIcon(
entry: entry,
@ -30,7 +27,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
else if (entry.isAnimated)
const AnimatedImageIcon()
else ...[
if (entry.isRaw && context.select<ThumbnailThemeData, bool>((t) => t.showRaw)) const RawIcon(),
if (entry.isRaw && context.select<GridThemeData, bool>((t) => t.showRaw)) const RawIcon(),
if (entry.isMultiPage) MultiPageIcon(entry: entry),
if (entry.isGeotiff) const GeotiffIcon(),
if (entry.is360) const SphericalImageIcon(),
@ -46,57 +43,6 @@ class ThumbnailEntryOverlay extends StatelessWidget {
}
}
class ThumbnailSelectionOverlay extends StatelessWidget {
final AvesEntry entry;
static const duration = Durations.thumbnailOverlayAnimation;
const ThumbnailSelectionOverlay({
Key? key,
required this.entry,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isSelecting = context.select<Selection<AvesEntry>, bool>((selection) => selection.isSelecting);
final child = isSelecting
? Selector<Selection<AvesEntry>, bool>(
selector: (context, selection) => selection.isSelected([entry]),
builder: (context, isSelected, child) {
var child = isSelecting
? OverlayIcon(
key: ValueKey(isSelected),
icon: isSelected ? AIcons.selected : AIcons.unselected,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
)
: const SizedBox.shrink();
child = AnimatedSwitcher(
duration: duration,
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeOutBack,
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: child,
),
child: child,
);
child = AnimatedContainer(
duration: duration,
alignment: AlignmentDirectional.topEnd,
color: isSelected ? Colors.black54 : Colors.transparent,
child: child,
);
return child;
},
)
: const SizedBox.shrink();
return AnimatedSwitcher(
duration: duration,
child: child,
);
}
}
class ThumbnailHighlightOverlay extends StatefulWidget {
final AvesEntry entry;
@ -125,7 +71,7 @@ class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide(
color: Theme.of(context).accentColor,
width: context.select<ThumbnailThemeData, double>((t) => t.highlightBorderWidth),
width: context.select<GridThemeData, double>((t) => t.highlightBorderWidth),
)),
),
),

View file

@ -1,16 +1,15 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart';
class SectionHeader extends StatelessWidget {
class SectionHeader<T> extends StatelessWidget {
final SectionKey sectionKey;
final Widget? leading, trailing;
final String title;
@ -44,7 +43,7 @@ class SectionHeader extends StatelessWidget {
children: [
WidgetSpan(
alignment: widgetSpanAlignment,
child: _SectionSelectableLeading(
child: _SectionSelectableLeading<T>(
selectable: selectable,
sectionKey: sectionKey,
browsingBuilder: leading != null
@ -78,9 +77,8 @@ class SectionHeader extends StatelessWidget {
}
void _toggleSectionSelection(BuildContext context) {
final collection = context.read<CollectionLens>();
final sectionEntries = collection.sections[sectionKey]!;
final selection = context.read<Selection<AvesEntry>>();
final sectionEntries = context.read<SectionedListLayout<T>>().sections[sectionKey]!;
final selection = context.read<Selection<T>>();
final isSelected = selection.isSelected(sectionEntries);
if (isSelected) {
selection.removeFromSelection(sectionEntries);
@ -124,7 +122,7 @@ class SectionHeader extends StatelessWidget {
}
}
class _SectionSelectableLeading extends StatelessWidget {
class _SectionSelectableLeading<T> extends StatelessWidget {
final bool selectable;
final SectionKey sectionKey;
final WidgetBuilder? browsingBuilder;
@ -144,9 +142,9 @@ class _SectionSelectableLeading extends StatelessWidget {
Widget build(BuildContext context) {
if (!selectable) return _buildBrowsing(context);
final isSelecting = context.select<Selection<AvesEntry>, bool>((selection) => selection.isSelecting);
final isSelecting = context.select<Selection<T>, bool>((selection) => selection.isSelecting);
final Widget child = isSelecting
? _SectionSelectingLeading(
? _SectionSelectingLeading<T>(
sectionKey: sectionKey,
onPressed: onPressed,
)
@ -179,7 +177,7 @@ class _SectionSelectableLeading extends StatelessWidget {
Widget _buildBrowsing(BuildContext context) => browsingBuilder?.call(context) ?? const SizedBox(height: leadingDimension);
}
class _SectionSelectingLeading extends StatelessWidget {
class _SectionSelectingLeading<T> extends StatelessWidget {
final SectionKey sectionKey;
final VoidCallback? onPressed;
@ -191,8 +189,8 @@ class _SectionSelectingLeading extends StatelessWidget {
@override
Widget build(BuildContext context) {
final sectionEntries = context.watch<CollectionLens>().sections[sectionKey]!;
final selection = context.watch<Selection<AvesEntry>>();
final sectionEntries = context.watch<SectionedListLayout<T>>().sections[sectionKey]!;
final selection = context.watch<Selection<T>>();
final isSelected = selection.isSelected(sectionEntries);
return AnimatedSwitcher(
duration: Durations.sectionHeaderAnimation,

View file

@ -0,0 +1,66 @@
import 'package:aves/model/selection.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/identity/aves_icons.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class GridItemSelectionOverlay<T> extends StatelessWidget {
final T item;
final BorderRadius? borderRadius;
final EdgeInsets? padding;
static const duration = Durations.thumbnailOverlayAnimation;
const GridItemSelectionOverlay({
Key? key,
required this.item,
this.borderRadius,
this.padding,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isSelecting = context.select<Selection<T>, bool>((selection) => selection.isSelecting);
final child = isSelecting
? Selector<Selection<T>, bool>(
selector: (context, selection) => selection.isSelected([item]),
builder: (context, isSelected, child) {
var child = isSelecting
? OverlayIcon(
key: ValueKey(isSelected),
icon: isSelected ? AIcons.selected : AIcons.unselected,
size: context.select<GridThemeData, double>((t) => t.iconSize),
)
: const SizedBox.shrink();
child = AnimatedSwitcher(
duration: duration,
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeOutBack,
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: child,
),
child: child,
);
child = AnimatedContainer(
duration: duration,
alignment: AlignmentDirectional.topEnd,
padding: padding,
decoration: BoxDecoration(
color: isSelected ? Colors.black54 : Colors.transparent,
borderRadius: borderRadius,
),
child: child,
);
return child;
},
)
: const SizedBox.shrink();
return AnimatedSwitcher(
duration: duration,
child: child,
);
}
}

View file

@ -11,7 +11,7 @@ import 'package:provider/provider.dart';
class GridSelectionGestureDetector<T> extends StatefulWidget {
final bool selectable;
final List<T> entries;
final List<T> items;
final ScrollController scrollController;
final ValueNotifier<double> appBarHeightNotifier;
final Widget child;
@ -19,7 +19,7 @@ class GridSelectionGestureDetector<T> extends StatefulWidget {
const GridSelectionGestureDetector({
Key? key,
this.selectable = true,
required this.entries,
required this.items,
required this.scrollController,
required this.appBarHeightNotifier,
required this.child,
@ -37,7 +37,7 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
late double _scrollSpeedFactor;
Timer? _updateTimer;
List<T> get entries => widget.entries;
List<T> get items => widget.items;
ScrollController get scrollController => widget.scrollController;
@ -49,16 +49,17 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
@override
Widget build(BuildContext context) {
final selectable = widget.selectable;
return GestureDetector(
onLongPressStart: widget.selectable
onLongPressStart: selectable
? (details) {
final fromEntry = _getEntryAt(details.localPosition);
if (fromEntry == null) return;
final fromItem = _getItemAt(details.localPosition);
if (fromItem == null) return;
final selection = context.read<Selection<T>>();
selection.toggleSelection(fromEntry);
_selecting = selection.isSelected([fromEntry]);
_fromIndex = entries.indexOf(fromEntry);
selection.toggleSelection(fromItem);
_selecting = selection.isSelected([fromItem]);
_fromIndex = items.indexOf(fromItem);
_lastToIndex = _fromIndex;
_scrollableInsets = EdgeInsets.only(
top: appBarHeight,
@ -68,20 +69,29 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
_pressing = true;
}
: null,
onLongPressMoveUpdate: widget.selectable
onLongPressMoveUpdate: selectable
? (details) {
if (!_pressing) return;
_localPosition = details.localPosition;
_onLongPressUpdate();
}
: null,
onLongPressEnd: widget.selectable
onLongPressEnd: selectable
? (details) {
if (!_pressing) return;
_setScrollSpeed(0);
_pressing = false;
}
: null,
onTapUp: selectable && context.select<Selection<T>, bool>((selection) => selection.isSelecting)
? (details) {
final item = _getItemAt(details.localPosition);
if (item == null) return;
final selection = context.read<Selection<T>>();
selection.toggleSelection(item);
}
: null,
child: widget.child,
);
}
@ -100,9 +110,9 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
_setScrollSpeed(0);
}
final toEntry = _getEntryAt(_localPosition);
if (toEntry != null) {
_toggleSelectionToIndex(entries.indexOf(toEntry));
final toItem = _getItemAt(_localPosition);
if (toItem != null) {
_toggleSelectionToIndex(items.indexOf(toItem));
}
}
@ -126,16 +136,16 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
duration: Duration(milliseconds: millis.round()),
curve: Curves.linear,
);
// use a timer to update the entry 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
_updateTimer = Timer.periodic(scrollUpdateInterval, (_) => _onLongPressUpdate());
}
}
T? _getEntryAt(Offset localPosition) {
T? _getItemAt(Offset localPosition) {
// as of Flutter v1.22.5, `hitTest` on the `ScrollView` render object works fine when it is static,
// but when it is scrolling (through controller animation), result is incomplete and children are missing,
// so we use custom layout computation instead to find the entry.
// so we use custom layout computation instead to find the item.
final offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
final sectionedListLayout = context.read<SectionedListLayout<T>>();
return sectionedListLayout.getItemAt(offset);
@ -148,26 +158,26 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
if (_selecting) {
if (toIndex <= _fromIndex) {
if (toIndex < _lastToIndex) {
selection.addToSelection(entries.getRange(toIndex, min(_fromIndex, _lastToIndex)));
selection.addToSelection(items.getRange(toIndex, min(_fromIndex, _lastToIndex)));
if (_fromIndex < _lastToIndex) {
selection.removeFromSelection(entries.getRange(_fromIndex + 1, _lastToIndex + 1));
selection.removeFromSelection(items.getRange(_fromIndex + 1, _lastToIndex + 1));
}
} else if (_lastToIndex < toIndex) {
selection.removeFromSelection(entries.getRange(_lastToIndex, toIndex));
selection.removeFromSelection(items.getRange(_lastToIndex, toIndex));
}
} else if (_fromIndex < toIndex) {
if (_lastToIndex < toIndex) {
selection.addToSelection(entries.getRange(max(_fromIndex, _lastToIndex), toIndex + 1));
selection.addToSelection(items.getRange(max(_fromIndex, _lastToIndex), toIndex + 1));
if (_lastToIndex < _fromIndex) {
selection.removeFromSelection(entries.getRange(_lastToIndex, _fromIndex));
selection.removeFromSelection(items.getRange(_lastToIndex, _fromIndex));
}
} else if (toIndex < _lastToIndex) {
selection.removeFromSelection(entries.getRange(toIndex + 1, _lastToIndex + 1));
selection.removeFromSelection(items.getRange(toIndex + 1, _lastToIndex + 1));
}
}
_lastToIndex = toIndex;
} else {
selection.removeFromSelection(entries.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1));
selection.removeFromSelection(items.getRange(min(_fromIndex, toIndex), max(_fromIndex, toIndex) + 1));
}
}
}

View file

@ -4,12 +4,12 @@ import 'package:aves/model/settings/settings.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ThumbnailTheme extends StatelessWidget {
class GridTheme extends StatelessWidget {
final double extent;
final bool? showLocation;
final Widget child;
const ThumbnailTheme({
const GridTheme({
Key? key,
required this.extent,
this.showLocation,
@ -18,12 +18,12 @@ class ThumbnailTheme extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ProxyProvider<Settings, ThumbnailThemeData>(
return ProxyProvider<Settings, GridThemeData>(
update: (_, settings, __) {
final iconSize = min(28.0, (extent / 4)).roundToDouble();
final fontSize = (iconSize / 2).floorToDouble();
final highlightBorderWidth = extent * .1;
return ThumbnailThemeData(
return GridThemeData(
iconSize: iconSize,
fontSize: fontSize,
highlightBorderWidth: highlightBorderWidth,
@ -37,11 +37,11 @@ class ThumbnailTheme extends StatelessWidget {
}
}
class ThumbnailThemeData {
class GridThemeData {
final double iconSize, fontSize, highlightBorderWidth;
final bool showLocation, showRaw, showVideoDuration;
const ThumbnailThemeData({
const GridThemeData({
required this.iconSize,
required this.fontSize,
required this.highlightBorderWidth,

View file

@ -8,7 +8,7 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
@ -209,48 +209,44 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
borderRadius: borderRadius,
child: widget.background,
),
Tooltip(
message: filter.getTooltip(context),
preferBelow: false,
child: Material(
color: hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor,
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
),
child: InkWell(
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
// so we get the long press details from the tap instead
onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null,
onTap: onTap != null
? () {
WidgetsBinding.instance!.addPostFrameCallback((_) => onTap!(filter));
setState(() => _tapped = true);
}
: null,
onLongPress: onLongPress != null ? () => onLongPress!(context, filter, _tapPosition!) : null,
borderRadius: borderRadius,
child: FutureBuilder<Color>(
future: _colorFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
_outlineColor = snapshot.data!;
Material(
color: hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor,
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
),
child: InkWell(
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
// so we get the long press details from the tap instead
onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null,
onTap: onTap != null
? () {
WidgetsBinding.instance!.addPostFrameCallback((_) => onTap!(filter));
setState(() => _tapped = true);
}
return DecoratedBox(
decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide(
color: _outlineColor,
width: AvesFilterChip.outlineWidth,
)),
borderRadius: borderRadius,
),
position: DecorationPosition.foreground,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: content,
),
);
},
),
: null,
onLongPress: onLongPress != null ? () => onLongPress!(context, filter, _tapPosition!) : null,
borderRadius: borderRadius,
child: FutureBuilder<Color>(
future: _colorFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
_outlineColor = snapshot.data!;
}
return DecoratedBox(
decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide(
color: _outlineColor,
width: AvesFilterChip.outlineWidth,
)),
borderRadius: borderRadius,
),
position: DecorationPosition.foreground,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: content,
),
);
},
),
),
),

View file

@ -5,7 +5,7 @@ import 'package:aves/model/entry.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/collection/thumbnail/theme.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@ -20,11 +20,11 @@ class VideoIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
final thumbnailTheme = context.watch<ThumbnailThemeData>();
final showDuration = thumbnailTheme.showVideoDuration;
final gridTheme = context.watch<GridThemeData>();
final showDuration = gridTheme.showVideoDuration;
Widget child = OverlayIcon(
icon: entry.is360 ? AIcons.threeSixty : AIcons.videoThumb,
size: thumbnailTheme.iconSize,
size: gridTheme.iconSize,
text: showDuration ? entry.durationText : null,
iconScale: entry.is360 && showDuration ? .9 : 1,
);
@ -32,7 +32,7 @@ class VideoIcon extends StatelessWidget {
child = DefaultTextStyle(
style: TextStyle(
color: Colors.grey.shade200,
fontSize: thumbnailTheme.fontSize,
fontSize: gridTheme.fontSize,
),
child: child,
);
@ -48,7 +48,7 @@ class AnimatedImageIcon extends StatelessWidget {
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.animated,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
size: context.select<GridThemeData, double>((t) => t.iconSize),
iconScale: .8,
);
}
@ -61,7 +61,7 @@ class GeotiffIcon extends StatelessWidget {
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.geo,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
size: context.select<GridThemeData, double>((t) => t.iconSize),
);
}
}
@ -73,7 +73,7 @@ class SphericalImageIcon extends StatelessWidget {
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.threeSixty,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
size: context.select<GridThemeData, double>((t) => t.iconSize),
);
}
}
@ -85,7 +85,7 @@ class GpsIcon extends StatelessWidget {
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.location,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
size: context.select<GridThemeData, double>((t) => t.iconSize),
);
}
}
@ -97,7 +97,7 @@ class RawIcon extends StatelessWidget {
Widget build(BuildContext context) {
return OverlayIcon(
icon: AIcons.raw,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
size: context.select<GridThemeData, double>((t) => t.iconSize),
);
}
}
@ -114,7 +114,7 @@ class MultiPageIcon extends StatelessWidget {
Widget build(BuildContext context) {
return OverlayIcon(
icon: entry.isMotionPhoto ? AIcons.motionPhoto : AIcons.multiPage,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize),
size: context.select<GridThemeData, double>((t) => t.iconSize),
iconScale: .8,
);
}

View file

@ -76,8 +76,6 @@ class AvesLogoPainter extends CustomPainter {
path3.relativeArcToPoint(Offset(dim * 1.917, dim * -4.63), radius: Radius.circular(dim * 2.712), rotation: 112.5, clockwise: false);
path3.close();
canvas.drawPath(
path0,
Paint()

View file

@ -2,6 +2,7 @@ import 'dart:ui' as ui;
import 'package:aves/model/highlight.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:collection/collection.dart';
@ -79,7 +80,10 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
builder: (extent) => SizedBox(
width: extent,
height: extent,
child: widget.scaledBuilder(_metadata!.item, extent),
child: GridTheme(
extent: extent,
child: widget.scaledBuilder(_metadata!.item, extent),
),
),
center: thumbnailCenter,
viewportWidth: gridWidth,

View file

@ -7,7 +7,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/item_pick_dialog.dart';
import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
@ -89,7 +89,7 @@ class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
Container(
alignment: Alignment.center,
padding: const EdgeInsets.only(bottom: 16),
child: DecoratedFilterChip(
child: CoveredFilterChip(
filter: filter,
extent: extent,
coverEntry: _isCustom ? _customEntry : _recentEntry,

View file

@ -1,6 +1,7 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/actions/chip_set_actions.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_source.dart';
@ -12,9 +13,10 @@ import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/basic/query_bar.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/dialogs/create_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -46,37 +48,41 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
@override
Widget build(BuildContext context) {
Widget appBar = AlbumPickAppBar(
source: source,
moveType: widget.moveType,
actionDelegate: AlbumChipSetActionDelegate(),
queryNotifier: _queryNotifier,
);
return Selector<Settings, Tuple2<AlbumChipGroupFactor, ChipSortFactor>>(
selector: (context, s) => Tuple2(s.albumGroupFactor, s.albumSortFactor),
builder: (context, s, child) {
return StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) => FilterGridPage<AlbumFilter>(
settingsRouteKey: AlbumListPage.routeName,
appBar: appBar,
appBarHeight: AlbumPickAppBar.preferredHeight,
filterSections: AlbumListPage.getAlbumEntries(context, source),
sortFactor: settings.albumSortFactor,
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
queryNotifier: _queryNotifier,
applyQuery: (filters, query) {
if (query.isEmpty) return filters;
query = query.toUpperCase();
return filters.where((item) => (item.filter.displayName ?? item.filter.album).toUpperCase().contains(query)).toList();
},
emptyBuilder: () => EmptyContent(
icon: AIcons.album,
text: context.l10n.albumEmpty,
),
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter).album),
),
builder: (context, snapshot) {
final gridItems = AlbumListPage.getAlbumGridItems(context, source);
return SelectionProvider<FilterGridItem<AlbumFilter>>(
child: FilterGridPage<AlbumFilter>(
settingsRouteKey: AlbumListPage.routeName,
appBar: AlbumPickAppBar(
source: source,
moveType: widget.moveType,
actionDelegate: AlbumChipSetActionDelegate(gridItems),
queryNotifier: _queryNotifier,
),
appBarHeight: AlbumPickAppBar.preferredHeight,
sections: AlbumListPage.groupToSections(context, gridItems),
sortFactor: settings.albumSortFactor,
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
selectable: false,
queryNotifier: _queryNotifier,
applyQuery: (filters, query) {
if (query.isEmpty) return filters;
query = query.toUpperCase();
return filters.where((item) => (item.filter.displayName ?? item.filter.album).toUpperCase().contains(query)).toList();
},
emptyBuilder: () => EmptyContent(
icon: AIcons.album,
text: context.l10n.albumEmpty,
),
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter).album),
),
);
},
);
},
);
@ -156,7 +162,7 @@ class AlbumPickAppBar extends StatelessWidget {
FocusManager.instance.primaryFocus?.unfocus();
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, action));
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => actionDelegate.onActionSelected(context, {}, action));
},
),
],

View file

@ -1,4 +1,3 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart';
@ -9,8 +8,7 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart';
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:collection/collection.dart';
@ -38,32 +36,22 @@ class AlbumListPage extends StatelessWidget {
animation: androidFileUtils.appNameChangeNotifier,
builder: (context, child) => StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage<AlbumFilter>(
source: source,
title: context.l10n.albumPageTitle,
sortFactor: settings.albumSortFactor,
groupable: true,
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
chipSetActionDelegate: AlbumChipSetActionDelegate(),
chipActionDelegate: AlbumChipActionDelegate(),
chipActionsBuilder: (filter) {
final dir = VolumeRelativeDirectory.fromPath(filter.album);
// do not allow renaming volume root
final canRename = dir != null && dir.relativeDir.isNotEmpty;
return [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.setCover,
if (canRename) ChipAction.rename,
ChipAction.delete,
ChipAction.hide,
];
},
filterSections: getAlbumEntries(context, source),
emptyBuilder: () => EmptyContent(
icon: AIcons.album,
text: context.l10n.albumEmpty,
),
),
builder: (context, snapshot) {
final gridItems = getAlbumGridItems(context, source);
return FilterNavigationPage<AlbumFilter>(
source: source,
title: context.l10n.albumPageTitle,
sortFactor: settings.albumSortFactor,
groupable: true,
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
actionDelegate: AlbumChipSetActionDelegate(gridItems),
filterSections: groupToSections(context, gridItems),
emptyBuilder: () => EmptyContent(
icon: AIcons.album,
text: context.l10n.albumEmpty,
),
);
},
),
);
},
@ -72,14 +60,13 @@ class AlbumListPage extends StatelessWidget {
// common with album selection page to move/copy entries
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> getAlbumEntries(BuildContext context, CollectionSource source) {
static List<FilterGridItem<AlbumFilter>> getAlbumGridItems(BuildContext context, CollectionSource source) {
final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getAlbumDisplayName(context, album))).toSet();
final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
return _group(context, sorted);
return FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
}
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> _group(BuildContext context, Iterable<FilterGridItem<AlbumFilter>> sortedMapEntries) {
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> groupToSections(BuildContext context, Iterable<FilterGridItem<AlbumFilter>> sortedMapEntries) {
final pinned = settings.pinnedFilters.whereType<AlbumFilter>();
final byPin = groupBy<FilterGridItem<AlbumFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
final pinnedMapEntries = byPin[true] ?? [];

View file

@ -1,143 +1,112 @@
import 'dart:io';
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/actions/chip_set_actions.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/cover_selection_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/dialogs/rename_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:collection/collection.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class ChipActionDelegate {
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
switch (action) {
case ChipAction.pin:
settings.pinnedFilters = settings.pinnedFilters..add(filter);
break;
case ChipAction.unpin:
settings.pinnedFilters = settings.pinnedFilters..remove(filter);
break;
case ChipAction.hide:
_hide(context, filter);
break;
case ChipAction.setCover:
_showCoverSelectionDialog(context, filter);
break;
case ChipAction.goToAlbumPage:
_goTo(context, filter, AlbumListPage.routeName, (context) => const AlbumListPage());
break;
case ChipAction.goToCountryPage:
_goTo(context, filter, CountryListPage.routeName, (context) => const CountryListPage());
break;
case ChipAction.goToTagPage:
_goTo(context, filter, TagListPage.routeName, (context) => const TagListPage());
break;
default:
break;
}
}
class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
final Iterable<FilterGridItem<AlbumFilter>> _items;
Future<void> _hide(BuildContext context, CollectionFilter filter) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AvesDialog(
context: context,
content: Text(context.l10n.hideFilterConfirmationDialogMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.hideButtonLabel),
),
],
);
},
);
if (confirmed == null || !confirmed) return;
AlbumChipSetActionDelegate(Iterable<FilterGridItem<AlbumFilter>> items) : _items = items;
final source = context.read<CollectionSource>();
source.changeFilterVisibility(filter, false);
}
void _showCoverSelectionDialog(BuildContext context, CollectionFilter filter) async {
final contentId = covers.coverContentId(filter);
final customEntry = context.read<CollectionSource>().visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId);
final coverSelection = await showDialog<Tuple2<bool, AvesEntry?>>(
context: context,
builder: (context) => CoverSelectionDialog(
filter: filter,
customEntry: customEntry,
),
);
if (coverSelection == null) return;
final isCustom = coverSelection.item1;
await covers.set(filter, isCustom ? coverSelection.item2?.contentId : null);
}
void _goTo(
BuildContext context,
CollectionFilter filter,
String routeName,
WidgetBuilder pageBuilder,
) {
context.read<HighlightInfo>().set(filter);
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
settings: RouteSettings(name: routeName),
builder: pageBuilder,
),
(route) => false,
);
}
}
class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
@override
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
super.onActionSelected(context, filter, action);
Iterable<FilterGridItem<AlbumFilter>> get allItems => _items;
@override
ChipSortFactor get sortFactor => settings.albumSortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor;
@override
bool isValid(Set<AlbumFilter> filters, ChipSetAction action) {
switch (action) {
case ChipAction.delete:
_showDeleteDialog(context, filter as AlbumFilter);
case ChipSetAction.delete:
case ChipSetAction.rename:
return true;
default:
return super.isValid(filters, action);
}
}
@override
bool canApply(Set<AlbumFilter> filters, ChipSetAction action) {
switch (action) {
case ChipSetAction.rename:
{
if (filters.length != 1) return false;
// do not allow renaming volume root
final dir = VolumeRelativeDirectory.fromPath(filters.first.album);
return dir != null && dir.relativeDir.isNotEmpty;
}
default:
return super.canApply(filters, action);
}
}
@override
void onActionSelected(BuildContext context, Set<AlbumFilter> filters, ChipSetAction action) {
switch (action) {
// general
case ChipSetAction.group:
_showGroupDialog(context);
break;
case ChipAction.rename:
_showRenameDialog(context, filter as AlbumFilter);
// single/multiple filters
case ChipSetAction.delete:
_showDeleteDialog(context, filters);
break;
// single filter
case ChipSetAction.rename:
_showRenameDialog(context, filters.first);
break;
default:
break;
}
super.onActionSelected(context, filters, action);
}
Future<void> _showDeleteDialog(BuildContext context, AlbumFilter filter) async {
Future<void> _showGroupDialog(BuildContext context) async {
final factor = await showDialog<AlbumChipGroupFactor>(
context: context,
builder: (context) => AvesSelectionDialog<AlbumChipGroupFactor>(
initialValue: settings.albumGroupFactor,
options: {
AlbumChipGroupFactor.importance: context.l10n.albumGroupTier,
AlbumChipGroupFactor.volume: context.l10n.albumGroupVolume,
AlbumChipGroupFactor.none: context.l10n.albumGroupNone,
},
title: context.l10n.albumGroupTitle,
),
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (factor != null) {
settings.albumGroupFactor = factor;
}
}
Future<void> _showDeleteDialog(BuildContext context, Set<AlbumFilter> filters) async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
final source = context.read<CollectionSource>();
final album = filter.album;
final todoEntries = source.visibleEntries.where(filter.test).toSet();
final albums = filters.map((v) => v.album).toSet();
final todoEntries = source.visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet();
final todoCount = todoEntries.length;
final confirmed = await showDialog<bool>(
@ -145,7 +114,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
builder: (context) {
return AvesDialog(
context: context,
content: Text(l10n.deleteAlbumConfirmationDialogMessage(todoCount)),
content: Text(filters.length == 1 ? l10n.deleteSingleAlbumConfirmationDialogMessage(todoCount) : l10n.deleteMultiAlbumConfirmationDialogMessage(todoCount)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
@ -161,7 +130,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
);
if (confirmed == null || !confirmed) return;
if (!await checkStoragePermissionForAlbums(context, {album})) return;
if (!await checkStoragePermissionForAlbums(context, albums)) return;
source.pauseMonitoring();
showOpReport<ImageOpEvent>(
@ -180,7 +149,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
}
// cleanup
await storageService.deleteEmptyDirectories({album});
await storageService.deleteEmptyDirectories(albums);
},
);
}

View file

@ -0,0 +1,75 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ChipActionDelegate {
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
switch (action) {
case ChipAction.hide:
_hide(context, filter);
break;
case ChipAction.goToAlbumPage:
_goTo(context, filter, AlbumListPage.routeName, (context) => const AlbumListPage());
break;
case ChipAction.goToCountryPage:
_goTo(context, filter, CountryListPage.routeName, (context) => const CountryListPage());
break;
case ChipAction.goToTagPage:
_goTo(context, filter, TagListPage.routeName, (context) => const TagListPage());
break;
default:
break;
}
}
Future<void> _hide(BuildContext context, CollectionFilter filter) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AvesDialog(
context: context,
content: Text(context.l10n.hideFilterConfirmationDialogMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.hideButtonLabel),
),
],
);
},
);
if (confirmed == null || !confirmed) return;
final source = context.read<CollectionSource>();
source.changeFilterVisibility({filter}, false);
}
void _goTo(
BuildContext context,
CollectionFilter filter,
String routeName,
WidgetBuilder pageBuilder,
) {
context.read<HighlightInfo>().set(filter);
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
settings: RouteSettings(name: routeName),
builder: pageBuilder,
),
(route) => false,
);
}
}

View file

@ -0,0 +1,180 @@
import 'package:aves/model/actions/chip_set_actions.dart';
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/dialogs/cover_selection_dialog.dart';
import 'package:aves/widgets/stats/stats.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
Iterable<FilterGridItem<T>> get allItems;
ChipSortFactor get sortFactor;
set sortFactor(ChipSortFactor factor);
bool isValid(Set<T> filters, ChipSetAction action) {
final hasSelection = filters.isNotEmpty;
switch (action) {
case ChipSetAction.delete:
case ChipSetAction.rename:
return false;
case ChipSetAction.pin:
return !hasSelection || !settings.pinnedFilters.containsAll(filters);
case ChipSetAction.unpin:
return hasSelection && settings.pinnedFilters.containsAll(filters);
default:
return true;
}
}
bool canApply(Set<T> filters, ChipSetAction action) {
switch (action) {
// general
case ChipSetAction.sort:
case ChipSetAction.group:
case ChipSetAction.select:
case ChipSetAction.selectAll:
case ChipSetAction.selectNone:
case ChipSetAction.stats:
return true;
// single/multiple filters
case ChipSetAction.delete:
case ChipSetAction.hide:
case ChipSetAction.pin:
case ChipSetAction.unpin:
return filters.isNotEmpty;
// single filter
case ChipSetAction.rename:
case ChipSetAction.setCover:
return filters.length == 1;
}
}
void onActionSelected(BuildContext context, Set<T> filters, ChipSetAction action) {
switch (action) {
// general
case ChipSetAction.sort:
_showSortDialog(context);
break;
case ChipSetAction.stats:
_goToStats(context);
break;
case ChipSetAction.select:
context.read<Selection<FilterGridItem<T>>>().select();
break;
case ChipSetAction.selectAll:
context.read<Selection<FilterGridItem<T>>>().addToSelection(allItems);
break;
case ChipSetAction.selectNone:
context.read<Selection<FilterGridItem<T>>>().clearSelection();
break;
// single/multiple filters
case ChipSetAction.pin:
settings.pinnedFilters = settings.pinnedFilters..addAll(filters);
break;
case ChipSetAction.unpin:
settings.pinnedFilters = settings.pinnedFilters..removeAll(filters);
break;
case ChipSetAction.hide:
_hide(context, filters);
break;
// single filter
case ChipSetAction.setCover:
_showCoverSelectionDialog(context, filters.first);
break;
default:
break;
}
}
Future<void> _showSortDialog(BuildContext context) async {
final factor = await showDialog<ChipSortFactor>(
context: context,
builder: (context) => AvesSelectionDialog<ChipSortFactor>(
initialValue: sortFactor,
options: {
ChipSortFactor.date: context.l10n.chipSortDate,
ChipSortFactor.name: context.l10n.chipSortName,
ChipSortFactor.count: context.l10n.chipSortCount,
},
title: context.l10n.chipSortTitle,
),
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (factor != null) {
sortFactor = factor;
}
}
void _goToStats(BuildContext context) {
final source = context.read<CollectionSource>();
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: StatsPage.routeName),
builder: (context) => StatsPage(
source: source,
),
),
);
}
Future<void> _hide(BuildContext context, Set<T> filters) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AvesDialog(
context: context,
content: Text(context.l10n.hideFilterConfirmationDialogMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.hideButtonLabel),
),
],
);
},
);
if (confirmed == null || !confirmed) return;
final source = context.read<CollectionSource>();
source.changeFilterVisibility(filters, false);
}
void _showCoverSelectionDialog(BuildContext context, T filter) async {
final contentId = covers.coverContentId(filter);
final customEntry = context.read<CollectionSource>().visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId);
final coverSelection = await showDialog<Tuple2<bool, AvesEntry?>>(
context: context,
builder: (context) => CoverSelectionDialog(
filter: filter,
customEntry: customEntry,
),
);
if (coverSelection == null) return;
final isCustom = coverSelection.item1;
await covers.set(filter, isCustom ? coverSelection.item2?.contentId : null);
}
}

View file

@ -0,0 +1,20 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
class CountryChipSetActionDelegate extends ChipSetActionDelegate<LocationFilter> {
final Iterable<FilterGridItem<LocationFilter>> _items;
CountryChipSetActionDelegate(Iterable<FilterGridItem<LocationFilter>> items) : _items = items;
@override
Iterable<FilterGridItem<LocationFilter>> get allItems => _items;
@override
ChipSortFactor get sortFactor => settings.countrySortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.countrySortFactor = factor;
}

View file

@ -0,0 +1,20 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
class TagChipSetActionDelegate extends ChipSetActionDelegate<TagFilter> {
final Iterable<FilterGridItem<TagFilter>> _items;
TagChipSetActionDelegate(Iterable<FilterGridItem<TagFilter>> items) : _items = items;
@override
Iterable<FilterGridItem<TagFilter>> get allItems => _items;
@override
ChipSortFactor get sortFactor => settings.tagSortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.tagSortFactor = factor;
}

View file

@ -0,0 +1,239 @@
import 'dart:ui';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/chip_set_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart';
import 'package:aves/widgets/common/app_bar_title.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
import 'package:aves/widgets/search/search_button.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class FilterGridAppBar<T extends CollectionFilter> extends StatefulWidget {
final CollectionSource source;
final String title;
final ChipSetActionDelegate actionDelegate;
final bool groupable, isEmpty;
const FilterGridAppBar({
Key? key,
required this.source,
required this.title,
required this.actionDelegate,
required this.groupable,
required this.isEmpty,
}) : super(key: key);
@override
_FilterGridAppBarState createState() => _FilterGridAppBarState<T>();
}
class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGridAppBar<T>> with SingleTickerProviderStateMixin {
late AnimationController _browseToSelectAnimation;
final ValueNotifier<bool> _isSelectingNotifier = ValueNotifier(false);
CollectionSource get source => widget.source;
ChipSetActionDelegate get actionDelegate => widget.actionDelegate;
static const filterSelectionActions = [
ChipSetAction.setCover,
ChipSetAction.pin,
ChipSetAction.unpin,
ChipSetAction.delete,
ChipSetAction.rename,
ChipSetAction.hide,
];
static const buttonActionCount = 2;
@override
void initState() {
super.initState();
_browseToSelectAnimation = AnimationController(
duration: Durations.iconAnimation,
vsync: this,
);
_isSelectingNotifier.addListener(_onActivityChange);
}
@override
void dispose() {
_isSelectingNotifier.removeListener(_onActivityChange);
_browseToSelectAnimation.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
final selection = context.watch<Selection<FilterGridItem<T>>>();
final isSelecting = selection.isSelecting;
_isSelectingNotifier.value = isSelecting;
return SliverAppBar(
leading: appMode.hasDrawer ? _buildAppBarLeading(isSelecting) : null,
title: _buildAppBarTitle(isSelecting),
actions: _buildActions(appMode, selection),
titleSpacing: 0,
floating: true,
);
}
Widget _buildAppBarLeading(bool isSelecting) {
VoidCallback? onPressed;
String? tooltip;
if (isSelecting) {
onPressed = () => context.read<Selection<FilterGridItem<T>>>().browse();
tooltip = MaterialLocalizations.of(context).backButtonTooltip;
} else {
onPressed = Scaffold.of(context).openDrawer;
tooltip = MaterialLocalizations.of(context).openAppDrawerTooltip;
}
return IconButton(
key: const Key('appbar-leading-button'),
icon: AnimatedIcon(
icon: AnimatedIcons.menu_arrow,
progress: _browseToSelectAnimation,
),
onPressed: onPressed,
tooltip: tooltip,
);
}
Widget? _buildAppBarTitle(bool isSelecting) {
if (isSelecting) {
return Selector<Selection<FilterGridItem<T>>, int>(
selector: (context, selection) => selection.selection.length,
builder: (context, count, child) => Text(context.l10n.collectionSelectionPageTitle(count)),
);
} else {
final appMode = context.watch<ValueNotifier<AppMode>>().value;
return InteractiveAppBarTitle(
onTap: appMode.canSearch ? _goToSearch : null,
child: SourceStateAwareAppBarTitle(
title: Text(widget.title),
source: source,
),
);
}
}
List<Widget> _buildActions(AppMode appMode, Selection<FilterGridItem<T>> selection) {
final selectedFilters = selection.selection.map((v) => v.filter).toSet();
PopupMenuItem<ChipSetAction> toMenuItem(ChipSetAction action, {bool enabled = true}) {
return PopupMenuItem(
value: action,
enabled: enabled && actionDelegate.canApply(selectedFilters, action),
child: MenuRow(
text: action.getText(context),
icon: action.getIcon(),
),
);
}
void applyAction(ChipSetAction action) {
actionDelegate.onActionSelected(context, selectedFilters, action);
if (filterSelectionActions.contains(action)) {
selection.browse();
}
}
final isSelecting = selection.isSelecting;
final selectionRowActions = <ChipSetAction>[];
final buttonActions = <Widget>[];
if (isSelecting) {
final selectedFilters = selection.selection.map((v) => v.filter).toSet();
final validActions = filterSelectionActions.where((action) => actionDelegate.isValid(selectedFilters, action)).toList();
buttonActions.addAll(validActions.take(buttonActionCount).map(
(action) {
final enabled = actionDelegate.canApply(selectedFilters, action);
return IconButton(
icon: Icon(action.getIcon()),
onPressed: enabled ? () => applyAction(action) : null,
tooltip: action.getText(context),
);
},
));
selectionRowActions.addAll(validActions.skip(buttonActionCount));
} else if (appMode.canSearch) {
buttonActions.add(CollectionSearchButton(source: source));
}
return [
...buttonActions,
PopupMenuButton<ChipSetAction>(
key: const Key('appbar-menu-button'),
itemBuilder: (context) {
final menuItems = <PopupMenuEntry<ChipSetAction>>[
toMenuItem(ChipSetAction.sort),
if (widget.groupable) toMenuItem(ChipSetAction.group),
];
if (isSelecting) {
final selectedItems = selection.selection;
if (selectionRowActions.isNotEmpty) {
menuItems.add(const PopupMenuDivider());
menuItems.addAll(selectionRowActions.map(toMenuItem));
}
menuItems.addAll([
const PopupMenuDivider(),
toMenuItem(
ChipSetAction.selectAll,
enabled: selectedItems.length < actionDelegate.allItems.length,
),
toMenuItem(
ChipSetAction.selectNone,
enabled: selectedItems.isNotEmpty,
),
]);
} else if (appMode == AppMode.main) {
menuItems.addAll([
toMenuItem(
ChipSetAction.select,
enabled: !widget.isEmpty,
),
toMenuItem(ChipSetAction.stats),
]);
}
return menuItems;
},
onSelected: (action) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => applyAction(action));
},
),
];
}
void _onActivityChange() {
if (context.read<Selection<FilterGridItem<T>>>().isSelecting) {
_browseToSelectAnimation.forward();
} else {
_browseToSelectAnimation.reverse();
}
}
void _goToSearch() {
Navigator.push(
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
source: source,
),
),
);
}
}

View file

@ -1,119 +0,0 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/stats/stats.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
abstract class ChipSetActionDelegate {
ChipSortFactor get sortFactor;
set sortFactor(ChipSortFactor factor);
void onActionSelected(BuildContext context, ChipSetAction action) {
switch (action) {
case ChipSetAction.sort:
_showSortDialog(context);
break;
case ChipSetAction.stats:
_goToStats(context);
break;
default:
break;
}
}
Future<void> _showSortDialog(BuildContext context) async {
final factor = await showDialog<ChipSortFactor>(
context: context,
builder: (context) => AvesSelectionDialog<ChipSortFactor>(
initialValue: sortFactor,
options: {
ChipSortFactor.date: context.l10n.chipSortDate,
ChipSortFactor.name: context.l10n.chipSortName,
ChipSortFactor.count: context.l10n.chipSortCount,
},
title: context.l10n.chipSortTitle,
),
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (factor != null) {
sortFactor = factor;
}
}
void _goToStats(BuildContext context) {
final source = context.read<CollectionSource>();
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: StatsPage.routeName),
builder: (context) => StatsPage(
source: source,
),
),
);
}
}
class AlbumChipSetActionDelegate extends ChipSetActionDelegate {
@override
ChipSortFactor get sortFactor => settings.albumSortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor;
@override
void onActionSelected(BuildContext context, ChipSetAction action) {
switch (action) {
case ChipSetAction.group:
_showGroupDialog(context);
break;
default:
break;
}
super.onActionSelected(context, action);
}
Future<void> _showGroupDialog(BuildContext context) async {
final factor = await showDialog<AlbumChipGroupFactor>(
context: context,
builder: (context) => AvesSelectionDialog<AlbumChipGroupFactor>(
initialValue: settings.albumGroupFactor,
options: {
AlbumChipGroupFactor.importance: context.l10n.albumGroupTier,
AlbumChipGroupFactor.volume: context.l10n.albumGroupVolume,
AlbumChipGroupFactor.none: context.l10n.albumGroupNone,
},
title: context.l10n.albumGroupTitle,
),
);
// wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (factor != null) {
settings.albumGroupFactor = factor;
}
}
}
class CountryChipSetActionDelegate extends ChipSetActionDelegate {
@override
ChipSortFactor get sortFactor => settings.countrySortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.countrySortFactor = factor;
}
class TagChipSetActionDelegate extends ChipSetActionDelegate {
@override
ChipSortFactor get sortFactor => settings.tagSortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.tagSortFactor = factor;
}

View file

@ -16,29 +16,25 @@ import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/collection/thumbnail/image.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:aves/widgets/filter_grids/common/overlay.dart';
import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class DecoratedFilterChip extends StatelessWidget {
final CollectionFilter filter;
class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
final T filter;
final double extent, thumbnailExtent;
final AvesEntry? coverEntry;
final bool pinned, highlightable;
final bool pinned;
final FilterCallback? onTap;
final OffsetFilterCallback? onLongPress;
const DecoratedFilterChip({
const CoveredFilterChip({
Key? key,
required this.filter,
required this.extent,
double? thumbnailExtent,
this.coverEntry,
this.pinned = false,
this.highlightable = true,
this.onTap,
this.onLongPress,
}) : thumbnailExtent = thumbnailExtent ?? extent,
super(key: key);
@ -89,41 +85,23 @@ class DecoratedFilterChip extends StatelessWidget {
extent: thumbnailExtent,
);
final titlePadding = min<double>(4.0, extent / 32);
final borderRadius = BorderRadius.all(radius(extent));
Widget child = AvesFilterChip(
filter: filter,
showGenericIcon: false,
background: backgroundImage,
details: _buildDetails(source, filter),
borderRadius: borderRadius,
padding: titlePadding,
onTap: onTap,
onLongPress: onLongPress,
);
child = Stack(
fit: StackFit.passthrough,
children: [
child,
if (highlightable)
ChipHighlightOverlay(
filter: filter,
extent: extent,
borderRadius: borderRadius,
),
],
);
child = SizedBox(
return SizedBox(
width: extent,
height: extent,
child: child,
child: AvesFilterChip(
filter: filter,
showGenericIcon: false,
background: backgroundImage,
details: _buildDetails(source, filter),
borderRadius: BorderRadius.all(radius(extent)),
padding: titlePadding,
onTap: onTap,
onLongPress: null,
),
);
return child;
}
Widget _buildDetails(CollectionSource source, CollectionFilter filter) {
Widget _buildDetails(CollectionSource source, T filter) {
final padding = min<double>(8.0, extent / 16);
final iconSize = min<double>(14.0, extent / 8);
final fontSize = min<double>(14.0, extent / 6);

View file

@ -0,0 +1,43 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/widgets/common/grid/overlay.dart';
import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart';
import 'package:aves/widgets/filter_grids/common/overlay.dart';
import 'package:flutter/widgets.dart';
class FilterChipGridDecorator<T extends CollectionFilter, U extends FilterGridItem<T>> extends StatelessWidget {
final U gridItem;
final double extent;
final Widget child;
const FilterChipGridDecorator({
Key? key,
required this.gridItem,
required this.extent,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.all(CoveredFilterChip.radius(extent));
return SizedBox(
width: extent,
height: extent,
child: Stack(
fit: StackFit.passthrough,
children: [
child,
GridItemSelectionOverlay<FilterGridItem<T>>(
item: gridItem,
borderRadius: borderRadius,
padding: EdgeInsets.all(extent / 24),
),
ChipHighlightOverlay(
filter: gridItem.filter,
extent: extent,
borderRadius: borderRadius,
),
],
),
);
}
}

View file

@ -1,8 +1,10 @@
import 'dart:ui';
import 'package:aves/app_mode.dart';
import 'package:aves/model/covers.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
@ -13,7 +15,9 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/item_tracker.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:aves/widgets/common/grid/selector.dart';
import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
@ -21,8 +25,9 @@ import 'package:aves/widgets/common/providers/tile_extent_controller_provider.da
import 'package:aves/widgets/common/scaling.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:aves/widgets/drawer/app_drawer.dart';
import 'package:aves/widgets/filter_grids/common/decorated_filter_chip.dart';
import 'package:aves/widgets/filter_grids/common/covered_filter_chip.dart';
import 'package:aves/widgets/filter_grids/common/draggable_thumb_label.dart';
import 'package:aves/widgets/filter_grids/common/filter_chip_grid_decorator.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:aves/widgets/filter_grids/common/section_layout.dart';
import 'package:collection/collection.dart';
@ -38,28 +43,27 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
final String? settingsRouteKey;
final Widget appBar;
final double appBarHeight;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
final ChipSortFactor sortFactor;
final bool showHeaders;
final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier;
final QueryTest<T>? applyQuery;
final Widget Function() emptyBuilder;
final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
const FilterGridPage({
Key? key,
this.settingsRouteKey,
required this.appBar,
this.appBarHeight = kToolbarHeight,
required this.filterSections,
required this.sections,
required this.sortFactor,
required this.showHeaders,
required this.selectable,
required this.queryNotifier,
this.applyQuery,
required this.emptyBuilder,
required this.onTap,
this.onLongPress,
}) : super(key: key);
static const Color detailColor = Color(0xFFE0E0E0);
@ -68,24 +72,34 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
Widget build(BuildContext context) {
return MediaQueryDataProvider(
child: Scaffold(
body: DoubleBackPopScope(
child: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: AnimatedBuilder(
animation: covers,
builder: (context, child) => FilterGrid<T>(
settingsRouteKey: settingsRouteKey,
appBar: appBar,
appBarHeight: appBarHeight,
filterSections: filterSections,
sortFactor: sortFactor,
showHeaders: showHeaders,
queryNotifier: queryNotifier,
applyQuery: applyQuery,
emptyBuilder: emptyBuilder,
onTap: onTap,
onLongPress: onLongPress,
body: WillPopScope(
onWillPop: () {
final selection = context.read<Selection<FilterGridItem<T>>>();
if (selection.isSelecting) {
selection.browse();
return SynchronousFuture(false);
}
return SynchronousFuture(true);
},
child: DoubleBackPopScope(
child: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: AnimatedBuilder(
animation: covers,
builder: (context, child) => FilterGrid<T>(
settingsRouteKey: settingsRouteKey,
appBar: appBar,
appBarHeight: appBarHeight,
sections: sections,
sortFactor: sortFactor,
showHeaders: showHeaders,
selectable: selectable,
queryNotifier: queryNotifier,
applyQuery: applyQuery,
emptyBuilder: emptyBuilder,
onTap: onTap,
),
),
),
),
@ -102,28 +116,27 @@ class FilterGrid<T extends CollectionFilter> extends StatefulWidget {
final String? settingsRouteKey;
final Widget appBar;
final double appBarHeight;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
final ChipSortFactor sortFactor;
final bool showHeaders;
final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier;
final QueryTest<T>? applyQuery;
final Widget Function() emptyBuilder;
final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
const FilterGrid({
Key? key,
required this.settingsRouteKey,
required this.appBar,
required this.appBarHeight,
required this.filterSections,
required this.sections,
required this.sortFactor,
required this.showHeaders,
required this.selectable,
required this.queryNotifier,
required this.applyQuery,
required this.emptyBuilder,
required this.onTap,
required this.onLongPress,
}) : super(key: key);
@override
@ -152,14 +165,14 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
child: _FilterGridContent<T>(
appBar: widget.appBar,
appBarHeight: widget.appBarHeight,
filterSections: widget.filterSections,
sections: widget.sections,
sortFactor: widget.sortFactor,
showHeaders: widget.showHeaders,
selectable: widget.selectable,
queryNotifier: widget.queryNotifier,
applyQuery: widget.applyQuery,
emptyBuilder: widget.emptyBuilder,
onTap: widget.onTap,
onLongPress: widget.onLongPress,
),
);
}
@ -167,14 +180,13 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
final Widget appBar;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
final ChipSortFactor sortFactor;
final bool showHeaders;
final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier;
final Widget Function() emptyBuilder;
final QueryTest<T>? applyQuery;
final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
@ -182,14 +194,14 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
Key? key,
required this.appBar,
required double appBarHeight,
required this.filterSections,
required this.sections,
required this.sortFactor,
required this.showHeaders,
required this.selectable,
required this.queryNotifier,
required this.applyQuery,
required this.emptyBuilder,
required this.onTap,
required this.onLongPress,
}) : super(key: key) {
_appBarHeightNotifier.value = appBarHeight;
}
@ -199,15 +211,15 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
return ValueListenableBuilder<String>(
valueListenable: queryNotifier,
builder: (context, query, child) {
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleSections;
if (applyQuery == null) {
visibleFilterSections = filterSections;
visibleSections = sections;
} else {
visibleFilterSections = {};
filterSections.forEach((sectionKey, sectionFilters) {
visibleSections = {};
sections.forEach((sectionKey, sectionFilters) {
final visibleFilters = applyQuery!(sectionFilters, query);
if (visibleFilters.isNotEmpty) {
visibleFilterSections[sectionKey] = visibleFilters.toList();
visibleSections[sectionKey] = visibleFilters.toList();
}
});
}
@ -216,48 +228,54 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, tileExtent, child) {
return Selector<TileExtentController, Tuple3<double, int, double>>(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
builder: (context, c, child) {
final scrollableWidth = c.item1;
final columnCount = c.item2;
final tileSpacing = c.item3;
// do not listen for animation delay change
final controller = Provider.of<TileExtentController>(context, listen: false);
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
return SectionedFilterListLayoutProvider<T>(
sections: visibleFilterSections,
showHeaders: showHeaders,
scrollableWidth: scrollableWidth,
columnCount: columnCount,
spacing: tileSpacing,
tileExtent: tileExtent,
tileBuilder: (gridItem) {
final filter = gridItem.filter;
final entry = gridItem.entry;
return MetaData(
metaData: ScalerMetadata(FilterGridItem<T>(filter, entry)),
child: DecoratedFilterChip(
key: Key(filter.key),
filter: filter,
extent: tileExtent,
pinned: pinnedFilters.contains(filter),
onTap: onTap,
onLongPress: onLongPress,
),
);
},
tileAnimationDelay: tileAnimationDelay,
child: _FilterSectionedContent<T>(
appBar: appBar,
appBarHeightNotifier: _appBarHeightNotifier,
visibleFilterSections: visibleFilterSections,
sortFactor: sortFactor,
emptyBuilder: emptyBuilder,
scrollController: PrimaryScrollController.of(context)!,
),
);
});
return GridTheme(
extent: tileExtent,
child: Selector<TileExtentController, Tuple3<double, int, double>>(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
builder: (context, c, child) {
final scrollableWidth = c.item1;
final columnCount = c.item2;
final tileSpacing = c.item3;
// do not listen for animation delay change
final controller = Provider.of<TileExtentController>(context, listen: false);
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
return SectionedFilterListLayoutProvider<T>(
sections: visibleSections,
showHeaders: showHeaders,
scrollableWidth: scrollableWidth,
columnCount: columnCount,
spacing: tileSpacing,
tileExtent: tileExtent,
tileBuilder: (gridItem) {
final filter = gridItem.filter;
return MetaData(
metaData: ScalerMetadata(gridItem),
child: FilterChipGridDecorator<T, FilterGridItem<T>>(
gridItem: gridItem,
extent: tileExtent,
child: CoveredFilterChip(
key: Key(filter.key),
filter: filter,
extent: tileExtent,
pinned: pinnedFilters.contains(filter),
onTap: onTap,
),
),
);
},
tileAnimationDelay: tileAnimationDelay,
child: _FilterSectionedContent<T>(
appBar: appBar,
appBarHeightNotifier: _appBarHeightNotifier,
visibleSections: visibleSections,
sortFactor: sortFactor,
selectable: selectable,
emptyBuilder: emptyBuilder,
scrollController: PrimaryScrollController.of(context)!,
),
);
}),
);
},
);
return sectionedListLayoutProvider;
@ -269,16 +287,18 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
class _FilterSectionedContent<T extends CollectionFilter> extends StatefulWidget {
final Widget appBar;
final ValueNotifier<double> appBarHeightNotifier;
final Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections;
final Map<ChipSectionKey, List<FilterGridItem<T>>> visibleSections;
final ChipSortFactor sortFactor;
final bool selectable;
final Widget Function() emptyBuilder;
final ScrollController scrollController;
const _FilterSectionedContent({
required this.appBar,
required this.appBarHeightNotifier,
required this.visibleFilterSections,
required this.visibleSections,
required this.sortFactor,
required this.selectable,
required this.emptyBuilder,
required this.scrollController,
});
@ -293,7 +313,7 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
@override
ValueNotifier<double> get appBarHeightNotifier => widget.appBarHeightNotifier;
Map<ChipSectionKey, List<FilterGridItem<T>>> get visibleFilterSections => widget.visibleFilterSections;
Map<ChipSectionKey, List<FilterGridItem<T>>> get visibleSections => widget.visibleSections;
Widget Function() get emptyBuilder => widget.emptyBuilder;
@ -328,14 +348,23 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
child: scrollView,
);
return scaler;
final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
final selector = GridSelectionGestureDetector<FilterGridItem<T>>(
selectable: isMainMode && widget.selectable,
items: visibleSections.values.expand((v) => v).toList(),
scrollController: scrollController,
appBarHeightNotifier: appBarHeightNotifier,
child: scaler,
);
return selector;
}
Future<void> _checkInitHighlight() async {
final highlightInfo = context.read<HighlightInfo>();
final filter = highlightInfo.clear();
if (filter is T) {
final gridItem = visibleFilterSections.values.expand((list) => list).firstWhereOrNull((gridItem) => gridItem.filter == filter);
final gridItem = visibleSections.values.expand((list) => list).firstWhereOrNull((gridItem) => gridItem.filter == filter);
if (gridItem != null) {
await Future.delayed(Durations.highlightScrollInitDelay);
highlightInfo.trackItem(gridItem, highlightItem: filter);
@ -367,19 +396,18 @@ class _FilterScaler<T extends CollectionFilter> extends StatelessWidget {
extent: extent,
spacing: tileSpacing,
borderWidth: AvesFilterChip.outlineWidth,
borderRadius: DecoratedFilterChip.radius(extent),
borderRadius: CoveredFilterChip.radius(extent),
color: Colors.grey.shade700,
),
child: child,
),
scaledBuilder: (item, extent) {
final filter = item.filter;
return DecoratedFilterChip(
return CoveredFilterChip(
filter: filter,
extent: extent,
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
pinned: pinnedFilters.contains(filter),
highlightable: false,
);
},
highlightItem: (item) => item.filter,

View file

@ -1,38 +1,22 @@
import 'dart:ui';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart';
import 'package:aves/widgets/common/app_bar_title.dart';
import 'package:aves/widgets/common/basic/menu_row.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
import 'package:aves/widgets/filter_grids/common/app_bar.dart';
import 'package:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:aves/widgets/search/search_button.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
final CollectionSource source;
final String title;
final ChipSetActionDelegate chipSetActionDelegate;
final ChipSortFactor sortFactor;
final bool groupable, showHeaders;
final ChipActionDelegate chipActionDelegate;
final List<ChipAction> Function(T filter) chipActionsBuilder;
final ChipSetActionDelegate actionDelegate;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
final Widget Function() emptyBuilder;
@ -43,114 +27,54 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
required this.sortFactor,
this.groupable = false,
this.showHeaders = false,
required this.chipSetActionDelegate,
required this.chipActionDelegate,
required this.chipActionsBuilder,
required this.actionDelegate,
required this.filterSections,
required this.emptyBuilder,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
return FilterGridPage<T>(
key: const Key('filter-grid-page'),
appBar: SliverAppBar(
title: InteractiveAppBarTitle(
onTap: () => _goToSearch(context),
child: SourceStateAwareAppBarTitle(
title: Text(title),
return SelectionProvider<FilterGridItem<T>>(
child: Builder(
builder: (context) => FilterGridPage<T>(
key: const Key('filter-grid-page'),
appBar: FilterGridAppBar<T>(
source: source,
title: title,
actionDelegate: actionDelegate,
groupable: groupable,
isEmpty: filterSections.isEmpty,
),
),
actions: _buildActions(context),
titleSpacing: 0,
floating: true,
),
filterSections: filterSections,
sortFactor: sortFactor,
showHeaders: showHeaders,
queryNotifier: ValueNotifier(''),
emptyBuilder: () => ValueListenableBuilder<SourceState>(
valueListenable: source.stateNotifier,
builder: (context, sourceState, child) {
return sourceState != SourceState.loading ? emptyBuilder() : const SizedBox.shrink();
},
),
onTap: (filter) => Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(
collection: CollectionLens(
source: source,
filters: [filter],
),
sections: filterSections,
sortFactor: sortFactor,
showHeaders: showHeaders,
selectable: true,
queryNotifier: ValueNotifier(''),
emptyBuilder: () => ValueListenableBuilder<SourceState>(
valueListenable: source.stateNotifier,
builder: (context, sourceState, child) {
return sourceState != SourceState.loading ? emptyBuilder() : const SizedBox.shrink();
},
),
onTap: (filter) => _goToCollection(context, filter),
),
),
onLongPress: isMainMode ? _showMenu as OffsetFilterCallback : null,
);
}
void _showMenu(BuildContext context, T filter, Offset? tapPosition) async {
final overlay = Overlay.of(context)!.context.findRenderObject() as RenderBox;
const touchArea = Size(40, 40);
final selectedAction = await showMenu<ChipAction>(
context: context,
position: RelativeRect.fromRect((tapPosition ?? Offset.zero) & touchArea, Offset.zero & overlay.size),
items: chipActionsBuilder(filter)
.map((action) => PopupMenuItem(
value: action,
child: MenuRow(text: action.getText(context), icon: action.getIcon()),
))
.toList(),
);
if (selectedAction != null) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => chipActionDelegate.onActionSelected(context, filter, selectedAction));
}
}
List<Widget> _buildActions(BuildContext context) {
return [
CollectionSearchButton(source: source),
PopupMenuButton<ChipSetAction>(
key: const Key('appbar-menu-button'),
itemBuilder: (context) {
return [
PopupMenuItem(
key: const Key('menu-sort'),
value: ChipSetAction.sort,
child: MenuRow(text: context.l10n.menuActionSort, icon: AIcons.sort),
),
if (groupable)
PopupMenuItem(
value: ChipSetAction.group,
child: MenuRow(text: context.l10n.menuActionGroup, icon: AIcons.group),
),
PopupMenuItem(
value: ChipSetAction.stats,
child: MenuRow(text: context.l10n.menuActionStats, icon: AIcons.stats),
),
];
},
onSelected: (action) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => chipSetActionDelegate.onActionSelected(context, action));
},
),
];
}
void _goToSearch(BuildContext context) {
void _goToCollection(BuildContext context, CollectionFilter filter) {
Navigator.push(
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
context,
MaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(
collection: CollectionLens(
source: source,
filters: [filter],
),
));
),
),
);
}
static int compareFiltersByDate(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) {
@ -167,21 +91,23 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
return a.filter.compareTo(b.filter);
}
static Iterable<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Set<T> filters) {
Iterable<FilterGridItem<T>> toGridItem(CollectionSource source, Set<T> filters) {
return filters.map((filter) => FilterGridItem(
filter,
source.recentEntry(filter),
));
static List<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Set<T> filters) {
List<FilterGridItem<T>> toGridItem(CollectionSource source, Set<T> filters) {
return filters
.map((filter) => FilterGridItem(
filter,
source.recentEntry(filter),
))
.toList();
}
Iterable<FilterGridItem<T>> allMapEntries = {};
List<FilterGridItem<T>> allMapEntries = [];
switch (sortFactor) {
case ChipSortFactor.name:
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByName);
allMapEntries = toGridItem(source, filters)..sort(compareFiltersByName);
break;
case ChipSortFactor.date:
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByDate);
allMapEntries = toGridItem(source, filters)..sort(compareFiltersByDate);
break;
case ChipSortFactor.count:
final filtersWithCount = List.of(filters.map((filter) => MapEntry(filter, source.count(filter))));

View file

@ -2,7 +2,7 @@ import 'package:aves/widgets/common/grid/header.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:flutter/material.dart';
class FilterChipSectionHeader extends StatelessWidget {
class FilterChipSectionHeader<T> extends StatelessWidget {
final ChipSectionKey sectionKey;
const FilterChipSectionHeader({
@ -12,11 +12,10 @@ class FilterChipSectionHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SectionHeader(
return SectionHeader<T>(
sectionKey: sectionKey,
leading: sectionKey.leading,
title: sectionKey.title,
selectable: false,
);
}

View file

@ -41,7 +41,7 @@ class SectionedFilterListLayoutProvider<T extends CollectionFilter> extends Sect
@override
Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent) {
return FilterChipSectionHeader(
return FilterChipSectionHeader<FilterGridItem<T>>(
sectionKey: sectionKey as ChipSectionKey,
);
}

View file

@ -1,4 +1,3 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/settings.dart';
@ -8,8 +7,7 @@ import 'package:aves/model/source/location.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/country_set.dart';
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:collection/collection.dart';
@ -35,36 +33,32 @@ class CountryListPage extends StatelessWidget {
builder: (context, s, child) {
return StreamBuilder(
stream: source.eventBus.on<CountriesChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage<LocationFilter>(
source: source,
title: context.l10n.countryPageTitle,
sortFactor: settings.countrySortFactor,
chipSetActionDelegate: CountryChipSetActionDelegate(),
chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.setCover,
ChipAction.hide,
],
filterSections: _getCountryEntries(source),
emptyBuilder: () => EmptyContent(
icon: AIcons.location,
text: context.l10n.countryEmpty,
),
),
builder: (context, snapshot) {
final gridItems = _getGridItems(source);
return FilterNavigationPage<LocationFilter>(
source: source,
title: context.l10n.countryPageTitle,
sortFactor: settings.countrySortFactor,
actionDelegate: CountryChipSetActionDelegate(gridItems),
filterSections: _groupToSections(gridItems),
emptyBuilder: () => EmptyContent(
icon: AIcons.location,
text: context.l10n.countryEmpty,
),
);
},
);
},
);
}
Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _getCountryEntries(CollectionSource source) {
List<FilterGridItem<LocationFilter>> _getGridItems(CollectionSource source) {
final filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)).toSet();
final sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters);
return _group(sorted);
return FilterNavigationPage.sort(settings.countrySortFactor, source, filters);
}
static Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _group(Iterable<FilterGridItem<LocationFilter>> sortedMapEntries) {
static Map<ChipSectionKey, List<FilterGridItem<LocationFilter>>> _groupToSections(Iterable<FilterGridItem<LocationFilter>> sortedMapEntries) {
final pinned = settings.pinnedFilters.whereType<LocationFilter>();
final byPin = groupBy<FilterGridItem<LocationFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
final pinnedMapEntries = (byPin[true] ?? []);

View file

@ -1,4 +1,3 @@
import 'package:aves/model/actions/chip_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/settings/settings.dart';
@ -8,8 +7,7 @@ import 'package:aves/model/source/tag.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/filter_grids/common/chip_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/tag_set.dart';
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:collection/collection.dart';
@ -35,36 +33,32 @@ class TagListPage extends StatelessWidget {
builder: (context, s, child) {
return StreamBuilder(
stream: source.eventBus.on<TagsChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage<TagFilter>(
source: source,
title: context.l10n.tagPageTitle,
sortFactor: settings.tagSortFactor,
chipSetActionDelegate: TagChipSetActionDelegate(),
chipActionDelegate: ChipActionDelegate(),
chipActionsBuilder: (filter) => [
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin,
ChipAction.setCover,
ChipAction.hide,
],
filterSections: _getTagEntries(source),
emptyBuilder: () => EmptyContent(
icon: AIcons.tag,
text: context.l10n.tagEmpty,
),
),
builder: (context, snapshot) {
final gridItems = _getGridItems(source);
return FilterNavigationPage<TagFilter>(
source: source,
title: context.l10n.tagPageTitle,
sortFactor: settings.tagSortFactor,
actionDelegate: TagChipSetActionDelegate(gridItems),
filterSections: _groupToSections(gridItems),
emptyBuilder: () => EmptyContent(
icon: AIcons.tag,
text: context.l10n.tagEmpty,
),
);
},
);
},
);
}
Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _getTagEntries(CollectionSource source) {
List<FilterGridItem<TagFilter>> _getGridItems(CollectionSource source) {
final filters = source.sortedTags.map((tag) => TagFilter(tag)).toSet();
final sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters);
return _group(sorted);
return FilterNavigationPage.sort(settings.tagSortFactor, source, filters);
}
static Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _group(Iterable<FilterGridItem<TagFilter>> sortedMapEntries) {
static Map<ChipSectionKey, List<FilterGridItem<TagFilter>>> _groupToSections(Iterable<FilterGridItem<TagFilter>> sortedMapEntries) {
final pinned = settings.pinnedFilters.whereType<TagFilter>();
final byPin = groupBy<FilterGridItem<TagFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
final pinnedMapEntries = (byPin[true] ?? []);

View file

@ -26,12 +26,13 @@ class CollectionSearchButton extends StatelessWidget {
void _goToSearch(BuildContext context) {
Navigator.push(
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
source: source,
parentCollection: parentCollection,
),
));
context,
SearchPageRoute(
delegate: CollectionSearchDelegate(
source: source,
parentCollection: parentCollection,
),
),
);
}
}

View file

@ -76,7 +76,7 @@ class HiddenFilterPage extends StatelessWidget {
.map((filter) => AvesFilterChip(
filter: filter,
removable: true,
onTap: (filter) => context.read<CollectionSource>().changeFilterVisibility(filter, true),
onTap: (filter) => context.read<CollectionSource>().changeFilterVisibility({filter}, true),
onLongPress: null,
))
.toList(),

View file

@ -36,7 +36,7 @@ class VideoSection extends StatelessWidget {
if (!standalonePage)
SwitchListTile(
value: currentShowVideos,
onChanged: (v) => context.read<CollectionSource>().changeFilterVisibility(MimeFilter.video, v),
onChanged: (v) => context.read<CollectionSource>().changeFilterVisibility({MimeFilter.video}, v),
title: Text(context.l10n.settingsVideoShowVideos),
),
const VideoActionsTile(),

View file

@ -3,7 +3,7 @@ import 'dart:math';
import 'package:aves/model/multipage.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/collection/thumbnail/theme.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/viewer/multipage/controller.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -91,7 +91,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
final horizontalMargin = SizedBox(width: marginWidth);
const separator = SizedBox(width: separatorWidth);
return ThumbnailTheme(
return GridTheme(
extent: extent,
showLocation: false,
child: StreamBuilder<MultiPageInfo?>(

View file

@ -1,10 +1,10 @@
import 'package:aves/model/entry.dart';
import 'package:aves/services/services.dart';
import 'package:aves/utils/pedantic.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/panorama_page.dart';
import 'package:flutter/material.dart';
import 'package:aves/utils/pedantic.dart';
class PanoramaOverlay extends StatelessWidget {
final AvesEntry entry;

View file

@ -130,7 +130,7 @@ void selectFirstAlbum() {
await driver.tap(find.descendant(
of: find.byValueKey('filter-grid-page'),
matching: find.byType('DecoratedFilterChip'),
matching: find.byType('CoveredFilterChip'),
firstMatchOnly: true,
));
await driver.waitUntilNoTransientCallbacks();