#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": "Directory already exists",
"@renameAlbumDialogLabelAlreadyExistsHelper": {}, "@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?}}", "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?}}",
"@deleteAlbumConfirmationDialogMessage": { "@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": { "placeholders": {
"count": {} "count": {}
} }
@ -630,11 +636,11 @@
"@settingsSubtitleThemeBackgroundColor": {}, "@settingsSubtitleThemeBackgroundColor": {},
"settingsSubtitleThemeBackgroundOpacity": "Background opacity", "settingsSubtitleThemeBackgroundOpacity": "Background opacity",
"@settingsSubtitleThemeBackgroundOpacity": {}, "@settingsSubtitleThemeBackgroundOpacity": {},
"settingsSubtitleThemeTextAlignmentLeft": "Left", "settingsSubtitleThemeTextAlignmentLeft": "Left",
"@settingsSubtitleThemeTextAlignmentLeft": {}, "@settingsSubtitleThemeTextAlignmentLeft": {},
"settingsSubtitleThemeTextAlignmentCenter": "Center", "settingsSubtitleThemeTextAlignmentCenter": "Center",
"@settingsSubtitleThemeTextAlignmentCenter": {}, "@settingsSubtitleThemeTextAlignmentCenter": {},
"settingsSubtitleThemeTextAlignmentRight": "Right", "settingsSubtitleThemeTextAlignmentRight": "Right",
"@settingsSubtitleThemeTextAlignmentRight": {}, "@settingsSubtitleThemeTextAlignmentRight": {},
"settingsSectionPrivacy": "Privacy", "settingsSectionPrivacy": "Privacy",

View file

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

View file

@ -2,29 +2,16 @@ import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
enum ChipSetAction {
group,
sort,
stats,
}
enum ChipAction { enum ChipAction {
delete,
hide,
pin,
unpin,
rename,
setCover,
goToAlbumPage, goToAlbumPage,
goToCountryPage, goToCountryPage,
goToTagPage, goToTagPage,
hide,
} }
extension ExtraChipAction on ChipAction { extension ExtraChipAction on ChipAction {
String getText(BuildContext context) { String getText(BuildContext context) {
switch (this) { switch (this) {
case ChipAction.delete:
return context.l10n.chipActionDelete;
case ChipAction.goToAlbumPage: case ChipAction.goToAlbumPage:
return context.l10n.chipActionGoToAlbumPage; return context.l10n.chipActionGoToAlbumPage;
case ChipAction.goToCountryPage: case ChipAction.goToCountryPage:
@ -33,21 +20,11 @@ extension ExtraChipAction on ChipAction {
return context.l10n.chipActionGoToTagPage; return context.l10n.chipActionGoToTagPage;
case ChipAction.hide: case ChipAction.hide:
return context.l10n.chipActionHide; 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() { IconData getIcon() {
switch (this) { switch (this) {
case ChipAction.delete:
return AIcons.delete;
case ChipAction.goToAlbumPage: case ChipAction.goToAlbumPage:
return AIcons.album; return AIcons.album;
case ChipAction.goToCountryPage: case ChipAction.goToCountryPage:
@ -56,13 +33,6 @@ extension ExtraChipAction on ChipAction {
return AIcons.tag; return AIcons.tag;
case ChipAction.hide: case ChipAction.hide:
return AIcons.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); return recentEntry(filter);
} }
void changeFilterVisibility(CollectionFilter filter, bool visible) { void changeFilterVisibility(Set<CollectionFilter> filters, bool visible) {
final hiddenFilters = settings.hiddenFilters; final hiddenFilters = settings.hiddenFilters;
if (visible) { if (visible) {
hiddenFilters.remove(filter); hiddenFilters.removeAll(filters);
} else { } else {
hiddenFilters.add(filter); hiddenFilters.addAll(filters);
settings.searchHistory = settings.searchHistory..remove(filter); settings.searchHistory = settings.searchHistory..removeWhere(filters.contains);
} }
settings.hiddenFilters = hiddenFilters; settings.hiddenFilters = hiddenFilters;
@ -292,10 +292,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
updateLocations(); updateLocations();
updateTags(); updateTags();
eventBus.fire(FilterVisibilityChangedEvent(filter, visible)); eventBus.fire(FilterVisibilityChangedEvent(filters, visible));
if (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 { class FilterVisibilityChangedEvent {
final CollectionFilter filter; final Set<CollectionFilter> filters;
final bool visible; final bool visible;
const FilterVisibilityChangedEvent(this.filter, this.visible); const FilterVisibilityChangedEvent(this.filters, this.visible);
} }
class ProgressEvent { class ProgressEvent {

View file

@ -50,6 +50,7 @@ class AIcons {
static const IconData layers = Icons.layers_outlined; static const IconData layers = Icons.layers_outlined;
static const IconData openOutside = Icons.open_in_new_outlined; static const IconData openOutside = Icons.open_in_new_outlined;
static const IconData pin = Icons.push_pin_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 play = Icons.play_arrow;
static const IconData pause = Icons.pause; static const IconData pause = Icons.pause;
static const IconData print = Icons.print_outlined; static const IconData print = Icons.print_outlined;

View file

@ -46,7 +46,6 @@ class CollectionAppBar extends StatefulWidget {
} }
class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin { class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerProviderStateMixin {
final TextEditingController _searchFieldController = TextEditingController();
final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate(); final EntrySetActionDelegate _actionDelegate = EntrySetActionDelegate();
late AnimationController _browseToSelectAnimation; late AnimationController _browseToSelectAnimation;
late Future<bool> _canAddShortcutsLoader; late Future<bool> _canAddShortcutsLoader;
@ -83,7 +82,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
_unregisterWidget(widget); _unregisterWidget(widget);
_isSelectingNotifier.removeListener(_onActivityChange); _isSelectingNotifier.removeListener(_onActivityChange);
_browseToSelectAnimation.dispose(); _browseToSelectAnimation.dispose();
_searchFieldController.dispose();
super.dispose(); super.dispose();
} }
@ -271,7 +269,6 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
_browseToSelectAnimation.forward(); _browseToSelectAnimation.forward();
} else { } else {
_browseToSelectAnimation.reverse(); _browseToSelectAnimation.reverse();
_searchFieldController.clear();
} }
} }
@ -370,13 +367,14 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
void _goToSearch() { void _goToSearch() {
Navigator.push( Navigator.push(
context, context,
SearchPageRoute( SearchPageRoute(
delegate: CollectionSearchDelegate( delegate: CollectionSearchDelegate(
source: collection.source, source: collection.source,
parentCollection: collection, parentCollection: collection,
), ),
)); ),
);
} }
void _goToStats() { 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/section_layout.dart';
import 'package:aves/widgets/collection/grid/thumbnail.dart'; import 'package:aves/widgets/collection/grid/thumbnail.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.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/draggable_scrollbar.dart';
import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.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/item_tracker.dart';
import 'package:aves/widgets/common/grid/selector.dart'; import 'package:aves/widgets/common/grid/selector.dart';
import 'package:aves/widgets/common/grid/sliver.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/empty.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart'; import 'package:aves/widgets/common/providers/tile_extent_controller_provider.dart';
@ -79,7 +79,7 @@ class _CollectionGridContent extends StatelessWidget {
final sectionedListLayoutProvider = ValueListenableBuilder<double>( final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier), valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, tileExtent, child) { builder: (context, tileExtent, child) {
return ThumbnailTheme( return GridTheme(
extent: tileExtent, extent: tileExtent,
child: Selector<TileExtentController, Tuple3<double, int, double>>( child: Selector<TileExtentController, Tuple3<double, int, double>>(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing), 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 isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main);
final selector = GridSelectionGestureDetector( final selector = GridSelectionGestureDetector(
selectable: isMainMode, selectable: isMainMode,
entries: collection.sortedEntries, items: collection.sortedEntries,
scrollController: scrollController, scrollController: scrollController,
appBarHeightNotifier: appBarHeightNotifier, appBarHeightNotifier: appBarHeightNotifier,
child: scaler, child: scaler,
@ -210,14 +210,11 @@ class _CollectionScaler extends StatelessWidget {
), ),
child: child, child: child,
), ),
scaledBuilder: (entry, extent) => ThumbnailTheme( scaledBuilder: (entry, extent) => DecoratedThumbnail(
extent: extent, entry: entry,
child: DecoratedThumbnail( tileExtent: context.read<TileExtentController>().effectiveExtentMax,
entry: entry, selectable: false,
tileExtent: context.read<TileExtentController>().effectiveExtentMax, highlightable: false,
selectable: false,
highlightable: false,
),
), ),
child: child, 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/collection_source.dart';
import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
@ -33,7 +34,7 @@ class AlbumSectionHeader extends StatelessWidget {
); );
} }
} }
return SectionHeader( return SectionHeader<AvesEntry>(
sectionKey: EntryAlbumSectionKey(directory), sectionKey: EntryAlbumSectionKey(directory),
leading: albumIcon, leading: albumIcon,
title: albumName ?? context.l10n.sectionUnknown, title: albumName ?? context.l10n.sectionUnknown,

View file

@ -1,5 +1,6 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
@ -39,9 +40,9 @@ class CollectionSectionHeader extends StatelessWidget {
case EntryGroupFactor.album: case EntryGroupFactor.album:
return _buildAlbumHeader(context); return _buildAlbumHeader(context);
case EntryGroupFactor.month: 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: 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: case EntryGroupFactor.none:
break; break;
} }

View file

@ -5,7 +5,7 @@ import 'package:aves/widgets/common/grid/header.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class DaySectionHeader extends StatelessWidget { class DaySectionHeader<T> extends StatelessWidget {
final DateTime? date; final DateTime? date;
const DaySectionHeader({ const DaySectionHeader({
@ -45,14 +45,14 @@ class DaySectionHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SectionHeader( return SectionHeader<T>(
sectionKey: EntryDateSectionKey(date), sectionKey: EntryDateSectionKey(date),
title: _formatDate(context, date), title: _formatDate(context, date),
); );
} }
} }
class MonthSectionHeader extends StatelessWidget { class MonthSectionHeader<T> extends StatelessWidget {
final DateTime? date; final DateTime? date;
const MonthSectionHeader({ const MonthSectionHeader({
@ -71,7 +71,7 @@ class MonthSectionHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SectionHeader( return SectionHeader<T>(
sectionKey: EntryDateSectionKey(date), sectionKey: EntryDateSectionKey(date),
title: _formatDate(context, 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/image.dart';
import 'package:aves/widgets/collection/thumbnail/overlay.dart'; import 'package:aves/widgets/collection/thumbnail/overlay.dart';
import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/grid/overlay.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class DecoratedThumbnail extends StatelessWidget { class DecoratedThumbnail extends StatelessWidget {
@ -46,7 +47,7 @@ class DecoratedThumbnail extends StatelessWidget {
children: [ children: [
child, child,
if (!isSvg) ThumbnailEntryOverlay(entry: entry), if (!isSvg) ThumbnailEntryOverlay(entry: entry),
if (selectable) ThumbnailSelectionOverlay(entry: entry), if (selectable) GridItemSelectionOverlay(item: entry),
if (highlightable) ThumbnailHighlightOverlay(entry: 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/entry.dart';
import 'package:aves/model/highlight.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/fx/sweeper.dart';
import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -22,7 +19,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final children = [ 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) if (entry.isVideo)
VideoIcon( VideoIcon(
entry: entry, entry: entry,
@ -30,7 +27,7 @@ class ThumbnailEntryOverlay extends StatelessWidget {
else if (entry.isAnimated) else if (entry.isAnimated)
const AnimatedImageIcon() const AnimatedImageIcon()
else ...[ 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.isMultiPage) MultiPageIcon(entry: entry),
if (entry.isGeotiff) const GeotiffIcon(), if (entry.isGeotiff) const GeotiffIcon(),
if (entry.is360) const SphericalImageIcon(), 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 { class ThumbnailHighlightOverlay extends StatefulWidget {
final AvesEntry entry; final AvesEntry entry;
@ -125,7 +71,7 @@ class _ThumbnailHighlightOverlayState extends State<ThumbnailHighlightOverlay> {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide( border: Border.fromBorderSide(BorderSide(
color: Theme.of(context).accentColor, 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/selection.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/extensions/build_context.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/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class SectionHeader extends StatelessWidget { class SectionHeader<T> extends StatelessWidget {
final SectionKey sectionKey; final SectionKey sectionKey;
final Widget? leading, trailing; final Widget? leading, trailing;
final String title; final String title;
@ -44,7 +43,7 @@ class SectionHeader extends StatelessWidget {
children: [ children: [
WidgetSpan( WidgetSpan(
alignment: widgetSpanAlignment, alignment: widgetSpanAlignment,
child: _SectionSelectableLeading( child: _SectionSelectableLeading<T>(
selectable: selectable, selectable: selectable,
sectionKey: sectionKey, sectionKey: sectionKey,
browsingBuilder: leading != null browsingBuilder: leading != null
@ -78,9 +77,8 @@ class SectionHeader extends StatelessWidget {
} }
void _toggleSectionSelection(BuildContext context) { void _toggleSectionSelection(BuildContext context) {
final collection = context.read<CollectionLens>(); final sectionEntries = context.read<SectionedListLayout<T>>().sections[sectionKey]!;
final sectionEntries = collection.sections[sectionKey]!; final selection = context.read<Selection<T>>();
final selection = context.read<Selection<AvesEntry>>();
final isSelected = selection.isSelected(sectionEntries); final isSelected = selection.isSelected(sectionEntries);
if (isSelected) { if (isSelected) {
selection.removeFromSelection(sectionEntries); selection.removeFromSelection(sectionEntries);
@ -124,7 +122,7 @@ class SectionHeader extends StatelessWidget {
} }
} }
class _SectionSelectableLeading extends StatelessWidget { class _SectionSelectableLeading<T> extends StatelessWidget {
final bool selectable; final bool selectable;
final SectionKey sectionKey; final SectionKey sectionKey;
final WidgetBuilder? browsingBuilder; final WidgetBuilder? browsingBuilder;
@ -144,9 +142,9 @@ class _SectionSelectableLeading extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!selectable) return _buildBrowsing(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 final Widget child = isSelecting
? _SectionSelectingLeading( ? _SectionSelectingLeading<T>(
sectionKey: sectionKey, sectionKey: sectionKey,
onPressed: onPressed, onPressed: onPressed,
) )
@ -179,7 +177,7 @@ class _SectionSelectableLeading extends StatelessWidget {
Widget _buildBrowsing(BuildContext context) => browsingBuilder?.call(context) ?? const SizedBox(height: leadingDimension); Widget _buildBrowsing(BuildContext context) => browsingBuilder?.call(context) ?? const SizedBox(height: leadingDimension);
} }
class _SectionSelectingLeading extends StatelessWidget { class _SectionSelectingLeading<T> extends StatelessWidget {
final SectionKey sectionKey; final SectionKey sectionKey;
final VoidCallback? onPressed; final VoidCallback? onPressed;
@ -191,8 +189,8 @@ class _SectionSelectingLeading extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sectionEntries = context.watch<CollectionLens>().sections[sectionKey]!; final sectionEntries = context.watch<SectionedListLayout<T>>().sections[sectionKey]!;
final selection = context.watch<Selection<AvesEntry>>(); final selection = context.watch<Selection<T>>();
final isSelected = selection.isSelected(sectionEntries); final isSelected = selection.isSelected(sectionEntries);
return AnimatedSwitcher( return AnimatedSwitcher(
duration: Durations.sectionHeaderAnimation, 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 { class GridSelectionGestureDetector<T> extends StatefulWidget {
final bool selectable; final bool selectable;
final List<T> entries; final List<T> items;
final ScrollController scrollController; final ScrollController scrollController;
final ValueNotifier<double> appBarHeightNotifier; final ValueNotifier<double> appBarHeightNotifier;
final Widget child; final Widget child;
@ -19,7 +19,7 @@ class GridSelectionGestureDetector<T> extends StatefulWidget {
const GridSelectionGestureDetector({ const GridSelectionGestureDetector({
Key? key, Key? key,
this.selectable = true, this.selectable = true,
required this.entries, required this.items,
required this.scrollController, required this.scrollController,
required this.appBarHeightNotifier, required this.appBarHeightNotifier,
required this.child, required this.child,
@ -37,7 +37,7 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
late double _scrollSpeedFactor; late double _scrollSpeedFactor;
Timer? _updateTimer; Timer? _updateTimer;
List<T> get entries => widget.entries; List<T> get items => widget.items;
ScrollController get scrollController => widget.scrollController; ScrollController get scrollController => widget.scrollController;
@ -49,16 +49,17 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final selectable = widget.selectable;
return GestureDetector( return GestureDetector(
onLongPressStart: widget.selectable onLongPressStart: selectable
? (details) { ? (details) {
final fromEntry = _getEntryAt(details.localPosition); final fromItem = _getItemAt(details.localPosition);
if (fromEntry == null) return; if (fromItem == null) return;
final selection = context.read<Selection<T>>(); final selection = context.read<Selection<T>>();
selection.toggleSelection(fromEntry); selection.toggleSelection(fromItem);
_selecting = selection.isSelected([fromEntry]); _selecting = selection.isSelected([fromItem]);
_fromIndex = entries.indexOf(fromEntry); _fromIndex = items.indexOf(fromItem);
_lastToIndex = _fromIndex; _lastToIndex = _fromIndex;
_scrollableInsets = EdgeInsets.only( _scrollableInsets = EdgeInsets.only(
top: appBarHeight, top: appBarHeight,
@ -68,20 +69,29 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
_pressing = true; _pressing = true;
} }
: null, : null,
onLongPressMoveUpdate: widget.selectable onLongPressMoveUpdate: selectable
? (details) { ? (details) {
if (!_pressing) return; if (!_pressing) return;
_localPosition = details.localPosition; _localPosition = details.localPosition;
_onLongPressUpdate(); _onLongPressUpdate();
} }
: null, : null,
onLongPressEnd: widget.selectable onLongPressEnd: selectable
? (details) { ? (details) {
if (!_pressing) return; if (!_pressing) return;
_setScrollSpeed(0); _setScrollSpeed(0);
_pressing = false; _pressing = false;
} }
: null, : 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, child: widget.child,
); );
} }
@ -100,9 +110,9 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
_setScrollSpeed(0); _setScrollSpeed(0);
} }
final toEntry = _getEntryAt(_localPosition); final toItem = _getItemAt(_localPosition);
if (toEntry != null) { if (toItem != null) {
_toggleSelectionToIndex(entries.indexOf(toEntry)); _toggleSelectionToIndex(items.indexOf(toItem));
} }
} }
@ -126,16 +136,16 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
duration: Duration(milliseconds: millis.round()), duration: Duration(milliseconds: millis.round()),
curve: Curves.linear, 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 // is not called when the pointer stays still while the view is scrolling
_updateTimer = Timer.periodic(scrollUpdateInterval, (_) => _onLongPressUpdate()); _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, // 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, // 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 offset = Offset(0, scrollController.offset - appBarHeight) + localPosition;
final sectionedListLayout = context.read<SectionedListLayout<T>>(); final sectionedListLayout = context.read<SectionedListLayout<T>>();
return sectionedListLayout.getItemAt(offset); return sectionedListLayout.getItemAt(offset);
@ -148,26 +158,26 @@ class _GridSelectionGestureDetectorState<T> extends State<GridSelectionGestureDe
if (_selecting) { if (_selecting) {
if (toIndex <= _fromIndex) { if (toIndex <= _fromIndex) {
if (toIndex < _lastToIndex) { if (toIndex < _lastToIndex) {
selection.addToSelection(entries.getRange(toIndex, min(_fromIndex, _lastToIndex))); selection.addToSelection(items.getRange(toIndex, min(_fromIndex, _lastToIndex)));
if (_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) { } else if (_lastToIndex < toIndex) {
selection.removeFromSelection(entries.getRange(_lastToIndex, toIndex)); selection.removeFromSelection(items.getRange(_lastToIndex, toIndex));
} }
} else if (_fromIndex < toIndex) { } else if (_fromIndex < toIndex) {
if (_lastToIndex < 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) { if (_lastToIndex < _fromIndex) {
selection.removeFromSelection(entries.getRange(_lastToIndex, _fromIndex)); selection.removeFromSelection(items.getRange(_lastToIndex, _fromIndex));
} }
} else if (toIndex < _lastToIndex) { } else if (toIndex < _lastToIndex) {
selection.removeFromSelection(entries.getRange(toIndex + 1, _lastToIndex + 1)); selection.removeFromSelection(items.getRange(toIndex + 1, _lastToIndex + 1));
} }
} }
_lastToIndex = toIndex; _lastToIndex = toIndex;
} else { } 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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class ThumbnailTheme extends StatelessWidget { class GridTheme extends StatelessWidget {
final double extent; final double extent;
final bool? showLocation; final bool? showLocation;
final Widget child; final Widget child;
const ThumbnailTheme({ const GridTheme({
Key? key, Key? key,
required this.extent, required this.extent,
this.showLocation, this.showLocation,
@ -18,12 +18,12 @@ class ThumbnailTheme extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ProxyProvider<Settings, ThumbnailThemeData>( return ProxyProvider<Settings, GridThemeData>(
update: (_, settings, __) { update: (_, settings, __) {
final iconSize = min(28.0, (extent / 4)).roundToDouble(); final iconSize = min(28.0, (extent / 4)).roundToDouble();
final fontSize = (iconSize / 2).floorToDouble(); final fontSize = (iconSize / 2).floorToDouble();
final highlightBorderWidth = extent * .1; final highlightBorderWidth = extent * .1;
return ThumbnailThemeData( return GridThemeData(
iconSize: iconSize, iconSize: iconSize,
fontSize: fontSize, fontSize: fontSize,
highlightBorderWidth: highlightBorderWidth, highlightBorderWidth: highlightBorderWidth,
@ -37,11 +37,11 @@ class ThumbnailTheme extends StatelessWidget {
} }
} }
class ThumbnailThemeData { class GridThemeData {
final double iconSize, fontSize, highlightBorderWidth; final double iconSize, fontSize, highlightBorderWidth;
final bool showLocation, showRaw, showVideoDuration; final bool showLocation, showRaw, showVideoDuration;
const ThumbnailThemeData({ const GridThemeData({
required this.iconSize, required this.iconSize,
required this.fontSize, required this.fontSize,
required this.highlightBorderWidth, required this.highlightBorderWidth,

View file

@ -8,7 +8,7 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/basic/menu_row.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/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -209,48 +209,44 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
borderRadius: borderRadius, borderRadius: borderRadius,
child: widget.background, child: widget.background,
), ),
Tooltip( Material(
message: filter.getTooltip(context), color: hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor,
preferBelow: false, shape: RoundedRectangleBorder(
child: Material( borderRadius: borderRadius,
color: hasBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor, ),
shape: RoundedRectangleBorder( child: InkWell(
borderRadius: borderRadius, // as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`,
), // so we get the long press details from the tap instead
child: InkWell( onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null,
// as of Flutter v1.22.5, `InkWell` does not have `onLongPressStart` like `GestureDetector`, onTap: onTap != null
// so we get the long press details from the tap instead ? () {
onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null, WidgetsBinding.instance!.addPostFrameCallback((_) => onTap!(filter));
onTap: onTap != null setState(() => _tapped = true);
? () {
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!;
} }
return DecoratedBox( : null,
decoration: BoxDecoration( onLongPress: onLongPress != null ? () => onLongPress!(context, filter, _tapPosition!) : null,
border: Border.fromBorderSide(BorderSide( borderRadius: borderRadius,
color: _outlineColor, child: FutureBuilder<Color>(
width: AvesFilterChip.outlineWidth, future: _colorFuture,
)), builder: (context, snapshot) {
borderRadius: borderRadius, if (snapshot.hasData) {
), _outlineColor = snapshot.data!;
position: DecorationPosition.foreground, }
child: Padding( return DecoratedBox(
padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration(
child: content, 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/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.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:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -20,11 +20,11 @@ class VideoIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final thumbnailTheme = context.watch<ThumbnailThemeData>(); final gridTheme = context.watch<GridThemeData>();
final showDuration = thumbnailTheme.showVideoDuration; final showDuration = gridTheme.showVideoDuration;
Widget child = OverlayIcon( Widget child = OverlayIcon(
icon: entry.is360 ? AIcons.threeSixty : AIcons.videoThumb, icon: entry.is360 ? AIcons.threeSixty : AIcons.videoThumb,
size: thumbnailTheme.iconSize, size: gridTheme.iconSize,
text: showDuration ? entry.durationText : null, text: showDuration ? entry.durationText : null,
iconScale: entry.is360 && showDuration ? .9 : 1, iconScale: entry.is360 && showDuration ? .9 : 1,
); );
@ -32,7 +32,7 @@ class VideoIcon extends StatelessWidget {
child = DefaultTextStyle( child = DefaultTextStyle(
style: TextStyle( style: TextStyle(
color: Colors.grey.shade200, color: Colors.grey.shade200,
fontSize: thumbnailTheme.fontSize, fontSize: gridTheme.fontSize,
), ),
child: child, child: child,
); );
@ -48,7 +48,7 @@ class AnimatedImageIcon extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.animated, icon: AIcons.animated,
size: context.select<ThumbnailThemeData, double>((t) => t.iconSize), size: context.select<GridThemeData, double>((t) => t.iconSize),
iconScale: .8, iconScale: .8,
); );
} }
@ -61,7 +61,7 @@ class GeotiffIcon extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.geo, 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) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.threeSixty, 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) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.location, 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) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: AIcons.raw, 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) { Widget build(BuildContext context) {
return OverlayIcon( return OverlayIcon(
icon: entry.isMotionPhoto ? AIcons.motionPhoto : AIcons.multiPage, 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, 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.relativeArcToPoint(Offset(dim * 1.917, dim * -4.63), radius: Radius.circular(dim * 2.712), rotation: 112.5, clockwise: false);
path3.close(); path3.close();
canvas.drawPath( canvas.drawPath(
path0, path0,
Paint() Paint()

View file

@ -2,6 +2,7 @@ import 'dart:ui' as ui;
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/theme/durations.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/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -79,7 +80,10 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
builder: (extent) => SizedBox( builder: (extent) => SizedBox(
width: extent, width: extent,
height: extent, height: extent,
child: widget.scaledBuilder(_metadata!.item, extent), child: GridTheme(
extent: extent,
child: widget.scaledBuilder(_metadata!.item, extent),
),
), ),
center: thumbnailCenter, center: thumbnailCenter,
viewportWidth: gridWidth, 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/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/item_pick_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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -89,7 +89,7 @@ class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
Container( Container(
alignment: Alignment.center, alignment: Alignment.center,
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
child: DecoratedFilterChip( child: CoveredFilterChip(
filter: filter, filter: filter,
extent: extent, extent: extent,
coverEntry: _isCustom ? _customEntry : _recentEntry, 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/actions/move_type.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_source.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/basic/query_bar.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.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/dialogs/create_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.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:aves/widgets/filter_grids/common/filter_grid_page.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -46,37 +48,41 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget appBar = AlbumPickAppBar(
source: source,
moveType: widget.moveType,
actionDelegate: AlbumChipSetActionDelegate(),
queryNotifier: _queryNotifier,
);
return Selector<Settings, Tuple2<AlbumChipGroupFactor, ChipSortFactor>>( return Selector<Settings, Tuple2<AlbumChipGroupFactor, ChipSortFactor>>(
selector: (context, s) => Tuple2(s.albumGroupFactor, s.albumSortFactor), selector: (context, s) => Tuple2(s.albumGroupFactor, s.albumSortFactor),
builder: (context, s, child) { builder: (context, s, child) {
return StreamBuilder( return StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(), stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) => FilterGridPage<AlbumFilter>( builder: (context, snapshot) {
settingsRouteKey: AlbumListPage.routeName, final gridItems = AlbumListPage.getAlbumGridItems(context, source);
appBar: appBar, return SelectionProvider<FilterGridItem<AlbumFilter>>(
appBarHeight: AlbumPickAppBar.preferredHeight, child: FilterGridPage<AlbumFilter>(
filterSections: AlbumListPage.getAlbumEntries(context, source), settingsRouteKey: AlbumListPage.routeName,
sortFactor: settings.albumSortFactor, appBar: AlbumPickAppBar(
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, source: source,
queryNotifier: _queryNotifier, moveType: widget.moveType,
applyQuery: (filters, query) { actionDelegate: AlbumChipSetActionDelegate(gridItems),
if (query.isEmpty) return filters; queryNotifier: _queryNotifier,
query = query.toUpperCase(); ),
return filters.where((item) => (item.filter.displayName ?? item.filter.album).toUpperCase().contains(query)).toList(); appBarHeight: AlbumPickAppBar.preferredHeight,
}, sections: AlbumListPage.groupToSections(context, gridItems),
emptyBuilder: () => EmptyContent( sortFactor: settings.albumSortFactor,
icon: AIcons.album, showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
text: context.l10n.albumEmpty, selectable: false,
), queryNotifier: _queryNotifier,
onTap: (filter) => Navigator.pop<String>(context, (filter as AlbumFilter).album), 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(); FocusManager.instance.primaryFocus?.unfocus();
// wait for the popup menu to hide before proceeding with the action // 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/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.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/utils/android_file_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.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/action_delegates/album_set.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -38,32 +36,22 @@ class AlbumListPage extends StatelessWidget {
animation: androidFileUtils.appNameChangeNotifier, animation: androidFileUtils.appNameChangeNotifier,
builder: (context, child) => StreamBuilder( builder: (context, child) => StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(), stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage<AlbumFilter>( builder: (context, snapshot) {
source: source, final gridItems = getAlbumGridItems(context, source);
title: context.l10n.albumPageTitle, return FilterNavigationPage<AlbumFilter>(
sortFactor: settings.albumSortFactor, source: source,
groupable: true, title: context.l10n.albumPageTitle,
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, sortFactor: settings.albumSortFactor,
chipSetActionDelegate: AlbumChipSetActionDelegate(), groupable: true,
chipActionDelegate: AlbumChipActionDelegate(), showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
chipActionsBuilder: (filter) { actionDelegate: AlbumChipSetActionDelegate(gridItems),
final dir = VolumeRelativeDirectory.fromPath(filter.album); filterSections: groupToSections(context, gridItems),
// do not allow renaming volume root emptyBuilder: () => EmptyContent(
final canRename = dir != null && dir.relativeDir.isNotEmpty; icon: AIcons.album,
return [ text: context.l10n.albumEmpty,
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,
),
),
), ),
); );
}, },
@ -72,14 +60,13 @@ class AlbumListPage extends StatelessWidget {
// common with album selection page to move/copy entries // 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 filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getAlbumDisplayName(context, album))).toSet();
final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters); return FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
return _group(context, sorted);
} }
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 pinned = settings.pinnedFilters.whereType<AlbumFilter>();
final byPin = groupBy<FilterGridItem<AlbumFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter)); final byPin = groupBy<FilterGridItem<AlbumFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
final pinnedMapEntries = byPin[true] ?? []; final pinnedMapEntries = byPin[true] ?? [];

View file

@ -1,143 +1,112 @@
import 'dart:io'; 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/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/album.dart';
import 'package:aves/model/filters/filters.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/settings/settings.dart';
import 'package:aves/model/source/collection_source.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/image_op_events.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.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/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.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/dialogs/rename_album_dialog.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.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:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class ChipActionDelegate { class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { final Iterable<FilterGridItem<AlbumFilter>> _items;
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;
}
}
Future<void> _hide(BuildContext context, CollectionFilter filter) async { AlbumChipSetActionDelegate(Iterable<FilterGridItem<AlbumFilter>> items) : _items = items;
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 _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 @override
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { Iterable<FilterGridItem<AlbumFilter>> get allItems => _items;
super.onActionSelected(context, filter, action);
@override
ChipSortFactor get sortFactor => settings.albumSortFactor;
@override
set sortFactor(ChipSortFactor factor) => settings.albumSortFactor = factor;
@override
bool isValid(Set<AlbumFilter> filters, ChipSetAction action) {
switch (action) { switch (action) {
case ChipAction.delete: case ChipSetAction.delete:
_showDeleteDialog(context, filter as AlbumFilter); 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; break;
case ChipAction.rename: // single/multiple filters
_showRenameDialog(context, filter as AlbumFilter); case ChipSetAction.delete:
_showDeleteDialog(context, filters);
break;
// single filter
case ChipSetAction.rename:
_showRenameDialog(context, filters.first);
break; break;
default: default:
break; 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 l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
final album = filter.album; final albums = filters.map((v) => v.album).toSet();
final todoEntries = source.visibleEntries.where(filter.test).toSet(); final todoEntries = source.visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet();
final todoCount = todoEntries.length; final todoCount = todoEntries.length;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@ -145,7 +114,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
builder: (context) { builder: (context) {
return AvesDialog( return AvesDialog(
context: context, context: context,
content: Text(l10n.deleteAlbumConfirmationDialogMessage(todoCount)), content: Text(filters.length == 1 ? l10n.deleteSingleAlbumConfirmationDialogMessage(todoCount) : l10n.deleteMultiAlbumConfirmationDialogMessage(todoCount)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@ -161,7 +130,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
); );
if (confirmed == null || !confirmed) return; if (confirmed == null || !confirmed) return;
if (!await checkStoragePermissionForAlbums(context, {album})) return; if (!await checkStoragePermissionForAlbums(context, albums)) return;
source.pauseMonitoring(); source.pauseMonitoring();
showOpReport<ImageOpEvent>( showOpReport<ImageOpEvent>(
@ -180,7 +149,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
} }
// cleanup // 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/collection/thumbnail/image.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.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/filter_grid_page.dart';
import 'package:aves/widgets/filter_grids/common/overlay.dart';
import 'package:decorated_icon/decorated_icon.dart'; import 'package:decorated_icon/decorated_icon.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class DecoratedFilterChip extends StatelessWidget { class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
final CollectionFilter filter; final T filter;
final double extent, thumbnailExtent; final double extent, thumbnailExtent;
final AvesEntry? coverEntry; final AvesEntry? coverEntry;
final bool pinned, highlightable; final bool pinned;
final FilterCallback? onTap; final FilterCallback? onTap;
final OffsetFilterCallback? onLongPress;
const DecoratedFilterChip({ const CoveredFilterChip({
Key? key, Key? key,
required this.filter, required this.filter,
required this.extent, required this.extent,
double? thumbnailExtent, double? thumbnailExtent,
this.coverEntry, this.coverEntry,
this.pinned = false, this.pinned = false,
this.highlightable = true,
this.onTap, this.onTap,
this.onLongPress,
}) : thumbnailExtent = thumbnailExtent ?? extent, }) : thumbnailExtent = thumbnailExtent ?? extent,
super(key: key); super(key: key);
@ -89,41 +85,23 @@ class DecoratedFilterChip extends StatelessWidget {
extent: thumbnailExtent, extent: thumbnailExtent,
); );
final titlePadding = min<double>(4.0, extent / 32); final titlePadding = min<double>(4.0, extent / 32);
final borderRadius = BorderRadius.all(radius(extent)); return SizedBox(
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(
width: extent, width: extent,
height: 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 padding = min<double>(8.0, extent / 16);
final iconSize = min<double>(14.0, extent / 8); final iconSize = min<double>(14.0, extent / 8);
final fontSize = min<double>(14.0, extent / 6); 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 'dart:ui';
import 'package:aves/app_mode.dart';
import 'package:aves/model/covers.dart'; import 'package:aves/model/covers.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/theme/durations.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/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/item_tracker.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/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/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/aves_filter_chip.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.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/scaling.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart';
import 'package:aves/widgets/drawer/app_drawer.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/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_keys.dart';
import 'package:aves/widgets/filter_grids/common/section_layout.dart'; import 'package:aves/widgets/filter_grids/common/section_layout.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -38,28 +43,27 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
final String? settingsRouteKey; final String? settingsRouteKey;
final Widget appBar; final Widget appBar;
final double appBarHeight; final double appBarHeight;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections; final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
final ChipSortFactor sortFactor; final ChipSortFactor sortFactor;
final bool showHeaders; final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier; final ValueNotifier<String> queryNotifier;
final QueryTest<T>? applyQuery; final QueryTest<T>? applyQuery;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
final FilterCallback onTap; final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
const FilterGridPage({ const FilterGridPage({
Key? key, Key? key,
this.settingsRouteKey, this.settingsRouteKey,
required this.appBar, required this.appBar,
this.appBarHeight = kToolbarHeight, this.appBarHeight = kToolbarHeight,
required this.filterSections, required this.sections,
required this.sortFactor, required this.sortFactor,
required this.showHeaders, required this.showHeaders,
required this.selectable,
required this.queryNotifier, required this.queryNotifier,
this.applyQuery, this.applyQuery,
required this.emptyBuilder, required this.emptyBuilder,
required this.onTap, required this.onTap,
this.onLongPress,
}) : super(key: key); }) : super(key: key);
static const Color detailColor = Color(0xFFE0E0E0); static const Color detailColor = Color(0xFFE0E0E0);
@ -68,24 +72,34 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: DoubleBackPopScope( body: WillPopScope(
child: GestureAreaProtectorStack( onWillPop: () {
child: SafeArea( final selection = context.read<Selection<FilterGridItem<T>>>();
bottom: false, if (selection.isSelecting) {
child: AnimatedBuilder( selection.browse();
animation: covers, return SynchronousFuture(false);
builder: (context, child) => FilterGrid<T>( }
settingsRouteKey: settingsRouteKey, return SynchronousFuture(true);
appBar: appBar, },
appBarHeight: appBarHeight, child: DoubleBackPopScope(
filterSections: filterSections, child: GestureAreaProtectorStack(
sortFactor: sortFactor, child: SafeArea(
showHeaders: showHeaders, bottom: false,
queryNotifier: queryNotifier, child: AnimatedBuilder(
applyQuery: applyQuery, animation: covers,
emptyBuilder: emptyBuilder, builder: (context, child) => FilterGrid<T>(
onTap: onTap, settingsRouteKey: settingsRouteKey,
onLongPress: onLongPress, 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 String? settingsRouteKey;
final Widget appBar; final Widget appBar;
final double appBarHeight; final double appBarHeight;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections; final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
final ChipSortFactor sortFactor; final ChipSortFactor sortFactor;
final bool showHeaders; final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier; final ValueNotifier<String> queryNotifier;
final QueryTest<T>? applyQuery; final QueryTest<T>? applyQuery;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
final FilterCallback onTap; final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
const FilterGrid({ const FilterGrid({
Key? key, Key? key,
required this.settingsRouteKey, required this.settingsRouteKey,
required this.appBar, required this.appBar,
required this.appBarHeight, required this.appBarHeight,
required this.filterSections, required this.sections,
required this.sortFactor, required this.sortFactor,
required this.showHeaders, required this.showHeaders,
required this.selectable,
required this.queryNotifier, required this.queryNotifier,
required this.applyQuery, required this.applyQuery,
required this.emptyBuilder, required this.emptyBuilder,
required this.onTap, required this.onTap,
required this.onLongPress,
}) : super(key: key); }) : super(key: key);
@override @override
@ -152,14 +165,14 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
child: _FilterGridContent<T>( child: _FilterGridContent<T>(
appBar: widget.appBar, appBar: widget.appBar,
appBarHeight: widget.appBarHeight, appBarHeight: widget.appBarHeight,
filterSections: widget.filterSections, sections: widget.sections,
sortFactor: widget.sortFactor, sortFactor: widget.sortFactor,
showHeaders: widget.showHeaders, showHeaders: widget.showHeaders,
selectable: widget.selectable,
queryNotifier: widget.queryNotifier, queryNotifier: widget.queryNotifier,
applyQuery: widget.applyQuery, applyQuery: widget.applyQuery,
emptyBuilder: widget.emptyBuilder, emptyBuilder: widget.emptyBuilder,
onTap: widget.onTap, 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 { class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
final Widget appBar; final Widget appBar;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections; final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
final ChipSortFactor sortFactor; final ChipSortFactor sortFactor;
final bool showHeaders; final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier; final ValueNotifier<String> queryNotifier;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
final QueryTest<T>? applyQuery; final QueryTest<T>? applyQuery;
final FilterCallback onTap; final FilterCallback onTap;
final OffsetFilterCallback? onLongPress;
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0); final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0);
@ -182,14 +194,14 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
Key? key, Key? key,
required this.appBar, required this.appBar,
required double appBarHeight, required double appBarHeight,
required this.filterSections, required this.sections,
required this.sortFactor, required this.sortFactor,
required this.showHeaders, required this.showHeaders,
required this.selectable,
required this.queryNotifier, required this.queryNotifier,
required this.applyQuery, required this.applyQuery,
required this.emptyBuilder, required this.emptyBuilder,
required this.onTap, required this.onTap,
required this.onLongPress,
}) : super(key: key) { }) : super(key: key) {
_appBarHeightNotifier.value = appBarHeight; _appBarHeightNotifier.value = appBarHeight;
} }
@ -199,15 +211,15 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
return ValueListenableBuilder<String>( return ValueListenableBuilder<String>(
valueListenable: queryNotifier, valueListenable: queryNotifier,
builder: (context, query, child) { builder: (context, query, child) {
Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections; Map<ChipSectionKey, List<FilterGridItem<T>>> visibleSections;
if (applyQuery == null) { if (applyQuery == null) {
visibleFilterSections = filterSections; visibleSections = sections;
} else { } else {
visibleFilterSections = {}; visibleSections = {};
filterSections.forEach((sectionKey, sectionFilters) { sections.forEach((sectionKey, sectionFilters) {
final visibleFilters = applyQuery!(sectionFilters, query); final visibleFilters = applyQuery!(sectionFilters, query);
if (visibleFilters.isNotEmpty) { 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>( final sectionedListLayoutProvider = ValueListenableBuilder<double>(
valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier), valueListenable: context.select<TileExtentController, ValueNotifier<double>>((controller) => controller.extentNotifier),
builder: (context, tileExtent, child) { builder: (context, tileExtent, child) {
return Selector<TileExtentController, Tuple3<double, int, double>>( return GridTheme(
selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing), extent: tileExtent,
builder: (context, c, child) { child: Selector<TileExtentController, Tuple3<double, int, double>>(
final scrollableWidth = c.item1; selector: (context, c) => Tuple3(c.viewportSize.width, c.columnCount, c.spacing),
final columnCount = c.item2; builder: (context, c, child) {
final tileSpacing = c.item3; final scrollableWidth = c.item1;
// do not listen for animation delay change final columnCount = c.item2;
final controller = Provider.of<TileExtentController>(context, listen: false); final tileSpacing = c.item3;
final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget); // do not listen for animation delay change
return SectionedFilterListLayoutProvider<T>( final controller = Provider.of<TileExtentController>(context, listen: false);
sections: visibleFilterSections, final tileAnimationDelay = controller.getTileAnimationDelay(Durations.staggeredAnimationPageTarget);
showHeaders: showHeaders, return SectionedFilterListLayoutProvider<T>(
scrollableWidth: scrollableWidth, sections: visibleSections,
columnCount: columnCount, showHeaders: showHeaders,
spacing: tileSpacing, scrollableWidth: scrollableWidth,
tileExtent: tileExtent, columnCount: columnCount,
tileBuilder: (gridItem) { spacing: tileSpacing,
final filter = gridItem.filter; tileExtent: tileExtent,
final entry = gridItem.entry; tileBuilder: (gridItem) {
return MetaData( final filter = gridItem.filter;
metaData: ScalerMetadata(FilterGridItem<T>(filter, entry)), return MetaData(
child: DecoratedFilterChip( metaData: ScalerMetadata(gridItem),
key: Key(filter.key), child: FilterChipGridDecorator<T, FilterGridItem<T>>(
filter: filter, gridItem: gridItem,
extent: tileExtent, extent: tileExtent,
pinned: pinnedFilters.contains(filter), child: CoveredFilterChip(
onTap: onTap, key: Key(filter.key),
onLongPress: onLongPress, filter: filter,
), extent: tileExtent,
); pinned: pinnedFilters.contains(filter),
}, onTap: onTap,
tileAnimationDelay: tileAnimationDelay, ),
child: _FilterSectionedContent<T>( ),
appBar: appBar, );
appBarHeightNotifier: _appBarHeightNotifier, },
visibleFilterSections: visibleFilterSections, tileAnimationDelay: tileAnimationDelay,
sortFactor: sortFactor, child: _FilterSectionedContent<T>(
emptyBuilder: emptyBuilder, appBar: appBar,
scrollController: PrimaryScrollController.of(context)!, appBarHeightNotifier: _appBarHeightNotifier,
), visibleSections: visibleSections,
); sortFactor: sortFactor,
}); selectable: selectable,
emptyBuilder: emptyBuilder,
scrollController: PrimaryScrollController.of(context)!,
),
);
}),
);
}, },
); );
return sectionedListLayoutProvider; return sectionedListLayoutProvider;
@ -269,16 +287,18 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
class _FilterSectionedContent<T extends CollectionFilter> extends StatefulWidget { class _FilterSectionedContent<T extends CollectionFilter> extends StatefulWidget {
final Widget appBar; final Widget appBar;
final ValueNotifier<double> appBarHeightNotifier; final ValueNotifier<double> appBarHeightNotifier;
final Map<ChipSectionKey, List<FilterGridItem<T>>> visibleFilterSections; final Map<ChipSectionKey, List<FilterGridItem<T>>> visibleSections;
final ChipSortFactor sortFactor; final ChipSortFactor sortFactor;
final bool selectable;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
final ScrollController scrollController; final ScrollController scrollController;
const _FilterSectionedContent({ const _FilterSectionedContent({
required this.appBar, required this.appBar,
required this.appBarHeightNotifier, required this.appBarHeightNotifier,
required this.visibleFilterSections, required this.visibleSections,
required this.sortFactor, required this.sortFactor,
required this.selectable,
required this.emptyBuilder, required this.emptyBuilder,
required this.scrollController, required this.scrollController,
}); });
@ -293,7 +313,7 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
@override @override
ValueNotifier<double> get appBarHeightNotifier => widget.appBarHeightNotifier; 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; Widget Function() get emptyBuilder => widget.emptyBuilder;
@ -328,14 +348,23 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
child: scrollView, 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 { Future<void> _checkInitHighlight() async {
final highlightInfo = context.read<HighlightInfo>(); final highlightInfo = context.read<HighlightInfo>();
final filter = highlightInfo.clear(); final filter = highlightInfo.clear();
if (filter is T) { 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) { if (gridItem != null) {
await Future.delayed(Durations.highlightScrollInitDelay); await Future.delayed(Durations.highlightScrollInitDelay);
highlightInfo.trackItem(gridItem, highlightItem: filter); highlightInfo.trackItem(gridItem, highlightItem: filter);
@ -367,19 +396,18 @@ class _FilterScaler<T extends CollectionFilter> extends StatelessWidget {
extent: extent, extent: extent,
spacing: tileSpacing, spacing: tileSpacing,
borderWidth: AvesFilterChip.outlineWidth, borderWidth: AvesFilterChip.outlineWidth,
borderRadius: DecoratedFilterChip.radius(extent), borderRadius: CoveredFilterChip.radius(extent),
color: Colors.grey.shade700, color: Colors.grey.shade700,
), ),
child: child, child: child,
), ),
scaledBuilder: (item, extent) { scaledBuilder: (item, extent) {
final filter = item.filter; final filter = item.filter;
return DecoratedFilterChip( return CoveredFilterChip(
filter: filter, filter: filter,
extent: extent, extent: extent,
thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax, thumbnailExtent: context.read<TileExtentController>().effectiveExtentMax,
pinned: pinnedFilters.contains(filter), pinned: pinnedFilters.contains(filter),
highlightable: false,
); );
}, },
highlightItem: (item) => item.filter, 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/filters/filters.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.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/collection/collection_page.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart';
import 'package:aves/widgets/common/app_bar_title.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
import 'package:aves/widgets/common/basic/menu_row.dart'; import 'package:aves/widgets/filter_grids/common/app_bar.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/filter_grids/common/filter_grid_page.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/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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart';
class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget { class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
final CollectionSource source; final CollectionSource source;
final String title; final String title;
final ChipSetActionDelegate chipSetActionDelegate;
final ChipSortFactor sortFactor; final ChipSortFactor sortFactor;
final bool groupable, showHeaders; final bool groupable, showHeaders;
final ChipActionDelegate chipActionDelegate; final ChipSetActionDelegate actionDelegate;
final List<ChipAction> Function(T filter) chipActionsBuilder;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections; final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
@ -43,114 +27,54 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
required this.sortFactor, required this.sortFactor,
this.groupable = false, this.groupable = false,
this.showHeaders = false, this.showHeaders = false,
required this.chipSetActionDelegate, required this.actionDelegate,
required this.chipActionDelegate,
required this.chipActionsBuilder,
required this.filterSections, required this.filterSections,
required this.emptyBuilder, required this.emptyBuilder,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isMainMode = context.select<ValueNotifier<AppMode>, bool>((vn) => vn.value == AppMode.main); return SelectionProvider<FilterGridItem<T>>(
return FilterGridPage<T>( child: Builder(
key: const Key('filter-grid-page'), builder: (context) => FilterGridPage<T>(
appBar: SliverAppBar( key: const Key('filter-grid-page'),
title: InteractiveAppBarTitle( appBar: FilterGridAppBar<T>(
onTap: () => _goToSearch(context),
child: SourceStateAwareAppBarTitle(
title: Text(title),
source: source, source: source,
title: title,
actionDelegate: actionDelegate,
groupable: groupable,
isEmpty: filterSections.isEmpty,
), ),
), sections: filterSections,
actions: _buildActions(context), sortFactor: sortFactor,
titleSpacing: 0, showHeaders: showHeaders,
floating: true, selectable: true,
), queryNotifier: ValueNotifier(''),
filterSections: filterSections, emptyBuilder: () => ValueListenableBuilder<SourceState>(
sortFactor: sortFactor, valueListenable: source.stateNotifier,
showHeaders: showHeaders, builder: (context, sourceState, child) {
queryNotifier: ValueNotifier(''), return sourceState != SourceState.loading ? emptyBuilder() : const SizedBox.shrink();
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],
),
), ),
onTap: (filter) => _goToCollection(context, filter),
), ),
), ),
onLongPress: isMainMode ? _showMenu as OffsetFilterCallback : null,
); );
} }
void _showMenu(BuildContext context, T filter, Offset? tapPosition) async { void _goToCollection(BuildContext context, CollectionFilter filter) {
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) {
Navigator.push( Navigator.push(
context, context,
SearchPageRoute( MaterialPageRoute(
delegate: CollectionSearchDelegate( settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(
collection: CollectionLens(
source: source, source: source,
filters: [filter],
), ),
)); ),
),
);
} }
static int compareFiltersByDate(FilterGridItem<CollectionFilter> a, FilterGridItem<CollectionFilter> b) { 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); return a.filter.compareTo(b.filter);
} }
static Iterable<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Set<T> filters) { static List<FilterGridItem<T>> sort<T extends CollectionFilter>(ChipSortFactor sortFactor, CollectionSource source, Set<T> filters) {
Iterable<FilterGridItem<T>> toGridItem(CollectionSource source, Set<T> filters) { List<FilterGridItem<T>> toGridItem(CollectionSource source, Set<T> filters) {
return filters.map((filter) => FilterGridItem( return filters
filter, .map((filter) => FilterGridItem(
source.recentEntry(filter), filter,
)); source.recentEntry(filter),
))
.toList();
} }
Iterable<FilterGridItem<T>> allMapEntries = {}; List<FilterGridItem<T>> allMapEntries = [];
switch (sortFactor) { switch (sortFactor) {
case ChipSortFactor.name: case ChipSortFactor.name:
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByName); allMapEntries = toGridItem(source, filters)..sort(compareFiltersByName);
break; break;
case ChipSortFactor.date: case ChipSortFactor.date:
allMapEntries = toGridItem(source, filters).toList()..sort(compareFiltersByDate); allMapEntries = toGridItem(source, filters)..sort(compareFiltersByDate);
break; break;
case ChipSortFactor.count: case ChipSortFactor.count:
final filtersWithCount = List.of(filters.map((filter) => MapEntry(filter, source.count(filter)))); 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:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class FilterChipSectionHeader extends StatelessWidget { class FilterChipSectionHeader<T> extends StatelessWidget {
final ChipSectionKey sectionKey; final ChipSectionKey sectionKey;
const FilterChipSectionHeader({ const FilterChipSectionHeader({
@ -12,11 +12,10 @@ class FilterChipSectionHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SectionHeader( return SectionHeader<T>(
sectionKey: sectionKey, sectionKey: sectionKey,
leading: sectionKey.leading, leading: sectionKey.leading,
title: sectionKey.title, title: sectionKey.title,
selectable: false,
); );
} }

View file

@ -41,7 +41,7 @@ class SectionedFilterListLayoutProvider<T extends CollectionFilter> extends Sect
@override @override
Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent) { Widget buildHeader(BuildContext context, SectionKey sectionKey, double headerExtent) {
return FilterChipSectionHeader( return FilterChipSectionHeader<FilterGridItem<T>>(
sectionKey: sectionKey as ChipSectionKey, 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/filters.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/settings/settings.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/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.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/action_delegates/country_set.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -35,36 +33,32 @@ class CountryListPage extends StatelessWidget {
builder: (context, s, child) { builder: (context, s, child) {
return StreamBuilder( return StreamBuilder(
stream: source.eventBus.on<CountriesChangedEvent>(), stream: source.eventBus.on<CountriesChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage<LocationFilter>( builder: (context, snapshot) {
source: source, final gridItems = _getGridItems(source);
title: context.l10n.countryPageTitle, return FilterNavigationPage<LocationFilter>(
sortFactor: settings.countrySortFactor, source: source,
chipSetActionDelegate: CountryChipSetActionDelegate(), title: context.l10n.countryPageTitle,
chipActionDelegate: ChipActionDelegate(), sortFactor: settings.countrySortFactor,
chipActionsBuilder: (filter) => [ actionDelegate: CountryChipSetActionDelegate(gridItems),
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, filterSections: _groupToSections(gridItems),
ChipAction.setCover, emptyBuilder: () => EmptyContent(
ChipAction.hide, icon: AIcons.location,
], text: context.l10n.countryEmpty,
filterSections: _getCountryEntries(source), ),
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 filters = source.sortedCountries.map((location) => LocationFilter(LocationLevel.country, location)).toSet();
final sorted = FilterNavigationPage.sort(settings.countrySortFactor, source, filters); return FilterNavigationPage.sort(settings.countrySortFactor, source, filters);
return _group(sorted);
} }
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 pinned = settings.pinnedFilters.whereType<LocationFilter>();
final byPin = groupBy<FilterGridItem<LocationFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter)); final byPin = groupBy<FilterGridItem<LocationFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
final pinnedMapEntries = (byPin[true] ?? []); 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/filters.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/settings/settings.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/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/empty.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/action_delegates/tag_set.dart';
import 'package:aves/widgets/filter_grids/common/chip_set_action_delegate.dart';
import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart'; import 'package:aves/widgets/filter_grids/common/filter_nav_page.dart';
import 'package:aves/widgets/filter_grids/common/section_keys.dart'; import 'package:aves/widgets/filter_grids/common/section_keys.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -35,36 +33,32 @@ class TagListPage extends StatelessWidget {
builder: (context, s, child) { builder: (context, s, child) {
return StreamBuilder( return StreamBuilder(
stream: source.eventBus.on<TagsChangedEvent>(), stream: source.eventBus.on<TagsChangedEvent>(),
builder: (context, snapshot) => FilterNavigationPage<TagFilter>( builder: (context, snapshot) {
source: source, final gridItems = _getGridItems(source);
title: context.l10n.tagPageTitle, return FilterNavigationPage<TagFilter>(
sortFactor: settings.tagSortFactor, source: source,
chipSetActionDelegate: TagChipSetActionDelegate(), title: context.l10n.tagPageTitle,
chipActionDelegate: ChipActionDelegate(), sortFactor: settings.tagSortFactor,
chipActionsBuilder: (filter) => [ actionDelegate: TagChipSetActionDelegate(gridItems),
settings.pinnedFilters.contains(filter) ? ChipAction.unpin : ChipAction.pin, filterSections: _groupToSections(gridItems),
ChipAction.setCover, emptyBuilder: () => EmptyContent(
ChipAction.hide, icon: AIcons.tag,
], text: context.l10n.tagEmpty,
filterSections: _getTagEntries(source), ),
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 filters = source.sortedTags.map((tag) => TagFilter(tag)).toSet();
final sorted = FilterNavigationPage.sort(settings.tagSortFactor, source, filters); return FilterNavigationPage.sort(settings.tagSortFactor, source, filters);
return _group(sorted);
} }
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 pinned = settings.pinnedFilters.whereType<TagFilter>();
final byPin = groupBy<FilterGridItem<TagFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter)); final byPin = groupBy<FilterGridItem<TagFilter>, bool>(sortedMapEntries, (e) => pinned.contains(e.filter));
final pinnedMapEntries = (byPin[true] ?? []); final pinnedMapEntries = (byPin[true] ?? []);

View file

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

View file

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

View file

@ -36,7 +36,7 @@ class VideoSection extends StatelessWidget {
if (!standalonePage) if (!standalonePage)
SwitchListTile( SwitchListTile(
value: currentShowVideos, 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), title: Text(context.l10n.settingsVideoShowVideos),
), ),
const VideoActionsTile(), const VideoActionsTile(),

View file

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

View file

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

View file

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