#27 albums: action to create empty album

This commit is contained in:
Thibault Deckers 2021-07-13 18:51:09 +09:00
parent bcbb7d5994
commit 2f34c3c48d
14 changed files with 105 additions and 6 deletions

View file

@ -59,6 +59,8 @@
"@chipActionRename": {}, "@chipActionRename": {},
"chipActionSetCover": "Set cover", "chipActionSetCover": "Set cover",
"@chipActionSetCover": {}, "@chipActionSetCover": {},
"chipActionCreateAlbum": "Create album",
"@chipActionCreateAlbum": {},
"entryActionDelete": "Delete", "entryActionDelete": "Delete",
"@entryActionDelete": {}, "@entryActionDelete": {},
@ -515,6 +517,8 @@
"@createAlbumTooltip": {}, "@createAlbumTooltip": {},
"createAlbumButtonLabel": "CREATE", "createAlbumButtonLabel": "CREATE",
"@createAlbumButtonLabel": {}, "@createAlbumButtonLabel": {},
"newFilterBanner": "new",
"@newFilterBanner": {},
"countryPageTitle": "Countries", "countryPageTitle": "Countries",
"@countryPageTitle": {}, "@countryPageTitle": {},

View file

@ -29,6 +29,7 @@
"chipActionUnpin": "고정 해제", "chipActionUnpin": "고정 해제",
"chipActionRename": "이름 변경", "chipActionRename": "이름 변경",
"chipActionSetCover": "대표 이미지 변경", "chipActionSetCover": "대표 이미지 변경",
"chipActionCreateAlbum": "앨범 만들기",
"entryActionDelete": "삭제", "entryActionDelete": "삭제",
"entryActionExport": "내보내기", "entryActionExport": "내보내기",
@ -232,8 +233,9 @@
"albumPageTitle": "앨범", "albumPageTitle": "앨범",
"albumEmpty": "앨범이 없습니다", "albumEmpty": "앨범이 없습니다",
"createAlbumTooltip": "앨범 만들기", "createAlbumTooltip": "앨범 만들기",
"createAlbumButtonLabel": "추가", "createAlbumButtonLabel": "추가",
"newFilterBanner": "신규",
"countryPageTitle": "국가", "countryPageTitle": "국가",
"countryEmpty": "국가가 없습니다", "countryEmpty": "국가가 없습니다",

View file

@ -10,6 +10,7 @@ enum ChipSetAction {
selectAll, selectAll,
selectNone, selectNone,
stats, stats,
createAlbum,
// single/multiple filters // single/multiple filters
delete, delete,
hide, hide,
@ -36,6 +37,8 @@ extension ExtraChipSetAction on ChipSetAction {
return context.l10n.collectionActionSelectNone; return context.l10n.collectionActionSelectNone;
case ChipSetAction.stats: case ChipSetAction.stats:
return context.l10n.menuActionStats; return context.l10n.menuActionStats;
case ChipSetAction.createAlbum:
return context.l10n.chipActionCreateAlbum;
// single/multiple filters // single/multiple filters
case ChipSetAction.delete: case ChipSetAction.delete:
return context.l10n.chipActionDelete; return context.l10n.chipActionDelete;
@ -67,6 +70,8 @@ extension ExtraChipSetAction on ChipSetAction {
return null; return null;
case ChipSetAction.stats: case ChipSetAction.stats:
return AIcons.stats; return AIcons.stats;
case ChipSetAction.createAlbum:
return AIcons.createAlbum;
// single/multiple filters // single/multiple filters
case ChipSetAction.delete: case ChipSetAction.delete:
return AIcons.delete; return AIcons.delete;

View file

@ -10,9 +10,12 @@ import 'package:flutter/widgets.dart';
mixin AlbumMixin on SourceBase { mixin AlbumMixin on SourceBase {
final Set<String?> _directories = {}; final Set<String?> _directories = {};
final Set<String> _newAlbums = {};
List<String> get rawAlbums => List.unmodifiable(_directories); List<String> get rawAlbums => List.unmodifiable(_directories);
Set<AlbumFilter> getNewAlbumFilters(BuildContext context) => Set.unmodifiable(_newAlbums.map((v) => AlbumFilter(v, getAlbumDisplayName(context, v))));
int compareAlbumsByName(String a, String b) { int compareAlbumsByName(String a, String b) {
final ua = getAlbumDisplayName(null, a); final ua = getAlbumDisplayName(null, a);
final ub = getAlbumDisplayName(null, b); final ub = getAlbumDisplayName(null, b);
@ -109,7 +112,7 @@ mixin AlbumMixin on SourceBase {
} }
void cleanEmptyAlbums([Set<String?>? albums]) { void cleanEmptyAlbums([Set<String?>? albums]) {
final emptyAlbums = (albums ?? _directories).where(_isEmptyAlbum).toSet(); final emptyAlbums = (albums ?? _directories).where((v) => _isEmptyAlbum(v) && !_newAlbums.contains(v)).toSet();
if (emptyAlbums.isNotEmpty) { if (emptyAlbums.isNotEmpty) {
_directories.removeAll(emptyAlbums); _directories.removeAll(emptyAlbums);
_notifyAlbumChange(); _notifyAlbumChange();
@ -148,6 +151,22 @@ mixin AlbumMixin on SourceBase {
AvesEntry? albumRecentEntry(AlbumFilter filter) { AvesEntry? albumRecentEntry(AlbumFilter filter) {
return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhereOrNull(filter.test)); 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<String> directories) {
_newAlbums.removeAll(directories);
}
} }
class AlbumsChangedEvent {} class AlbumsChangedEvent {}

View file

@ -162,6 +162,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
final pinned = settings.pinnedFilters.contains(oldFilter); final pinned = settings.pinnedFilters.contains(oldFilter);
final oldCoverContentId = covers.coverContentId(oldFilter); final oldCoverContentId = covers.coverContentId(oldFilter);
final coverEntry = oldCoverContentId != null ? todoEntries.firstWhereOrNull((entry) => entry.contentId == oldCoverContentId) : null; final coverEntry = oldCoverContentId != null ? todoEntries.firstWhereOrNull((entry) => entry.contentId == oldCoverContentId) : null;
renameNewAlbum(sourceAlbum, destinationAlbum);
await updateAfterMove( await updateAfterMove(
todoEntries: todoEntries, todoEntries: todoEntries,
copy: false, copy: false,

View file

@ -23,6 +23,7 @@ class AvesFilterChip extends StatefulWidget {
final bool removable; final bool removable;
final bool showGenericIcon; final bool showGenericIcon;
final Widget? background; final Widget? background;
final String? banner;
final Widget? details; final Widget? details;
final BorderRadius? borderRadius; final BorderRadius? borderRadius;
final double padding; final double padding;
@ -43,6 +44,7 @@ class AvesFilterChip extends StatefulWidget {
this.removable = false, this.removable = false,
this.showGenericIcon = true, this.showGenericIcon = true,
this.background, this.background,
this.banner,
this.details, this.details,
this.borderRadius, this.borderRadius,
this.padding = 6.0, this.padding = 6.0,
@ -195,6 +197,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
} }
final borderRadius = widget.borderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)); final borderRadius = widget.borderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius));
final banner = widget.banner;
Widget chip = Container( Widget chip = Container(
constraints: const BoxConstraints( constraints: const BoxConstraints(
minWidth: AvesFilterChip.minChipWidth, minWidth: AvesFilterChip.minChipWidth,
@ -250,6 +253,21 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
), ),
), ),
), ),
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(),
),
),
);
}),
], ],
), ),
); );

View file

@ -66,6 +66,7 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
), ),
appBarHeight: AlbumPickAppBar.preferredHeight, appBarHeight: AlbumPickAppBar.preferredHeight,
sections: AlbumListPage.groupToSections(context, gridItems), sections: AlbumListPage.groupToSections(context, gridItems),
newFilters: source.getNewAlbumFilters(context),
sortFactor: settings.albumSortFactor, sortFactor: settings.albumSortFactor,
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
selectable: false, selectable: false,

View file

@ -46,6 +46,7 @@ class AlbumListPage extends StatelessWidget {
showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none,
actionDelegate: AlbumChipSetActionDelegate(gridItems), actionDelegate: AlbumChipSetActionDelegate(gridItems),
filterSections: groupToSections(context, gridItems), filterSections: groupToSections(context, gridItems),
newFilters: source.getNewAlbumFilters(context),
emptyBuilder: () => EmptyContent( emptyBuilder: () => EmptyContent(
icon: AIcons.album, icon: AIcons.album,
text: context.l10n.albumEmpty, text: context.l10n.albumEmpty,

View file

@ -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/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/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/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/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/aves_selection_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/dialogs/rename_album_dialog.dart';
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.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/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -67,6 +70,9 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
case ChipSetAction.group: case ChipSetAction.group:
_showGroupDialog(context); _showGroupDialog(context);
break; break;
case ChipSetAction.createAlbum:
_createAlbum(context);
break;
// single/multiple filters // single/multiple filters
case ChipSetAction.delete: case ChipSetAction.delete:
_showDeleteDialog(context, filters); _showDeleteDialog(context, filters);
@ -101,13 +107,35 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
} }
} }
void _createAlbum(BuildContext context) async {
final newAlbum = await showDialog<String>(
context: context,
builder: (context) => const CreateAlbumDialog(),
);
if (newAlbum != null && newAlbum.isNotEmpty) {
final source = context.read<CollectionSource>();
source.createAlbum(newAlbum);
final showAction = SnackBarAction(
label: context.l10n.showButtonLabel,
onPressed: () async {
final filter = AlbumFilter(newAlbum, source.getAlbumDisplayName(context, newAlbum));
context.read<HighlightInfo>().trackItem(FilterGridItem(filter, null), highlightItem: filter);
},
);
showFeedback(context, context.l10n.genericSuccessFeedback, showAction);
}
}
Future<void> _showDeleteDialog(BuildContext context, Set<AlbumFilter> filters) async { 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 albums = filters.map((v) => v.album).toSet();
final todoEntries = source.visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet(); final todoEntries = source.visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet();
final todoCount = todoEntries.length; 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<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@ -130,7 +158,10 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
); );
if (confirmed == null || !confirmed) return; 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(); source.pauseMonitoring();
showOpReport<ImageOpEvent>( showOpReport<ImageOpEvent>(
@ -149,7 +180,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
} }
// cleanup // cleanup
await storageService.deleteEmptyDirectories(albums); await storageService.deleteEmptyDirectories(filledAlbums);
}, },
); );
} }

View file

@ -52,6 +52,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
case ChipSetAction.selectAll: case ChipSetAction.selectAll:
case ChipSetAction.selectNone: case ChipSetAction.selectNone:
case ChipSetAction.stats: case ChipSetAction.stats:
case ChipSetAction.createAlbum:
return true; return true;
// single/multiple filters // single/multiple filters
case ChipSetAction.delete: case ChipSetAction.delete:

View file

@ -205,6 +205,7 @@ class _FilterGridAppBarState<T extends CollectionFilter> extends State<FilterGri
enabled: !widget.isEmpty, enabled: !widget.isEmpty,
), ),
toMenuItem(ChipSetAction.stats), toMenuItem(ChipSetAction.stats),
toMenuItem(ChipSetAction.createAlbum),
]); ]);
} }

View file

@ -25,6 +25,7 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
final double extent, thumbnailExtent; final double extent, thumbnailExtent;
final AvesEntry? coverEntry; final AvesEntry? coverEntry;
final bool pinned; final bool pinned;
final String? banner;
final FilterCallback? onTap; final FilterCallback? onTap;
const CoveredFilterChip({ const CoveredFilterChip({
@ -34,6 +35,7 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
double? thumbnailExtent, double? thumbnailExtent,
this.coverEntry, this.coverEntry,
this.pinned = false, this.pinned = false,
this.banner,
this.onTap, this.onTap,
}) : thumbnailExtent = thumbnailExtent ?? extent, }) : thumbnailExtent = thumbnailExtent ?? extent,
super(key: key); super(key: key);
@ -42,7 +44,7 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<CollectionSource>( return Consumer<CollectionSource>(
builder: (context, source, child) { builder: (context, source, child) {
switch (filter.runtimeType) { switch (T) {
case AlbumFilter: case AlbumFilter:
{ {
final album = (filter as AlbumFilter).album; final album = (filter as AlbumFilter).album;
@ -92,6 +94,7 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
filter: filter, filter: filter,
showGenericIcon: false, showGenericIcon: false,
background: backgroundImage, background: backgroundImage,
banner: banner,
details: _buildDetails(source, filter), details: _buildDetails(source, filter),
borderRadius: BorderRadius.all(radius(extent)), borderRadius: BorderRadius.all(radius(extent)),
padding: titlePadding, padding: titlePadding,

View file

@ -44,6 +44,7 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
final Widget appBar; final Widget appBar;
final double appBarHeight; final double appBarHeight;
final Map<ChipSectionKey, List<FilterGridItem<T>>> sections; final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
final Set<T> newFilters;
final ChipSortFactor sortFactor; final ChipSortFactor sortFactor;
final bool showHeaders, selectable; final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier; final ValueNotifier<String> queryNotifier;
@ -57,6 +58,7 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
required this.appBar, required this.appBar,
this.appBarHeight = kToolbarHeight, this.appBarHeight = kToolbarHeight,
required this.sections, required this.sections,
required this.newFilters,
required this.sortFactor, required this.sortFactor,
required this.showHeaders, required this.showHeaders,
required this.selectable, required this.selectable,
@ -92,6 +94,7 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
appBar: appBar, appBar: appBar,
appBarHeight: appBarHeight, appBarHeight: appBarHeight,
sections: sections, sections: sections,
newFilters: newFilters,
sortFactor: sortFactor, sortFactor: sortFactor,
showHeaders: showHeaders, showHeaders: showHeaders,
selectable: selectable, selectable: selectable,
@ -117,6 +120,7 @@ class FilterGrid<T extends CollectionFilter> extends StatefulWidget {
final Widget appBar; final Widget appBar;
final double appBarHeight; final double appBarHeight;
final Map<ChipSectionKey, List<FilterGridItem<T>>> sections; final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
final Set<T> newFilters;
final ChipSortFactor sortFactor; final ChipSortFactor sortFactor;
final bool showHeaders, selectable; final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier; final ValueNotifier<String> queryNotifier;
@ -130,6 +134,7 @@ class FilterGrid<T extends CollectionFilter> extends StatefulWidget {
required this.appBar, required this.appBar,
required this.appBarHeight, required this.appBarHeight,
required this.sections, required this.sections,
required this.newFilters,
required this.sortFactor, required this.sortFactor,
required this.showHeaders, required this.showHeaders,
required this.selectable, required this.selectable,
@ -166,6 +171,7 @@ class _FilterGridState<T extends CollectionFilter> extends State<FilterGrid<T>>
appBar: widget.appBar, appBar: widget.appBar,
appBarHeight: widget.appBarHeight, appBarHeight: widget.appBarHeight,
sections: widget.sections, sections: widget.sections,
newFilters: widget.newFilters,
sortFactor: widget.sortFactor, sortFactor: widget.sortFactor,
showHeaders: widget.showHeaders, showHeaders: widget.showHeaders,
selectable: widget.selectable, selectable: widget.selectable,
@ -181,6 +187,7 @@ 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>>> sections; final Map<ChipSectionKey, List<FilterGridItem<T>>> sections;
final Set<T> newFilters;
final ChipSortFactor sortFactor; final ChipSortFactor sortFactor;
final bool showHeaders, selectable; final bool showHeaders, selectable;
final ValueNotifier<String> queryNotifier; final ValueNotifier<String> queryNotifier;
@ -195,6 +202,7 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
required this.appBar, required this.appBar,
required double appBarHeight, required double appBarHeight,
required this.sections, required this.sections,
required this.newFilters,
required this.sortFactor, required this.sortFactor,
required this.showHeaders, required this.showHeaders,
required this.selectable, required this.selectable,
@ -258,6 +266,7 @@ class _FilterGridContent<T extends CollectionFilter> extends StatelessWidget {
filter: filter, filter: filter,
extent: tileExtent, extent: tileExtent,
pinned: pinnedFilters.contains(filter), pinned: pinnedFilters.contains(filter),
banner: newFilters.contains(filter) ? context.l10n.newFilterBanner : null,
onTap: onTap, onTap: onTap,
), ),
), ),

View file

@ -18,6 +18,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
final bool groupable, showHeaders; final bool groupable, showHeaders;
final ChipSetActionDelegate actionDelegate; final ChipSetActionDelegate actionDelegate;
final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections; final Map<ChipSectionKey, List<FilterGridItem<T>>> filterSections;
final Set<T>? newFilters;
final Widget Function() emptyBuilder; final Widget Function() emptyBuilder;
const FilterNavigationPage({ const FilterNavigationPage({
@ -29,6 +30,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
this.showHeaders = false, this.showHeaders = false,
required this.actionDelegate, required this.actionDelegate,
required this.filterSections, required this.filterSections,
this.newFilters,
required this.emptyBuilder, required this.emptyBuilder,
}) : super(key: key); }) : super(key: key);
@ -46,6 +48,7 @@ class FilterNavigationPage<T extends CollectionFilter> extends StatelessWidget {
isEmpty: filterSections.isEmpty, isEmpty: filterSections.isEmpty,
), ),
sections: filterSections, sections: filterSections,
newFilters: newFilters ?? {},
sortFactor: sortFactor, sortFactor: sortFactor,
showHeaders: showHeaders, showHeaders: showHeaders,
selectable: true, selectable: true,