diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c488f00e9..9a64af079 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -59,6 +59,8 @@ "@chipActionRename": {}, "chipActionSetCover": "Set cover", "@chipActionSetCover": {}, + "chipActionCreateAlbum": "Create album", + "@chipActionCreateAlbum": {}, "entryActionDelete": "Delete", "@entryActionDelete": {}, @@ -515,6 +517,8 @@ "@createAlbumTooltip": {}, "createAlbumButtonLabel": "CREATE", "@createAlbumButtonLabel": {}, + "newFilterBanner": "new", + "@newFilterBanner": {}, "countryPageTitle": "Countries", "@countryPageTitle": {}, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 44f353772..96def8ff9 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -29,6 +29,7 @@ "chipActionUnpin": "고정 해제", "chipActionRename": "이름 변경", "chipActionSetCover": "대표 이미지 변경", + "chipActionCreateAlbum": "앨범 만들기", "entryActionDelete": "삭제", "entryActionExport": "내보내기", @@ -232,8 +233,9 @@ "albumPageTitle": "앨범", "albumEmpty": "앨범이 없습니다", - "createAlbumTooltip": "새 앨범 만들기", + "createAlbumTooltip": "앨범 만들기", "createAlbumButtonLabel": "추가", + "newFilterBanner": "신규", "countryPageTitle": "국가", "countryEmpty": "국가가 없습니다", diff --git a/lib/model/actions/chip_set_actions.dart b/lib/model/actions/chip_set_actions.dart index 7a093da48..fcfa6ab36 100644 --- a/lib/model/actions/chip_set_actions.dart +++ b/lib/model/actions/chip_set_actions.dart @@ -10,6 +10,7 @@ enum ChipSetAction { selectAll, selectNone, stats, + createAlbum, // single/multiple filters delete, hide, @@ -36,6 +37,8 @@ extension ExtraChipSetAction on ChipSetAction { return context.l10n.collectionActionSelectNone; case ChipSetAction.stats: return context.l10n.menuActionStats; + case ChipSetAction.createAlbum: + return context.l10n.chipActionCreateAlbum; // single/multiple filters case ChipSetAction.delete: return context.l10n.chipActionDelete; @@ -67,6 +70,8 @@ extension ExtraChipSetAction on ChipSetAction { return null; case ChipSetAction.stats: return AIcons.stats; + case ChipSetAction.createAlbum: + return AIcons.createAlbum; // single/multiple filters case ChipSetAction.delete: return AIcons.delete; diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index cc5d8d0f9..e11ad2012 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -10,9 +10,12 @@ import 'package:flutter/widgets.dart'; mixin AlbumMixin on SourceBase { final Set _directories = {}; + final Set _newAlbums = {}; List get rawAlbums => List.unmodifiable(_directories); + Set getNewAlbumFilters(BuildContext context) => Set.unmodifiable(_newAlbums.map((v) => AlbumFilter(v, getAlbumDisplayName(context, v)))); + int compareAlbumsByName(String a, String b) { final ua = getAlbumDisplayName(null, a); final ub = getAlbumDisplayName(null, b); @@ -109,7 +112,7 @@ mixin AlbumMixin on SourceBase { } void cleanEmptyAlbums([Set? albums]) { - final emptyAlbums = (albums ?? _directories).where(_isEmptyAlbum).toSet(); + final emptyAlbums = (albums ?? _directories).where((v) => _isEmptyAlbum(v) && !_newAlbums.contains(v)).toSet(); if (emptyAlbums.isNotEmpty) { _directories.removeAll(emptyAlbums); _notifyAlbumChange(); @@ -148,6 +151,22 @@ mixin AlbumMixin on SourceBase { AvesEntry? albumRecentEntry(AlbumFilter filter) { return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhereOrNull(filter.test)); } + + void createAlbum(String directory) { + _newAlbums.add(directory); + addDirectories({directory}); + } + + void renameNewAlbum(String source, String destination) { + if (_newAlbums.remove(source)) { + cleanEmptyAlbums({source}); + createAlbum(destination); + } + } + + void forgetNewAlbums(Set directories) { + _newAlbums.removeAll(directories); + } } class AlbumsChangedEvent {} diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 01506ab53..f767408c5 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -162,6 +162,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM final pinned = settings.pinnedFilters.contains(oldFilter); final oldCoverContentId = covers.coverContentId(oldFilter); final coverEntry = oldCoverContentId != null ? todoEntries.firstWhereOrNull((entry) => entry.contentId == oldCoverContentId) : null; + renameNewAlbum(sourceAlbum, destinationAlbum); await updateAfterMove( todoEntries: todoEntries, copy: false, diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index fde4e2165..d43fa431a 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -23,6 +23,7 @@ class AvesFilterChip extends StatefulWidget { final bool removable; final bool showGenericIcon; final Widget? background; + final String? banner; final Widget? details; final BorderRadius? borderRadius; final double padding; @@ -43,6 +44,7 @@ class AvesFilterChip extends StatefulWidget { this.removable = false, this.showGenericIcon = true, this.background, + this.banner, this.details, this.borderRadius, this.padding = 6.0, @@ -195,6 +197,7 @@ class _AvesFilterChipState extends State { } final borderRadius = widget.borderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)); + final banner = widget.banner; Widget chip = Container( constraints: const BoxConstraints( minWidth: AvesFilterChip.minChipWidth, @@ -250,6 +253,21 @@ class _AvesFilterChipState extends State { ), ), ), + if (banner != null) + LayoutBuilder(builder: (context, constraints) { + return ClipRRect( + borderRadius: borderRadius, + child: Transform( + transform: Matrix4.identity().scaled((constraints.maxHeight / 90 - .4).clamp(.45, 1.0)), + child: Banner( + message: banner.toUpperCase(), + location: BannerLocation.topStart, + color: Theme.of(context).accentColor, + child: const SizedBox(), + ), + ), + ); + }), ], ), ); diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index 76c39573a..0cf21bef7 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -66,6 +66,7 @@ class _AlbumPickPageState extends State { ), appBarHeight: AlbumPickAppBar.preferredHeight, sections: AlbumListPage.groupToSections(context, gridItems), + newFilters: source.getNewAlbumFilters(context), sortFactor: settings.albumSortFactor, showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, selectable: false, diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index f7883694e..0f5cfccfc 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -46,6 +46,7 @@ class AlbumListPage extends StatelessWidget { showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, actionDelegate: AlbumChipSetActionDelegate(gridItems), filterSections: groupToSections(context, gridItems), + newFilters: source.getNewAlbumFilters(context), emptyBuilder: () => EmptyContent( icon: AIcons.album, text: context.l10n.albumEmpty, diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index 89d2d4c96..f14b9d5a9 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -4,6 +4,7 @@ import 'package:aves/model/actions/chip_set_actions.dart'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/highlight.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; @@ -14,8 +15,10 @@ import 'package:aves/utils/android_file_utils.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/create_album_dialog.dart'; import 'package:aves/widgets/dialogs/rename_album_dialog.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; @@ -67,6 +70,9 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { case ChipSetAction.group: _showGroupDialog(context); break; + case ChipSetAction.createAlbum: + _createAlbum(context); + break; // single/multiple filters case ChipSetAction.delete: _showDeleteDialog(context, filters); @@ -101,13 +107,35 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { } } + void _createAlbum(BuildContext context) async { + final newAlbum = await showDialog( + context: context, + builder: (context) => const CreateAlbumDialog(), + ); + if (newAlbum != null && newAlbum.isNotEmpty) { + final source = context.read(); + source.createAlbum(newAlbum); + + final showAction = SnackBarAction( + label: context.l10n.showButtonLabel, + onPressed: () async { + final filter = AlbumFilter(newAlbum, source.getAlbumDisplayName(context, newAlbum)); + context.read().trackItem(FilterGridItem(filter, null), highlightItem: filter); + }, + ); + showFeedback(context, context.l10n.genericSuccessFeedback, showAction); + } + } + Future _showDeleteDialog(BuildContext context, Set filters) async { final l10n = context.l10n; final messenger = ScaffoldMessenger.of(context); final source = context.read(); - final albums = filters.map((v) => v.album).toSet(); final todoEntries = source.visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet(); final todoCount = todoEntries.length; + final todoAlbums = filters.map((v) => v.album).toSet(); + final filledAlbums = todoEntries.map((e) => e.directory).whereNotNull().toSet(); + final emptyAlbums = todoAlbums.whereNot(filledAlbums.contains).toSet(); final confirmed = await showDialog( context: context, @@ -130,7 +158,10 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { ); if (confirmed == null || !confirmed) return; - if (!await checkStoragePermissionForAlbums(context, albums)) return; + source.forgetNewAlbums(todoAlbums); + source.cleanEmptyAlbums(emptyAlbums); + + if (!await checkStoragePermissionForAlbums(context, filledAlbums)) return; source.pauseMonitoring(); showOpReport( @@ -149,7 +180,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate { } // cleanup - await storageService.deleteEmptyDirectories(albums); + await storageService.deleteEmptyDirectories(filledAlbums); }, ); } diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index c8109d735..99333753e 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -52,6 +52,7 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.selectAll: case ChipSetAction.selectNone: case ChipSetAction.stats: + case ChipSetAction.createAlbum: return true; // single/multiple filters case ChipSetAction.delete: diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart index 4fb57ba99..00d4c23f9 100644 --- a/lib/widgets/filter_grids/common/app_bar.dart +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -205,6 +205,7 @@ class _FilterGridAppBarState extends State extends StatelessWidget { final double extent, thumbnailExtent; final AvesEntry? coverEntry; final bool pinned; + final String? banner; final FilterCallback? onTap; const CoveredFilterChip({ @@ -34,6 +35,7 @@ class CoveredFilterChip extends StatelessWidget { double? thumbnailExtent, this.coverEntry, this.pinned = false, + this.banner, this.onTap, }) : thumbnailExtent = thumbnailExtent ?? extent, super(key: key); @@ -42,7 +44,7 @@ class CoveredFilterChip extends StatelessWidget { Widget build(BuildContext context) { return Consumer( builder: (context, source, child) { - switch (filter.runtimeType) { + switch (T) { case AlbumFilter: { final album = (filter as AlbumFilter).album; @@ -92,6 +94,7 @@ class CoveredFilterChip extends StatelessWidget { filter: filter, showGenericIcon: false, background: backgroundImage, + banner: banner, details: _buildDetails(source, filter), borderRadius: BorderRadius.all(radius(extent)), padding: titlePadding, diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index e39cd6190..9e6c746ed 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -44,6 +44,7 @@ class FilterGridPage extends StatelessWidget { final Widget appBar; final double appBarHeight; final Map>> sections; + final Set newFilters; final ChipSortFactor sortFactor; final bool showHeaders, selectable; final ValueNotifier queryNotifier; @@ -57,6 +58,7 @@ class FilterGridPage extends StatelessWidget { required this.appBar, this.appBarHeight = kToolbarHeight, required this.sections, + required this.newFilters, required this.sortFactor, required this.showHeaders, required this.selectable, @@ -92,6 +94,7 @@ class FilterGridPage extends StatelessWidget { appBar: appBar, appBarHeight: appBarHeight, sections: sections, + newFilters: newFilters, sortFactor: sortFactor, showHeaders: showHeaders, selectable: selectable, @@ -117,6 +120,7 @@ class FilterGrid extends StatefulWidget { final Widget appBar; final double appBarHeight; final Map>> sections; + final Set newFilters; final ChipSortFactor sortFactor; final bool showHeaders, selectable; final ValueNotifier queryNotifier; @@ -130,6 +134,7 @@ class FilterGrid extends StatefulWidget { required this.appBar, required this.appBarHeight, required this.sections, + required this.newFilters, required this.sortFactor, required this.showHeaders, required this.selectable, @@ -166,6 +171,7 @@ class _FilterGridState extends State> appBar: widget.appBar, appBarHeight: widget.appBarHeight, sections: widget.sections, + newFilters: widget.newFilters, sortFactor: widget.sortFactor, showHeaders: widget.showHeaders, selectable: widget.selectable, @@ -181,6 +187,7 @@ class _FilterGridState extends State> class _FilterGridContent extends StatelessWidget { final Widget appBar; final Map>> sections; + final Set newFilters; final ChipSortFactor sortFactor; final bool showHeaders, selectable; final ValueNotifier queryNotifier; @@ -195,6 +202,7 @@ class _FilterGridContent extends StatelessWidget { required this.appBar, required double appBarHeight, required this.sections, + required this.newFilters, required this.sortFactor, required this.showHeaders, required this.selectable, @@ -258,6 +266,7 @@ class _FilterGridContent extends StatelessWidget { filter: filter, extent: tileExtent, pinned: pinnedFilters.contains(filter), + banner: newFilters.contains(filter) ? context.l10n.newFilterBanner : null, onTap: onTap, ), ), diff --git a/lib/widgets/filter_grids/common/filter_nav_page.dart b/lib/widgets/filter_grids/common/filter_nav_page.dart index 1a68b7a6f..e073e4f5d 100644 --- a/lib/widgets/filter_grids/common/filter_nav_page.dart +++ b/lib/widgets/filter_grids/common/filter_nav_page.dart @@ -18,6 +18,7 @@ class FilterNavigationPage extends StatelessWidget { final bool groupable, showHeaders; final ChipSetActionDelegate actionDelegate; final Map>> filterSections; + final Set? newFilters; final Widget Function() emptyBuilder; const FilterNavigationPage({ @@ -29,6 +30,7 @@ class FilterNavigationPage extends StatelessWidget { this.showHeaders = false, required this.actionDelegate, required this.filterSections, + this.newFilters, required this.emptyBuilder, }) : super(key: key); @@ -46,6 +48,7 @@ class FilterNavigationPage extends StatelessWidget { isEmpty: filterSections.isEmpty, ), sections: filterSections, + newFilters: newFilters ?? {}, sortFactor: sortFactor, showHeaders: showHeaders, selectable: true,