From 303425e699019a43454143303b149ddf83ef80b7 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 3 Dec 2024 00:25:12 +0100 Subject: [PATCH] #1107 dynamic albums --- CHANGELOG.md | 4 + lib/l10n/app_en.arb | 7 + lib/model/covers.dart | 66 +++++- lib/model/db/db.dart | 11 + lib/model/db/db_sqflite.dart | 66 +++++- lib/model/db/db_sqflite_upgrade.dart | 12 + lib/model/dynamic_albums.dart | 109 +++++++++ lib/model/favourites.dart | 4 +- lib/model/filters/covered/covered.dart | 17 ++ lib/model/filters/covered/dynamic_album.dart | 69 ++++++ lib/model/filters/{ => covered}/location.dart | 3 +- .../{album.dart => covered/stored_album.dart} | 43 +++- lib/model/filters/{ => covered}/tag.dart | 3 +- lib/model/filters/filters.dart | 43 ++-- lib/model/filters/set_and.dart | 75 ++++++ lib/model/filters/{or.dart => set_or.dart} | 19 +- lib/model/settings/modules/navigation.dart | 5 +- lib/model/source/album.dart | 80 ++++--- lib/model/source/collection_lens.dart | 6 +- lib/model/source/collection_source.dart | 56 +++-- lib/model/source/location/country.dart | 2 +- lib/model/source/location/location.dart | 2 +- lib/model/source/location/place.dart | 2 +- lib/model/source/location/state.dart | 2 +- lib/model/source/media_store_source.dart | 8 +- lib/model/source/tag.dart | 2 +- lib/theme/icons.dart | 2 + lib/utils/collection_utils.dart | 11 +- lib/view/src/actions/chip_set.dart | 2 + lib/view/src/actions/entry_set.dart | 2 + lib/widgets/collection/app_bar.dart | 5 +- lib/widgets/collection/collection_grid.dart | 2 +- .../collection/draggable_thumb_label.dart | 2 +- .../collection/entry_set_action_delegate.dart | 94 +++++++- .../collection/grid/headers/album.dart | 2 +- lib/widgets/collection/grid/headers/any.dart | 2 +- .../quick_choosers/album_chooser.dart | 4 +- .../quick_choosers/move_button.dart | 4 +- .../quick_choosers/tag_button.dart | 2 +- .../common/action_mixins/entry_editor.dart | 2 +- .../common/action_mixins/entry_storage.dart | 21 +- .../common/action_mixins/vault_aware.dart | 8 +- lib/widgets/common/expandable_filter_row.dart | 2 +- .../common/identity/aves_filter_chip.dart | 13 +- lib/widgets/debug/app_debug_page.dart | 4 +- lib/widgets/debug/database.dart | 28 ++- .../entry_editors/edit_location_dialog.dart | 2 +- .../entry_editors/tag_editor_page.dart | 2 +- .../add_dynamic_album_dialog.dart | 93 ++++++++ .../cover_selection_dialog.dart | 59 ++--- ...g.dart => create_stored_album_dialog.dart} | 10 +- .../filter_editors/edit_vault_dialog.dart | 4 +- .../rename_dynamic_album_dialog.dart | 92 +++++++ ...g.dart => rename_stored_album_dialog.dart} | 10 +- .../dialogs/pick_dialogs/album_pick_page.dart | 34 +-- lib/widgets/explorer/app_bar.dart | 2 +- .../file_picker => explorer}/crumb_line.dart | 0 lib/widgets/explorer/explorer_page.dart | 6 +- lib/widgets/filter_grids/albums_page.dart | 105 ++++---- .../common/action_delegates/album_set.dart | 222 ++++++++++++----- .../common/action_delegates/chip.dart | 10 +- .../common/action_delegates/chip_set.dart | 11 +- .../common/action_delegates/country_set.dart | 2 +- .../common/action_delegates/place_set.dart | 2 +- .../common/action_delegates/state_set.dart | 2 +- .../common/action_delegates/tag_set.dart | 15 +- .../common/covered_filter_chip.dart | 81 +++---- lib/widgets/filter_grids/common/enums.dart | 4 +- .../filter_grids/common/filter_tile.dart | 4 +- .../filter_grids/common/list_details.dart | 6 +- .../filter_grids/common/section_keys.dart | 2 + lib/widgets/filter_grids/countries_page.dart | 2 +- lib/widgets/filter_grids/places_page.dart | 2 +- lib/widgets/filter_grids/states_page.dart | 2 +- lib/widgets/filter_grids/tags_page.dart | 2 +- lib/widgets/home_page.dart | 8 +- lib/widgets/map/map_page.dart | 2 +- lib/widgets/navigation/drawer/app_drawer.dart | 35 ++- .../drawer/collection_nav_tile.dart | 11 +- lib/widgets/navigation/tv_rail.dart | 16 +- lib/widgets/search/search_delegate.dart | 45 ++-- lib/widgets/settings/app_export/items.dart | 7 +- lib/widgets/settings/navigation/drawer.dart | 5 +- .../navigation/drawer_tab_albums.dart | 22 +- .../privacy/file_picker/file_picker_page.dart | 224 ------------------ .../settings/settings_mobile_page.dart | 4 +- lib/widgets/stats/stats_page.dart | 8 +- .../action/entry_info_action_delegate.dart | 5 +- .../viewer/action/video_action_delegate.dart | 4 +- lib/widgets/viewer/info/basic_section.dart | 6 +- lib/widgets/viewer/info/location_section.dart | 2 +- lib/widgets/viewer/slideshow_page.dart | 4 +- .../aves_model/lib/src/actions/chip_set.dart | 2 + .../aves_model/lib/src/actions/entry_set.dart | 2 + test/fake/db.dart | 4 + test/model/collection_source_test.dart | 52 ++-- test/model/filters_test.dart | 8 +- 97 files changed, 1439 insertions(+), 753 deletions(-) create mode 100644 lib/model/dynamic_albums.dart create mode 100644 lib/model/filters/covered/covered.dart create mode 100644 lib/model/filters/covered/dynamic_album.dart rename lib/model/filters/{ => covered}/location.dart (96%) rename lib/model/filters/{album.dart => covered/stored_album.dart} (71%) rename lib/model/filters/{ => covered}/tag.dart (92%) create mode 100644 lib/model/filters/set_and.dart rename lib/model/filters/{or.dart => set_or.dart} (77%) create mode 100644 lib/widgets/dialogs/filter_editors/add_dynamic_album_dialog.dart rename lib/widgets/dialogs/filter_editors/{create_album_dialog.dart => create_stored_album_dialog.dart} (95%) create mode 100644 lib/widgets/dialogs/filter_editors/rename_dynamic_album_dialog.dart rename lib/widgets/dialogs/filter_editors/{rename_album_dialog.dart => rename_stored_album_dialog.dart} (89%) rename lib/widgets/{settings/privacy/file_picker => explorer}/crumb_line.dart (100%) delete mode 100644 lib/widgets/settings/privacy/file_picker/file_picker_page.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index c67508ef8..d9fb05c21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Albums: dynamic albums from filter sets + ## [v1.11.19] - 2024-11-24 ### Added diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 781a00af8..a04876369 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -85,6 +85,7 @@ "sourceStateLocatingPlaces": "Locating places", "chipActionDelete": "Delete", + "chipActionRemove": "Remove", "chipActionShowCollection": "Show in Collection", "chipActionGoToAlbumPage": "Show in Albums", "chipActionGoToCountryPage": "Show in Countries", @@ -204,6 +205,7 @@ "albumTierSpecial": "Common", "albumTierApps": "Apps", "albumTierVaults": "Vaults", + "albumTierDynamic": "Dynamic", "albumTierRegular": "Others", "coordinateFormatDms": "DMS", @@ -427,6 +429,9 @@ "newAlbumDialogNameLabelAlreadyExistsHelper": "Directory already exists", "newAlbumDialogStorageLabel": "Storage:", + "newDynamicAlbumDialogTitle": "New Dynamic Album", + "dynamicAlbumAlreadyExists": "Dynamic album already exists", + "newVaultWarningDialogMessage": "Items in vaults are only available to this app and no others.\n\nIf you uninstall this app, or clear this app data, you will lose all these items.", "newVaultDialogTitle": "New Vault", "configureVaultDialogTitle": "Configure Vault", @@ -595,6 +600,7 @@ "collectionActionShowTitleSearch": "Show title filter", "collectionActionHideTitleSearch": "Hide title filter", + "collectionActionAddDynamicAlbum": "Add dynamic album", "collectionActionAddShortcut": "Add shortcut", "collectionActionSetHome": "Set as home", "collectionActionEmptyBin": "Empty bin", @@ -806,6 +812,7 @@ "settingsActionImportDialogTitle": "Import", "appExportCovers": "Covers", + "appExportDynamicAlbums": "Dynamic albums", "appExportFavourites": "Favorites", "appExportSettings": "Settings", diff --git a/lib/model/covers.dart b/lib/model/covers.dart index 231d8effc..edf189280 100644 --- a/lib/model/covers.dart +++ b/lib/model/covers.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:aves/model/app_inventory.dart'; import 'package:aves/model/entry/entry.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/vaults/vaults.dart'; @@ -16,6 +16,8 @@ import 'package:flutter/painting.dart'; final Covers covers = Covers._private(); +typedef CoverProps = (int? entryId, String? packageName, Color? color); + class Covers { final StreamController?> _entryChangeStreamController = StreamController.broadcast(); final StreamController?> _packageChangeStreamController = StreamController.broadcast(); @@ -39,22 +41,60 @@ class Covers { Set get all => Set.unmodifiable(_rows); - (int? entryId, String? packageName, Color? color)? of(CollectionFilter filter) { - if (filter is AlbumFilter && vaults.isLocked(filter.album)) return null; + CoverProps? of(CollectionFilter filter) { + if (filter is StoredAlbumFilter && vaults.isLocked(filter.album)) return null; final row = _rows.firstWhereOrNull((row) => row.filter == filter); return row != null ? (row.entryId, row.packageName, row.color) : null; } + Future remove(CollectionFilter filter, {bool notify = true}) async { + final props = of(filter); + if (props != null) { + await set(filter: filter, entryId: null, packageName: null, color: null); + + if (notify) { + final (entryId, packageName, color) = props; + if (entryId != null) _entryChangeStreamController.add({filter}); + if (packageName != null) _packageChangeStreamController.add({filter}); + if (color != null) _colorChangeStreamController.add({filter}); + } + } + return props; + } + + Future removeAll(Set filters, {bool notify = true}) async { + final entryIdChanged = {}; + final packageNameChanged = {}; + final colorChanged = {}; + + for (final filter in filters) { + final props = await remove(filter, notify: false); + if (notify && props != null) { + final (entryId, packageName, color) = props; + if (entryId != null) entryIdChanged.add(filter); + if (packageName != null) packageNameChanged.add(filter); + if (color != null) colorChanged.add(filter); + } + } + + if (notify) { + if (entryIdChanged.isNotEmpty) _entryChangeStreamController.add(entryIdChanged); + if (packageNameChanged.isNotEmpty) _packageChangeStreamController.add(packageNameChanged); + if (colorChanged.isNotEmpty) _colorChangeStreamController.add(colorChanged); + } + } + Future set({ required CollectionFilter filter, required int? entryId, required String? packageName, required Color? color, + bool notify = true, }) async { // erase contextual properties from filters before saving them - if (filter is AlbumFilter) { - filter = AlbumFilter(filter.album, null); + if (filter is StoredAlbumFilter) { + filter = StoredAlbumFilter(filter.album, null); } final oldRows = _rows.where((row) => row.filter == filter).toSet(); @@ -77,9 +117,11 @@ class Covers { await localMediaDb.addCovers({row}); } - if (oldEntry != entryId) _entryChangeStreamController.add({filter}); - if (oldPackage != packageName) _packageChangeStreamController.add({filter}); - if (oldColor != color) _colorChangeStreamController.add({filter}); + if (notify) { + if (oldEntry != entryId) _entryChangeStreamController.add({filter}); + if (oldPackage != packageName) _packageChangeStreamController.add({filter}); + if (oldColor != color) _colorChangeStreamController.add({filter}); + } } Future _removeEntryFromRows(Set rows) { @@ -112,7 +154,7 @@ class Covers { } AlbumType effectiveAlbumType(String albumPath) { - final filterPackage = of(AlbumFilter(albumPath, null))?.$2; + final filterPackage = of(StoredAlbumFilter(albumPath, null))?.$2; if (filterPackage != null) { return filterPackage.isEmpty ? AlbumType.regular : AlbumType.app; } else { @@ -121,7 +163,7 @@ class Covers { } String? effectiveAlbumPackage(String albumPath) { - final filterPackage = of(AlbumFilter(albumPath, null))?.$2; + final filterPackage = of(StoredAlbumFilter(albumPath, null))?.$2; return filterPackage ?? appInventory.getAlbumAppPackageName(albumPath); } @@ -129,7 +171,7 @@ class Covers { List>? export(CollectionSource source) { final visibleEntries = source.visibleEntries; - final jsonList = covers.all + final jsonList = all .map((row) { final entryId = row.entryId; final path = visibleEntries.firstWhereOrNull((entry) => entryId == entry.id)?.path; @@ -180,7 +222,7 @@ class Covers { } if (entry != null || packageName != null || colorValue != null) { - covers.set( + set( filter: filter, entryId: entry?.id, packageName: packageName, diff --git a/lib/model/db/db.dart b/lib/model/db/db.dart index 0798e9e6f..d48ad360e 100644 --- a/lib/model/db/db.dart +++ b/lib/model/db/db.dart @@ -1,4 +1,5 @@ import 'package:aves/model/covers.dart'; +import 'package:aves/model/dynamic_albums.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/filters.dart'; @@ -109,6 +110,16 @@ abstract class LocalMediaDb { Future removeCovers(Set filters); + // dynamic albums + + Future clearDynamicAlbums(); + + Future> loadAllDynamicAlbums(); + + Future addDynamicAlbums(Set rows); + + Future removeDynamicAlbums(Set names); + // video playback Future clearVideoPlayback(); diff --git a/lib/model/db/db_sqflite.dart b/lib/model/db/db_sqflite.dart index 5bfb18d3a..cb5e51d9d 100644 --- a/lib/model/db/db_sqflite.dart +++ b/lib/model/db/db_sqflite.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:aves/model/covers.dart'; import 'package:aves/model/db/db.dart'; import 'package:aves/model/db/db_sqflite_upgrade.dart'; +import 'package:aves/model/dynamic_albums.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/filters.dart'; @@ -27,6 +28,7 @@ class SqfliteLocalMediaDb implements LocalMediaDb { static const addressTable = 'address'; static const favouriteTable = 'favourites'; static const coverTable = 'covers'; + static const dynamicAlbumTable = 'dynamicAlbums'; static const vaultTable = 'vaults'; static const trashTable = 'trash'; static const videoPlaybackTable = 'videoPlayback'; @@ -93,6 +95,10 @@ class SqfliteLocalMediaDb implements LocalMediaDb { ', packageName TEXT' ', color INTEGER' ')'); + await db.execute('CREATE TABLE $dynamicAlbumTable(' + 'name TEXT PRIMARY KEY' + ', filter TEXT' + ')'); await db.execute('CREATE TABLE $vaultTable(' 'name TEXT PRIMARY KEY' ', autoLock INTEGER' @@ -110,7 +116,7 @@ class SqfliteLocalMediaDb implements LocalMediaDb { ')'); }, onUpgrade: LocalMediaDbUpgrader.upgradeDb, - version: 11, + version: 12, ); final maxIdRows = await _db.rawQuery('SELECT MAX(id) AS maxId FROM $entryTable'); @@ -137,7 +143,7 @@ class SqfliteLocalMediaDb implements LocalMediaDb { final _dataTypes = dataTypes ?? EntryDataType.values.toSet(); - // using array in `whereArgs` and using it with `where id IN ?` is a pain, so we prefer `batch` instead + // using array in `whereArgs` and using it with `where arg IN ?` is a pain, so we prefer `batch` instead final batch = _db.batch(); const where = 'id = ?'; const coverWhere = 'entryId = ?'; @@ -450,7 +456,7 @@ class SqfliteLocalMediaDb implements LocalMediaDb { Future removeVaults(Set rows) async { if (rows.isEmpty) return; - // using array in `whereArgs` and using it with `where id IN ?` is a pain, so we prefer `batch` instead + // using array in `whereArgs` and using it with `where arg IN ?` is a pain, so we prefer `batch` instead final batch = _db.batch(); rows.map((v) => v.name).forEach((name) => batch.delete(vaultTable, where: 'name = ?', whereArgs: [name])); await batch.commit(noResult: true); @@ -539,7 +545,7 @@ class SqfliteLocalMediaDb implements LocalMediaDb { final ids = rows.map((row) => row.entryId); if (ids.isEmpty) return; - // using array in `whereArgs` and using it with `where id IN ?` is a pain, so we prefer `batch` instead + // using array in `whereArgs` and using it with `where arg IN ?` is a pain, so we prefer `batch` instead final batch = _db.batch(); ids.forEach((id) => batch.delete(favouriteTable, where: 'id = ?', whereArgs: [id])); await batch.commit(noResult: true); @@ -609,12 +615,60 @@ class SqfliteLocalMediaDb implements LocalMediaDb { } }); - // using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead + // using array in `whereArgs` and using it with `where arg IN ?` is a pain, so we prefer `batch` instead final batch = _db.batch(); obsoleteFilterJson.forEach((filterJson) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filterJson])); await batch.commit(noResult: true); } + // dynamic albums + + @override + Future clearDynamicAlbums() async { + final count = await _db.delete(dynamicAlbumTable, where: '1'); + debugPrint('$runtimeType clearDynamicAlbums deleted $count rows'); + } + + @override + Future> loadAllDynamicAlbums() async { + final result = {}; + final cursor = await _db.queryCursor(dynamicAlbumTable, bufferSize: _queryCursorBufferSize); + while (await cursor.moveNext()) { + final row = DynamicAlbumRow.fromMap(cursor.current); + if (row != null) { + result.add(row); + } + } + return result; + } + + @override + Future addDynamicAlbums(Set rows) async { + if (rows.isEmpty) return; + + final batch = _db.batch(); + rows.forEach((row) => _batchInsertDynamicAlbum(batch, row)); + await batch.commit(noResult: true); + } + + void _batchInsertDynamicAlbum(Batch batch, DynamicAlbumRow row) { + batch.insert( + dynamicAlbumTable, + row.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + @override + Future removeDynamicAlbums(Set names) async { + if (names.isEmpty) return; + + // using array in `whereArgs` and using it with `where arg IN ?` is a pain, so we prefer `batch` instead + final batch = _db.batch(); + names.forEach((name) => batch.delete(dynamicAlbumTable, where: 'name = ?', whereArgs: [name])); + await batch.commit(noResult: true); + } + // video playback @override @@ -665,7 +719,7 @@ class SqfliteLocalMediaDb implements LocalMediaDb { Future removeVideoPlayback(Set ids) async { if (ids.isEmpty) return; - // using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead + // using array in `whereArgs` and using it with `where arg IN ?` is a pain, so we prefer `batch` instead final batch = _db.batch(); ids.forEach((id) => batch.delete(videoPlaybackTable, where: 'id = ?', whereArgs: [id])); await batch.commit(noResult: true); diff --git a/lib/model/db/db_sqflite_upgrade.dart b/lib/model/db/db_sqflite_upgrade.dart index d9b902698..ab63f7564 100644 --- a/lib/model/db/db_sqflite_upgrade.dart +++ b/lib/model/db/db_sqflite_upgrade.dart @@ -9,6 +9,7 @@ class LocalMediaDbUpgrader { static const addressTable = SqfliteLocalMediaDb.addressTable; static const favouriteTable = SqfliteLocalMediaDb.favouriteTable; static const coverTable = SqfliteLocalMediaDb.coverTable; + static const dynamicAlbumTable = SqfliteLocalMediaDb.dynamicAlbumTable; static const vaultTable = SqfliteLocalMediaDb.vaultTable; static const trashTable = SqfliteLocalMediaDb.trashTable; static const videoPlaybackTable = SqfliteLocalMediaDb.videoPlaybackTable; @@ -38,6 +39,8 @@ class LocalMediaDbUpgrader { await _upgradeFrom9(db); case 10: await _upgradeFrom10(db); + case 11: + await _upgradeFrom11(db); } oldVersion++; } @@ -376,4 +379,13 @@ class LocalMediaDbUpgrader { ', lockType TEXT' ')'); } + + static Future _upgradeFrom11(Database db) async { + debugPrint('upgrading DB from v11'); + + await db.execute('CREATE TABLE $dynamicAlbumTable(' + 'name TEXT PRIMARY KEY' + ', filter TEXT' + ')'); + } } diff --git a/lib/model/dynamic_albums.dart b/lib/model/dynamic_albums.dart new file mode 100644 index 000000000..bd6e91498 --- /dev/null +++ b/lib/model/dynamic_albums.dart @@ -0,0 +1,109 @@ +import 'package:aves/model/filters/covered/dynamic_album.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; + +final DynamicAlbums dynamicAlbums = DynamicAlbums._private(); + +class DynamicAlbums with ChangeNotifier { + Set _rows = {}; + + DynamicAlbums._private() { + if (kFlutterMemoryAllocationsEnabled) ChangeNotifier.maybeDispatchObjectCreation(this); + } + + Future init() async { + _rows = (await localMediaDb.loadAllDynamicAlbums()).map((v) => DynamicAlbumFilter(v.name, v.filter)).toSet(); + } + + int get count => _rows.length; + + Set get all => Set.unmodifiable(_rows); + + Future add(DynamicAlbumFilter filter) async { + await localMediaDb.addDynamicAlbums({DynamicAlbumRow(name: filter.name, filter: filter.filter)}); + _rows.add(filter); + + notifyListeners(); + } + + Future remove(Set filters) async { + await localMediaDb.removeDynamicAlbums(filters.map((filter) => filter.name).toSet()); + _rows.removeAll(filters); + + notifyListeners(); + } + + Future clear() async { + await localMediaDb.clearDynamicAlbums(); + _rows.clear(); + + notifyListeners(); + } + + Future rename(DynamicAlbumFilter filter, String newName) async { + await localMediaDb.removeDynamicAlbums({filter.name}); + _rows.remove(filter); + + await add(DynamicAlbumFilter(newName, filter.filter)); + } + + DynamicAlbumFilter? get(String name) => _rows.firstWhereOrNull((row) => row.name == name); + + bool contains(String name) => get(name) != null; + + // import/export + + List? export() { + final jsonList = all.map((row) => row.toJson()).toList(); + return jsonList.isNotEmpty ? jsonList : null; + } + + void import(dynamic jsonList) { + if (jsonList is! List) { + debugPrint('failed to import dynamic albums for jsonMap=$jsonList'); + return; + } + + jsonList.forEach((row) { + final filter = CollectionFilter.fromJson(row); + if (filter == null || filter is! DynamicAlbumFilter) { + debugPrint('failed to import dynamic album for row=$row'); + return; + } + + add(filter); + }); + } +} + +@immutable +class DynamicAlbumRow extends Equatable { + final String name; + final CollectionFilter filter; + + @override + List get props => [name, filter]; + + const DynamicAlbumRow({ + required this.name, + required this.filter, + }); + + static DynamicAlbumRow? fromMap(Map map) { + final filter = CollectionFilter.fromJson(map['filter']); + if (filter == null) return null; + + return DynamicAlbumRow( + name: map['name'] as String, + filter: filter, + ); + } + + Map toMap() => { + 'name': name, + 'filter': filter.toJson(), + }; +} diff --git a/lib/model/favourites.dart b/lib/model/favourites.dart index b54bff4ef..7df6d0281 100644 --- a/lib/model/favourites.dart +++ b/lib/model/favourites.dart @@ -59,7 +59,7 @@ class Favourites with ChangeNotifier { Map>? export(CollectionSource source) { final visibleEntries = source.visibleEntries; - final ids = favourites.all; + final ids = all; final paths = visibleEntries.where((entry) => ids.contains(entry.id)).map((entry) => entry.path).nonNulls.toSet(); final byVolume = groupBy(paths, androidFileUtils.getStorageVolume); final jsonMap = Map.fromEntries(byVolume.entries.map((kv) { @@ -97,7 +97,7 @@ class Favourites with ChangeNotifier { } if (foundEntries.isNotEmpty) { - favourites.add(foundEntries); + add(foundEntries); } if (missedPaths.isNotEmpty) { debugPrint('failed to import favourites with ${missedPaths.length} missed paths'); diff --git a/lib/model/filters/covered/covered.dart b/lib/model/filters/covered/covered.dart new file mode 100644 index 000000000..bea2e8506 --- /dev/null +++ b/lib/model/filters/covered/covered.dart @@ -0,0 +1,17 @@ +import 'package:aves/model/covers.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +@immutable +mixin CoveredFilter on CollectionFilter { + @override + Future color(BuildContext context) { + final customColor = covers.of(this)?.$3; + if (customColor != null) { + return SynchronousFuture(customColor); + } + return super.color(context); + } +} + diff --git a/lib/model/filters/covered/dynamic_album.dart b/lib/model/filters/covered/dynamic_album.dart new file mode 100644 index 000000000..d8991ce5f --- /dev/null +++ b/lib/model/filters/covered/dynamic_album.dart @@ -0,0 +1,69 @@ +import 'package:aves/model/filters/covered/covered.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:flutter/widgets.dart'; + +class DynamicAlbumFilter extends AlbumBaseFilter with CoveredFilter { + static const type = 'dynamic_album'; + + final String name; + final CollectionFilter filter; + + @override + List get props => [name, filter, reversed]; + + DynamicAlbumFilter(this.name, this.filter, {super.reversed = false}); + + static DynamicAlbumFilter? fromMap(Map json) { + final filter = CollectionFilter.fromJson(json['filter']); + if (filter == null) return null; + + return DynamicAlbumFilter( + json['name'], + filter, + reversed: json['reversed'] ?? false, + ); + } + + @override + Map toMap() => { + 'type': type, + 'name': name, + 'filter': filter.toJson(), + 'reversed': reversed, + }; + + @override + EntryFilter get positiveTest => filter.test; + + @override + bool get exclusiveProp => false; + + @override + String get universalLabel => name; + + @override + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) { + return allowGenericIcon ? Icon(AIcons.dynamicAlbum, size: size) : null; + } + + @override + String get category => type; + + @override + String get key => '$type-$reversed-$name'; + + @override + bool match(String query) => name.toUpperCase().contains(query); + + @override + StorageVolume? get storageVolume => null; + + @override + bool get canRename => true; + + @override + bool get isVault => false; +} diff --git a/lib/model/filters/location.dart b/lib/model/filters/covered/location.dart similarity index 96% rename from lib/model/filters/location.dart rename to lib/model/filters/covered/location.dart index 7c29eddf0..701a3f8b4 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/covered/location.dart @@ -1,4 +1,5 @@ import 'package:aves/model/device.dart'; +import 'package:aves/model/filters/covered/covered.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/emoji_utils.dart'; @@ -6,7 +7,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; -class LocationFilter extends CoveredCollectionFilter { +class LocationFilter extends CollectionFilter with CoveredFilter { static const type = 'location'; static const locationSeparator = ';'; diff --git a/lib/model/filters/album.dart b/lib/model/filters/covered/stored_album.dart similarity index 71% rename from lib/model/filters/album.dart rename to lib/model/filters/covered/stored_album.dart index 53121a7ec..91ec8f6ad 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/covered/stored_album.dart @@ -1,15 +1,30 @@ import 'package:aves/model/covers.dart'; +import 'package:aves/model/filters/covered/covered.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/colors.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:aves_model/aves_model.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; -class AlbumFilter extends CoveredCollectionFilter { +abstract class AlbumBaseFilter extends CollectionFilter { + const AlbumBaseFilter({required super.reversed}); + + bool match(String query); + + StorageVolume? get storageVolume; + + bool get canRename; + + bool get isVault; +} + +class StoredAlbumFilter extends AlbumBaseFilter with CoveredFilter { static const type = 'album'; final String album; @@ -19,12 +34,12 @@ class AlbumFilter extends CoveredCollectionFilter { @override List get props => [album, reversed]; - AlbumFilter(this.album, this.displayName, {super.reversed = false}) { + StoredAlbumFilter(this.album, this.displayName, {super.reversed = false}) { _test = (entry) => entry.directory == album; } - factory AlbumFilter.fromMap(Map json) { - return AlbumFilter( + factory StoredAlbumFilter.fromMap(Map json) { + return StoredAlbumFilter( json['album'], json['uniqueName'], reversed: json['reversed'] ?? false, @@ -95,7 +110,25 @@ class AlbumFilter extends CoveredCollectionFilter { @override String get category => type; - // key `album-{path}` is expected by test driver + // key is expected by test driver @override String get key => '$type-$reversed-$album'; + + @override + bool match(String query) => (displayName ?? album).toUpperCase().contains(query); + + @override + StorageVolume? get storageVolume => androidFileUtils.getStorageVolume(album); + + @override + bool get canRename { + if (isVault) return true; + + // do not allow renaming volume root + final dir = androidFileUtils.relativeDirectoryFromPath(album); + return dir != null && dir.relativeDir.isNotEmpty; + } + + @override + bool get isVault => vaults.isVault(album); } diff --git a/lib/model/filters/tag.dart b/lib/model/filters/covered/tag.dart similarity index 92% rename from lib/model/filters/tag.dart rename to lib/model/filters/covered/tag.dart index 8282ad871..51bffbdaa 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/covered/tag.dart @@ -1,9 +1,10 @@ +import 'package:aves/model/filters/covered/covered.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/widgets.dart'; -class TagFilter extends CoveredCollectionFilter { +class TagFilter extends CollectionFilter with CoveredFilter { static const type = 'tag'; final String tag; diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index fe52ebbdf..a17eb11bd 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -1,22 +1,23 @@ import 'dart:convert'; -import 'package:aves/model/covers.dart'; import 'package:aves/model/entry/entry.dart'; -import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/aspect_ratio.dart'; import 'package:aves/model/filters/coordinate.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; +import 'package:aves/model/filters/covered/dynamic_album.dart'; +import 'package:aves/model/filters/covered/location.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/filters/date.dart'; import 'package:aves/model/filters/favourite.dart'; -import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/missing.dart'; -import 'package:aves/model/filters/or.dart'; import 'package:aves/model/filters/path.dart'; import 'package:aves/model/filters/placeholder.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/recent.dart'; -import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/set_and.dart'; +import 'package:aves/model/filters/set_or.dart'; import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/theme/colors.dart'; @@ -31,8 +32,11 @@ abstract class CollectionFilter extends Equatable implements Comparable categoryOrder = [ TrashFilter.type, QueryFilter.type, + SetAndFilter.type, + SetOrFilter.type, MimeFilter.type, - AlbumFilter.type, + DynamicAlbumFilter.type, + StoredAlbumFilter.type, TypeFilter.type, RecentlyAddedFilter.type, DateFilter.type, @@ -44,7 +48,6 @@ abstract class CollectionFilter extends Equatable implements Comparable jsonMap) { final type = jsonMap['type']; switch (type) { - case AlbumFilter.type: - return AlbumFilter.fromMap(jsonMap); case AspectRatioFilter.type: return AspectRatioFilter.fromMap(jsonMap); case CoordinateFilter.type: return CoordinateFilter.fromMap(jsonMap); case DateFilter.type: return DateFilter.fromMap(jsonMap); + case DynamicAlbumFilter.type: + return DynamicAlbumFilter.fromMap(jsonMap); case FavouriteFilter.type: return FavouriteFilter.fromMap(jsonMap); case LocationFilter.type: @@ -70,8 +73,10 @@ abstract class CollectionFilter extends Equatable implements Comparable color(BuildContext context) { - final customColor = covers.of(this)?.$3; - if (customColor != null) { - return SynchronousFuture(customColor); - } - return super.color(context); - } -} - @immutable class FilterGridItem with EquatableMixin { final T filter; diff --git a/lib/model/filters/set_and.dart b/lib/model/filters/set_and.dart new file mode 100644 index 000000000..ca4aea296 --- /dev/null +++ b/lib/model/filters/set_and.dart @@ -0,0 +1,75 @@ +import 'package:aves/model/filters/covered/stored_album.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/covered/location.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; + +class SetAndFilter extends CollectionFilter { + static const type = 'and'; + + late final List _filters; + + late final EntryFilter _test; + late final IconData? _genericIcon; + + @override + List get props => [_filters, reversed]; + + CollectionFilter get _first => _filters.first; + + SetAndFilter(Set filters, {super.reversed = false}) { + _filters = filters.toList().sorted(); + _test = (entry) => _filters.every((v) => v.test(entry)); + switch (_first) { + case StoredAlbumFilter(): + _genericIcon = AIcons.album; + case LocationFilter(level: LocationLevel.country): + _genericIcon = AIcons.country; + case LocationFilter(level: LocationLevel.state): + _genericIcon = AIcons.state; + default: + _genericIcon = null; + } + } + + static SetAndFilter? fromMap(Map json) { + final filters = (json['filters'] as List).cast().map(CollectionFilter.fromJson).nonNulls.toSet(); + if (filters.isEmpty) return null; + + return SetAndFilter( + filters, + reversed: json['reversed'] ?? false, + ); + } + + @override + Map toMap() => { + 'type': type, + 'filters': _filters.map((v) => v.toJson()).toList(), + 'reversed': reversed, + }; + + @override + EntryFilter get positiveTest => _test; + + @override + bool get exclusiveProp => false; + + @override + String get universalLabel => _filters.map((v) => v.universalLabel).join(', '); + + @override + String getLabel(BuildContext context) => _filters.map((v) => v.getLabel(context)).join(', '); + + @override + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) { + return _genericIcon != null ? Icon(_genericIcon, size: size) : _first.iconBuilder(context, size, allowGenericIcon: allowGenericIcon); + } + + @override + String get category => _first.category; + + @override + String get key => '$type-$reversed-${_filters.map((v) => v.key)}'; +} diff --git a/lib/model/filters/or.dart b/lib/model/filters/set_or.dart similarity index 77% rename from lib/model/filters/or.dart rename to lib/model/filters/set_or.dart index ea4876e3d..98f392ff3 100644 --- a/lib/model/filters/or.dart +++ b/lib/model/filters/set_or.dart @@ -1,11 +1,11 @@ -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/theme/icons.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; -class OrFilter extends CollectionFilter { +class SetOrFilter extends CollectionFilter { static const type = 'or'; late final List _filters; @@ -18,11 +18,11 @@ class OrFilter extends CollectionFilter { CollectionFilter get _first => _filters.first; - OrFilter(Set filters, {super.reversed = false}) { + SetOrFilter(Set filters, {super.reversed = false}) { _filters = filters.toList().sorted(); _test = (entry) => _filters.any((v) => v.test(entry)); switch (_first) { - case AlbumFilter(): + case StoredAlbumFilter(): _genericIcon = AIcons.album; case LocationFilter(level: LocationLevel.country): _genericIcon = AIcons.country; @@ -33,9 +33,12 @@ class OrFilter extends CollectionFilter { } } - factory OrFilter.fromMap(Map json) { - return OrFilter( - (json['filters'] as List).cast().map(CollectionFilter.fromJson).nonNulls.toSet(), + static SetOrFilter? fromMap(Map json) { + final filters = (json['filters'] as List).cast().map(CollectionFilter.fromJson).nonNulls.toSet(); + if (filters.isEmpty) return null; + + return SetOrFilter( + filters, reversed: json['reversed'] ?? false, ); } diff --git a/lib/model/settings/modules/navigation.dart b/lib/model/settings/modules/navigation.dart index 2f6eca490..6b24ed152 100644 --- a/lib/model/settings/modules/navigation.dart +++ b/lib/model/settings/modules/navigation.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/defaults.dart'; import 'package:aves_model/aves_model.dart'; @@ -64,9 +65,9 @@ mixin NavigationSettings on SettingsAccess { set drawerTypeBookmarks(List newValue) => set(SettingKeys.drawerTypeBookmarksKey, newValue.map((filter) => filter?.toJson() ?? '').toList()); - List? get drawerAlbumBookmarks => getStringList(SettingKeys.drawerAlbumBookmarksKey); + List? get drawerAlbumBookmarks => getStringList(SettingKeys.drawerAlbumBookmarksKey)?.map(CollectionFilter.fromJson).whereType().toList(); - set drawerAlbumBookmarks(List? newValue) => set(SettingKeys.drawerAlbumBookmarksKey, newValue); + set drawerAlbumBookmarks(List? newValue) => set(SettingKeys.drawerAlbumBookmarksKey, newValue?.map((filter) => filter.toJson()).toList()); List get drawerPageBookmarks => getStringList(SettingKeys.drawerPageBookmarksKey) ?? SettingsDefaults.drawerPageBookmarks; diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index b30b693d8..032ba52c4 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -1,5 +1,6 @@ import 'package:aves/model/entry/entry.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/dynamic_album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/vaults/vaults.dart'; @@ -17,13 +18,13 @@ mixin AlbumMixin on SourceBase { List get rawAlbums => List.unmodifiable(_directories); - Set getNewAlbumFilters(BuildContext context) => Set.unmodifiable(_newAlbums.map((v) => AlbumFilter(v, getAlbumDisplayName(context, v)))); + Set getNewAlbumFilters(BuildContext context) => Set.unmodifiable(_newAlbums.map((v) => StoredAlbumFilter(v, getStoredAlbumDisplayName(context, v)))); int compareAlbumsByName(String? a, String? b) { a ??= ''; b ??= ''; - final ua = getAlbumDisplayName(null, a); - final ub = getAlbumDisplayName(null, b); + final ua = getStoredAlbumDisplayName(null, a); + final ub = getStoredAlbumDisplayName(null, b); final c = compareAsciiUpperCaseNatural(ua, ub); if (c != 0) return c; final va = androidFileUtils.getStorageVolume(a)?.path ?? ''; @@ -36,7 +37,7 @@ mixin AlbumMixin on SourceBase { } void _onAlbumChanged({bool notify = true}) { - invalidateAlbumDisplayNames(); + invalidateStoredAlbumDisplayNames(); if (notify) { notifyAlbumsChanged(); } @@ -82,12 +83,6 @@ mixin AlbumMixin on SourceBase { _directories.removeAll(removableAlbums); _onAlbumChanged(); invalidateAlbumFilterSummary(directories: removableAlbums); - - final bookmarks = settings.drawerAlbumBookmarks; - removableAlbums.forEach((album) { - bookmarks?.remove(album); - }); - settings.drawerAlbumBookmarks = bookmarks; } } @@ -95,13 +90,13 @@ mixin AlbumMixin on SourceBase { if (visibleEntries.any((entry) => entry.directory == album)) return false; if (_newAlbums.contains(album)) return false; if (vaults.isVault(album)) return false; - if (settings.pinnedFilters.whereType().map((v) => v.album).contains(album)) return false; + if (settings.pinnedFilters.whereType().map((v) => v.album).contains(album)) return false; return true; } // filter summary - // by directory + // by filter key final Map _filterEntryCountMap = {}, _filterSizeMap = {}; final Map _filterRecentEntryMap = {}; @@ -117,36 +112,53 @@ mixin AlbumMixin on SourceBase { _filterSizeMap.clear(); _filterRecentEntryMap.clear(); } else { + // clear entries only for modified album directories directories ??= {}; if (entries != null) { directories.addAll(entries.map((entry) => entry.directory).nonNulls); } - directories.forEach((directory) { - _filterEntryCountMap.remove(directory); - _filterSizeMap.remove(directory); - _filterRecentEntryMap.remove(directory); + directories.nonNulls.map((v) => StoredAlbumFilter(v, null).key).forEach((key) { + _filterEntryCountMap.remove(key); + _filterSizeMap.remove(key); + _filterRecentEntryMap.remove(key); }); + + // clear entries for all dynamic albums + invalidateDynamicAlbumFilterSummary(notify: false); } if (notify) { - eventBus.fire(AlbumSummaryInvalidatedEvent(directories)); + eventBus.fire(StoredAlbumSummaryInvalidatedEvent(directories)); + eventBus.fire(const DynamicAlbumSummaryInvalidatedEvent()); } } - int albumEntryCount(AlbumFilter filter) { - return _filterEntryCountMap.putIfAbsent(filter.album, () => visibleEntries.where(filter.test).length); + void invalidateDynamicAlbumFilterSummary({bool notify = true}) { + _filterEntryCountMap.removeWhere(_isDynamicAlbumKey); + _filterSizeMap.removeWhere(_isDynamicAlbumKey); + _filterRecentEntryMap.removeWhere(_isDynamicAlbumKey); + + if (notify) { + eventBus.fire(const DynamicAlbumSummaryInvalidatedEvent()); + } } - int albumSize(AlbumFilter filter) { - return _filterSizeMap.putIfAbsent(filter.album, () => visibleEntries.where(filter.test).map((v) => v.sizeBytes).sum); + bool _isDynamicAlbumKey(String key, _) => key.startsWith('${DynamicAlbumFilter.type}-'); + + int albumEntryCount(AlbumBaseFilter filter) { + return _filterEntryCountMap.putIfAbsent(filter.key, () => visibleEntries.where(filter.test).length); } - AvesEntry? albumRecentEntry(AlbumFilter filter) { - return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhereOrNull(filter.test)); + int albumSize(AlbumBaseFilter filter) { + return _filterSizeMap.putIfAbsent(filter.key, () => visibleEntries.where(filter.test).map((v) => v.sizeBytes).sum); + } + + AvesEntry? albumRecentEntry(AlbumBaseFilter filter) { + return _filterRecentEntryMap.putIfAbsent(filter.key, () => sortedEntriesByDate.firstWhereOrNull(filter.test)); } // new albums - void createAlbum(String directory) { + void createStoredAlbum(String directory) { if (!_directories.contains(directory)) { _newAlbums.add(directory); addDirectories(albums: {directory}); @@ -156,7 +168,7 @@ mixin AlbumMixin on SourceBase { void renameNewAlbum(String source, String destination) { if (_newAlbums.remove(source)) { cleanEmptyAlbums({source}); - createAlbum(destination); + createStoredAlbum(destination); } } @@ -168,12 +180,12 @@ mixin AlbumMixin on SourceBase { final Map _albumDisplayNamesWithContext = {}, _albumDisplayNamesWithoutContext = {}; - void invalidateAlbumDisplayNames() { + void invalidateStoredAlbumDisplayNames() { _albumDisplayNamesWithContext.clear(); _albumDisplayNamesWithoutContext.clear(); } - String _computeDisplayName(BuildContext? context, String dirPath) { + String _computeStoredAlbumDisplayName(BuildContext? context, String dirPath) { final separator = pContext.separator; assert(!dirPath.endsWith(separator)); @@ -222,16 +234,20 @@ mixin AlbumMixin on SourceBase { } } - String getAlbumDisplayName(BuildContext? context, String dirPath) { + String getStoredAlbumDisplayName(BuildContext? context, String dirPath) { final names = (context != null ? _albumDisplayNamesWithContext : _albumDisplayNamesWithoutContext); - return names.putIfAbsent(dirPath, () => _computeDisplayName(context, dirPath)); + return names.putIfAbsent(dirPath, () => _computeStoredAlbumDisplayName(context, dirPath)); } } class AlbumsChangedEvent {} -class AlbumSummaryInvalidatedEvent { +class DynamicAlbumSummaryInvalidatedEvent { + const DynamicAlbumSummaryInvalidatedEvent(); +} + +class StoredAlbumSummaryInvalidatedEvent { final Set? directories; - const AlbumSummaryInvalidatedEvent(this.directories); + const StoredAlbumSummaryInvalidatedEvent(this.directories); } diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index f7976bdca..81d3bed0d 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -6,10 +6,10 @@ import 'package:aves/model/entry/extensions/multipage.dart'; import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/entry/sort.dart'; import 'package:aves/model/favourites.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/rating.dart'; @@ -143,7 +143,7 @@ class CollectionLens with ChangeNotifier { } bool get showHeaders { - bool showAlbumHeaders() => !filters.any((v) => v is AlbumFilter && !v.reversed); + bool showAlbumHeaders() => !filters.any((v) => v is StoredAlbumFilter && !v.reversed); switch (sortFactor) { case EntrySortFactor.date: diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index b87f599e8..2efcd06ed 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -7,10 +7,10 @@ import 'package:aves/model/entry/extensions/catalog.dart'; import 'package:aves/model/entry/extensions/location.dart'; import 'package:aves/model/entry/sort.dart'; import 'package:aves/model/favourites.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/location.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; -import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/metadata/trash.dart'; import 'package:aves/model/settings/settings.dart'; @@ -75,7 +75,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place object: this, ); } - settings.updateStream.where((event) => event.key == SettingKeys.localeKey).listen((_) => invalidateAlbumDisplayNames()); + settings.updateStream.where((event) => event.key == SettingKeys.localeKey).listen((_) => invalidateStoredAlbumDisplayNames()); settings.updateStream.where((event) => event.key == SettingKeys.hiddenFiltersKey).listen((event) { final oldValue = event.oldValue; if (oldValue is List?) { @@ -144,7 +144,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place Set _getAppHiddenFilters() => { ...settings.hiddenFilters, - ...vaults.vaultDirectories.where(vaults.isLocked).map((v) => AlbumFilter(v, null)), + ...vaults.vaultDirectories.where(vaults.isLocked).map((v) => StoredAlbumFilter(v, null)), }; Iterable _applyHiddenFilters(Iterable entries) { @@ -288,11 +288,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place } } - Future renameAlbum(String sourceAlbum, String destinationAlbum, Set entries, Set movedOps) async { - final oldFilter = AlbumFilter(sourceAlbum, null); - final newFilter = AlbumFilter(destinationAlbum, null); + Future renameStoredAlbum(String sourceAlbum, String destinationAlbum, Set entries, Set movedOps) async { + final oldFilter = StoredAlbumFilter(sourceAlbum, null); + final newFilter = StoredAlbumFilter(destinationAlbum, null); - final bookmark = settings.drawerAlbumBookmarks?.indexOf(sourceAlbum); final pinned = settings.pinnedFilters.contains(oldFilter); if (vaults.isVault(sourceAlbum)) { @@ -315,10 +314,17 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place movedOps: movedOps, ); - // restore bookmark and pin, as the obsolete album got removed and its associated state cleaned - if (bookmark != null && bookmark != -1) { - settings.drawerAlbumBookmarks = settings.drawerAlbumBookmarks?..insert(bookmark, destinationAlbum); + // update bookmark + final albumBookmarks = settings.drawerAlbumBookmarks; + if (albumBookmarks != null) { + final index = albumBookmarks.indexWhere((v) => v is StoredAlbumFilter && v.album == sourceAlbum); + if (index >= 0) { + albumBookmarks.removeAt(index); + albumBookmarks.insert(index, newFilter); + settings.drawerAlbumBookmarks = albumBookmarks; + } } + // restore pin, as the obsolete album got removed and its associated state cleaned if (pinned) { settings.pinnedFilters = settings.pinnedFilters ..remove(oldFilter) @@ -541,8 +547,9 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place // filter summary int count(CollectionFilter filter) { - if (filter is AlbumFilter) return albumEntryCount(filter); - if (filter is LocationFilter) { + if (filter is AlbumBaseFilter) { + return albumEntryCount(filter); + } else if (filter is LocationFilter) { switch (filter.level) { case LocationLevel.country: return countryEntryCount(filter); @@ -551,14 +558,16 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place case LocationLevel.place: return placeEntryCount(filter); } + } else if (filter is TagFilter) { + return tagEntryCount(filter); } - if (filter is TagFilter) return tagEntryCount(filter); return 0; } int size(CollectionFilter filter) { - if (filter is AlbumFilter) return albumSize(filter); - if (filter is LocationFilter) { + if (filter is AlbumBaseFilter) { + return albumSize(filter); + } else if (filter is LocationFilter) { switch (filter.level) { case LocationLevel.country: return countrySize(filter); @@ -567,14 +576,16 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place case LocationLevel.place: return placeSize(filter); } + } else if (filter is TagFilter) { + return tagSize(filter); } - if (filter is TagFilter) return tagSize(filter); return 0; } AvesEntry? recentEntry(CollectionFilter filter) { - if (filter is AlbumFilter) return albumRecentEntry(filter); - if (filter is LocationFilter) { + if (filter is AlbumBaseFilter) { + return albumRecentEntry(filter); + } else if (filter is LocationFilter) { switch (filter.level) { case LocationLevel.country: return countryRecentEntry(filter); @@ -583,8 +594,9 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place case LocationLevel.place: return placeRecentEntry(filter); } + } else if (filter is TagFilter) { + return tagRecentEntry(filter); } - if (filter is TagFilter) return tagRecentEntry(filter); return null; } @@ -608,7 +620,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place } void _onVaultsChanged() { - final newlyVisibleFilters = vaults.vaultDirectories.whereNot(vaults.isLocked).map((v) => AlbumFilter(v, null)).toSet(); + final newlyVisibleFilters = vaults.vaultDirectories.whereNot(vaults.isLocked).map((v) => StoredAlbumFilter(v, null)).toSet(); _onFilterVisibilityChanged(newlyVisibleFilters); } } diff --git a/lib/model/source/location/country.dart b/lib/model/source/location/country.dart index bf1b7264a..f7994077a 100644 --- a/lib/model/source/location/country.dart +++ b/lib/model/source/location/country.dart @@ -1,5 +1,5 @@ import 'package:aves/model/entry/entry.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/utils/collection_utils.dart'; import 'package:collection/collection.dart'; diff --git a/lib/model/source/location/location.dart b/lib/model/source/location/location.dart index d2af8780c..30ca150cf 100644 --- a/lib/model/source/location/location.dart +++ b/lib/model/source/location/location.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:aves/geo/countries.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/location.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/analysis_controller.dart'; diff --git a/lib/model/source/location/place.dart b/lib/model/source/location/place.dart index 3b518a182..42d7cd39c 100644 --- a/lib/model/source/location/place.dart +++ b/lib/model/source/location/place.dart @@ -1,5 +1,5 @@ import 'package:aves/model/entry/entry.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/utils/collection_utils.dart'; import 'package:collection/collection.dart'; diff --git a/lib/model/source/location/state.dart b/lib/model/source/location/state.dart index bfadc9568..8eb4abdfe 100644 --- a/lib/model/source/location/state.dart +++ b/lib/model/source/location/state.dart @@ -1,5 +1,5 @@ import 'package:aves/model/entry/entry.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/utils/collection_utils.dart'; import 'package:collection/collection.dart'; diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 1840cb111..eac2270fb 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -1,10 +1,11 @@ import 'dart:async'; import 'package:aves/model/covers.dart'; +import 'package:aves/model/dynamic_albums.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/origins.dart'; import 'package:aves/model/favourites.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_source.dart'; @@ -44,7 +45,7 @@ class MediaStoreSource extends CollectionSource { await reportService.log('$runtimeType init target scope=$scope'); _essentialLoader ??= _loadEssentials(); await _essentialLoader; - addDirectories(albums: settings.pinnedFilters.whereType().map((v) => v.album).toSet()); + addDirectories(albums: settings.pinnedFilters.whereType().map((v) => v.album).toSet()); await updateGeneration(); unawaited(_loadEntries( analysisController: analysisController, @@ -59,6 +60,7 @@ class MediaStoreSource extends CollectionSource { await vaults.init(); await favourites.init(); await covers.init(); + await dynamicAlbums.init(); final deviceOffset = DateTime.now().timeZoneOffset.inMilliseconds; final catalogOffset = settings.catalogTimeZoneOffsetMillis; @@ -81,7 +83,7 @@ class MediaStoreSource extends CollectionSource { state = SourceState.loading; clearEntries(); - final scopeAlbumFilters = _targetScope?.whereType(); + final scopeAlbumFilters = _targetScope?.whereType(); final scopeDirectory = scopeAlbumFilters != null && scopeAlbumFilters.length == 1 ? scopeAlbumFilters.first.album : null; final Set topEntries = {}; diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index efd96d26e..d0f0d3f8d 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -1,6 +1,6 @@ import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/catalog.dart'; -import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_source.dart'; diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index f947b079a..031788e88 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -126,6 +126,7 @@ class AIcons { static final unpin = MdiIcons.pinOffOutline; static const print = Icons.print_outlined; static const refresh = Icons.refresh_outlined; + static const remove = Icons.remove_outlined; static final resetBounds = MdiIcons.rayStartEnd; static const reverse = Icons.invert_colors_outlined; static const reset = Icons.restart_alt_outlined; @@ -187,6 +188,7 @@ class AIcons { // albums static const album = Icons.photo_album_outlined; + static const dynamicAlbum = Icons.image_search_outlined; static const cameraAlbum = Icons.photo_camera_outlined; static const downloadAlbum = Icons.file_download; static const screenshotAlbum = Icons.screenshot_outlined; diff --git a/lib/utils/collection_utils.dart b/lib/utils/collection_utils.dart index 0da1d0a58..505f487ac 100644 --- a/lib/utils/collection_utils.dart +++ b/lib/utils/collection_utils.dart @@ -1,5 +1,3 @@ - - extension ExtraList on List { bool replace(E old, E newItem) { final index = indexOf(old); @@ -10,6 +8,15 @@ extension ExtraList on List { } } +extension ExtraSet on Set { + bool replace(E old, E newItem) { + if (!remove(old)) return false; + + add(newItem); + return true; + } +} + extension ExtraMapNullableKey on Map { Map whereNotNullKey() => {for (var v in keys.nonNulls) v: this[v] as V}; } diff --git a/lib/view/src/actions/chip_set.dart b/lib/view/src/actions/chip_set.dart index 0e95ccfbc..e41ceb2d3 100644 --- a/lib/view/src/actions/chip_set.dart +++ b/lib/view/src/actions/chip_set.dart @@ -25,6 +25,7 @@ extension ExtraChipSetActionView on ChipSetAction { ChipSetAction.stats => l10n.menuActionStats, // selecting (single/multiple filters) ChipSetAction.delete => l10n.chipActionDelete, + ChipSetAction.remove => l10n.chipActionRemove, ChipSetAction.hide => l10n.chipActionHide, ChipSetAction.pin => l10n.chipActionPin, ChipSetAction.unpin => l10n.chipActionUnpin, @@ -60,6 +61,7 @@ extension ExtraChipSetActionView on ChipSetAction { ChipSetAction.stats => AIcons.stats, // selecting (single/multiple filters) ChipSetAction.delete => AIcons.delete, + ChipSetAction.remove => AIcons.remove, ChipSetAction.hide => AIcons.hide, ChipSetAction.pin => AIcons.pin, ChipSetAction.unpin => AIcons.unpin, diff --git a/lib/view/src/actions/entry_set.dart b/lib/view/src/actions/entry_set.dart index d9105e0e2..393ff839f 100644 --- a/lib/view/src/actions/entry_set.dart +++ b/lib/view/src/actions/entry_set.dart @@ -17,6 +17,7 @@ extension ExtraEntrySetActionView on EntrySetAction { EntrySetAction.toggleTitleSearch => // different data depending on toggle state l10n.collectionActionShowTitleSearch, + EntrySetAction.addDynamicAlbum => l10n.collectionActionAddDynamicAlbum, EntrySetAction.addShortcut => l10n.collectionActionAddShortcut, EntrySetAction.setHome => l10n.collectionActionSetHome, EntrySetAction.emptyBin => l10n.collectionActionEmptyBin, @@ -62,6 +63,7 @@ extension ExtraEntrySetActionView on EntrySetAction { EntrySetAction.toggleTitleSearch => // different data depending on toggle state AIcons.filter, + EntrySetAction.addDynamicAlbum => AIcons.dynamicAlbum, EntrySetAction.addShortcut => AIcons.addShortcut, EntrySetAction.setHome => AIcons.home, EntrySetAction.emptyBin => AIcons.emptyBin, diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index e2f47293f..89f921856 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -322,7 +322,7 @@ class _CollectionAppBarState extends State with SingleTickerPr bool canApply(EntrySetAction action) => _actionDelegate.canApply( action, isSelecting: isSelecting, - itemCount: collection.entryCount, + collection: collection, selectedItemCount: selectedItemCount, ); @@ -462,7 +462,7 @@ class _CollectionAppBarState extends State with SingleTickerPr return selection.selectedItems.expand((entry) => entry.stackedEntries ?? {entry}).toSet(); } - // key is expected by test driver (e.g. 'menu-configureView', 'menu-map') + // key is expected by test driver Key _getActionKey(EntrySetAction action) => Key('menu-${action.name}'); Widget _buildButtonIcon( @@ -636,6 +636,7 @@ class _CollectionAppBarState extends State with SingleTickerPr // browsing case EntrySetAction.searchCollection: case EntrySetAction.toggleTitleSearch: + case EntrySetAction.addDynamicAlbum: case EntrySetAction.addShortcut: case EntrySetAction.setHome: // browsing or selecting diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 7199fd7ad..b6236a80d 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -687,7 +687,7 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> with Widge sectionLayouts.forEach((section) { final directory = (section.sectionKey as EntryAlbumSectionKey).directory; if (directory != null) { - final label = source.getAlbumDisplayName(context, directory); + final label = source.getStoredAlbumDisplayName(context, directory); crumbs[section.minOffset / maxOffset] = label; } }); diff --git a/lib/widgets/collection/draggable_thumb_label.dart b/lib/widgets/collection/draggable_thumb_label.dart index 548dad5bd..8337f22a0 100644 --- a/lib/widgets/collection/draggable_thumb_label.dart +++ b/lib/widgets/collection/draggable_thumb_label.dart @@ -70,5 +70,5 @@ class CollectionDraggableThumbLabel extends StatelessWidget { bool _showAlbumName(BuildContext context, AvesEntry entry) => _hasMultipleSections(context) && entry.directory != null; - String _getAlbumName(BuildContext context, AvesEntry entry) => context.read().getAlbumDisplayName(context, entry.directory!); + String _getAlbumName(BuildContext context, AvesEntry entry) => context.read().getStoredAlbumDisplayName(context, entry.directory!); } diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 93f1c5618..b9bb0c8b2 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -2,13 +2,17 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; import 'package:aves/model/device.dart'; +import 'package:aves/model/dynamic_albums.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/favourites.dart'; import 'package:aves/model/entry/extensions/metadata_edition.dart'; import 'package:aves/model/entry/extensions/multipage.dart'; import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/favourites.dart'; +import 'package:aves/model/filters/covered/dynamic_album.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/set_and.dart'; +import 'package:aves/model/highlight.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/naming_pattern.dart'; import 'package:aves/model/query.dart'; @@ -39,7 +43,9 @@ import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/convert_entry_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/rename_entry_set_page.dart'; +import 'package:aves/widgets/dialogs/filter_editors/add_dynamic_album_dialog.dart'; import 'package:aves/widgets/dialogs/pick_dialogs/location_pick_page.dart'; +import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/search/search_delegate.dart'; import 'package:aves/widgets/stats/stats_page.dart'; @@ -75,28 +81,29 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware return isSelecting && selectedItemCount == itemCount; // browsing case EntrySetAction.searchCollection: - return !useTvLayout && appMode.canNavigate && !isSelecting; + return appMode.canNavigate && !isSelecting && !useTvLayout; case EntrySetAction.toggleTitleSearch: - return !useTvLayout && !isSelecting; + return !isSelecting && !useTvLayout; case EntrySetAction.addShortcut: return isMain && !isSelecting && !isTrash && device.canPinShortcut; + case EntrySetAction.addDynamicAlbum: case EntrySetAction.setHome: return isMain && !isSelecting && !isTrash && !useTvLayout; case EntrySetAction.emptyBin: - return canWrite && isMain && isTrash; + return isMain && isTrash && canWrite; // browsing or selecting case EntrySetAction.map: case EntrySetAction.slideshow: case EntrySetAction.stats: return isMain; case EntrySetAction.rescan: - return !useTvLayout && isMain && !isTrash && isSelecting; + return isMain && isSelecting && !isTrash && !useTvLayout; // selecting case EntrySetAction.share: case EntrySetAction.toggleFavourite: return isMain && isSelecting && !isTrash; case EntrySetAction.delete: - return canWrite && isMain && isSelecting; + return isMain && isSelecting && canWrite; case EntrySetAction.copy: case EntrySetAction.move: case EntrySetAction.rename: @@ -110,18 +117,19 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.editRating: case EntrySetAction.editTags: case EntrySetAction.removeMetadata: - return canWrite && isMain && isSelecting && !isTrash; + return isMain && isSelecting && !isTrash && canWrite; case EntrySetAction.restore: - return canWrite && isMain && isSelecting && isTrash; + return isMain && isSelecting && isTrash && canWrite; } } bool canApply( EntrySetAction action, { required bool isSelecting, - required int itemCount, + required CollectionLens collection, required int selectedItemCount, }) { + final itemCount = collection.entryCount; final hasItems = itemCount > 0; final hasSelection = selectedItemCount > 0; @@ -139,6 +147,8 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.addShortcut: case EntrySetAction.setHome: return true; + case EntrySetAction.addDynamicAlbum: + return collection.filters.isNotEmpty; case EntrySetAction.emptyBin: return !isSelecting && hasItems; case EntrySetAction.map: @@ -184,6 +194,8 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final routeName = context.currentRouteName!; settings.setShowTitleQuery(routeName, !settings.getShowTitleQuery(routeName)); context.read().toggle(); + case EntrySetAction.addDynamicAlbum: + _addDynamicAlbum(context); case EntrySetAction.addShortcut: _addShortcut(context); case EntrySetAction.setHome: @@ -727,10 +739,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware ); } - Future _addShortcut(BuildContext context) async { - final collection = context.read(); - final filters = collection.filters; - + static String? _getDefaultNameForFilters(BuildContext context, Set filters) { String? defaultName; if (filters.isNotEmpty) { // we compute the default name beforehand @@ -738,6 +747,67 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final sortedFilters = List.from(filters)..sort(); defaultName = sortedFilters.first.getLabel(context).replaceAll('\n', ' '); } + return defaultName; + } + + Future _addDynamicAlbum(BuildContext context) async { + final l10n = context.l10n; + final collection = context.read(); + final filters = collection.filters; + if (filters.isEmpty) return; + + // get navigator beforehand because + // local context may be deactivated when action is triggered after navigation + final navigator = Navigator.maybeOf(context); + + final name = await showDialog( + context: context, + builder: (context) => const AddDynamicAlbumDialog(), + routeSettings: const RouteSettings(name: AddDynamicAlbumDialog.routeName), + ); + if (name == null) return; + + final existingAlbum = dynamicAlbums.get(name); + if (existingAlbum != null) { + // album already exists, so we just need to highlight it + await _showDynamicAlbum(navigator, existingAlbum); + } else { + final album = DynamicAlbumFilter(name, filters.length == 1 ? filters.first : SetAndFilter(filters)); + await dynamicAlbums.add(album); + + final showAction = SnackBarAction( + label: l10n.showButtonLabel, + onPressed: () => _showDynamicAlbum(navigator, album), + ); + showFeedback(context, FeedbackType.info, l10n.genericSuccessFeedback, showAction); + } + } + + Future _showDynamicAlbum(NavigatorState? navigator, DynamicAlbumFilter album) async { + // local context may be deactivated when action is triggered after navigation + if (navigator != null) { + final context = navigator.context; + final highlightInfo = context.read(); + if (context.currentRouteName == AlbumListPage.routeName) { + highlightInfo.trackItem(FilterGridItem(album, null), highlightItem: album); + } else { + highlightInfo.set(album); + await navigator.pushAndRemoveUntil( + MaterialPageRoute( + settings: const RouteSettings(name: AlbumListPage.routeName), + builder: (_) => const AlbumListPage(), + ), + (route) => false, + ); + } + } + } + + Future _addShortcut(BuildContext context) async { + final collection = context.read(); + final filters = collection.filters; + + String? defaultName = _getDefaultNameForFilters(context, filters); final result = await showDialog<(AvesEntry?, String)>( context: context, builder: (context) => AddShortcutDialog( diff --git a/lib/widgets/collection/grid/headers/album.dart b/lib/widgets/collection/grid/headers/album.dart index 1a9e43d6e..8adb22ffc 100644 --- a/lib/widgets/collection/grid/headers/album.dart +++ b/lib/widgets/collection/grid/headers/album.dart @@ -53,7 +53,7 @@ class AlbumSectionHeader extends StatelessWidget { return SectionHeader.getPreferredHeight( context: context, maxWidth: maxWidth, - title: source.getAlbumDisplayName(context, directory), + title: source.getStoredAlbumDisplayName(context, directory), hasLeading: covers.effectiveAlbumType(directory) != AlbumType.regular, hasTrailing: androidFileUtils.isOnRemovableStorage(directory), ); diff --git a/lib/widgets/collection/grid/headers/any.dart b/lib/widgets/collection/grid/headers/any.dart index 213b7acf8..6b14ab3dc 100644 --- a/lib/widgets/collection/grid/headers/any.dart +++ b/lib/widgets/collection/grid/headers/any.dart @@ -78,7 +78,7 @@ class CollectionSectionHeader extends StatelessWidget { return AlbumSectionHeader( key: ValueKey(sectionKey), directory: directory, - albumName: directory != null ? source.getAlbumDisplayName(context, directory) : null, + albumName: directory != null ? source.getStoredAlbumDisplayName(context, directory) : null, selectable: selectable, ); } diff --git a/lib/widgets/common/action_controls/quick_choosers/album_chooser.dart b/lib/widgets/common/action_controls/quick_choosers/album_chooser.dart index e705d7670..da5a01782 100644 --- a/lib/widgets/common/action_controls/quick_choosers/album_chooser.dart +++ b/lib/widgets/common/action_controls/quick_choosers/album_chooser.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/widgets/common/action_controls/quick_choosers/common/menu.dart'; @@ -46,6 +46,6 @@ class AlbumQuickChooser extends StatelessWidget with FilterQuickChooserMixin(); - return AlbumFilter(option, source.getAlbumDisplayName(context, option)); + return StoredAlbumFilter(option, source.getStoredAlbumDisplayName(context, option)); } } diff --git a/lib/widgets/common/action_controls/quick_choosers/move_button.dart b/lib/widgets/common/action_controls/quick_choosers/move_button.dart index f15cca1c3..a3cc61bda 100644 --- a/lib/widgets/common/action_controls/quick_choosers/move_button.dart +++ b/lib/widgets/common/action_controls/quick_choosers/move_button.dart @@ -1,4 +1,4 @@ -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; @@ -44,7 +44,7 @@ class _MoveButtonState extends ChooserQuickButtonState { final options = settings.recentDestinationAlbums.where(rawAlbums.contains).toList(); final takeCount = FilterQuickChooserMixin.maxTotalOptionCount - options.length; if (takeCount > 0) { - final filters = rawAlbums.whereNot(options.contains).map((album) => AlbumFilter(album, null)).toSet(); + final filters = rawAlbums.whereNot(options.contains).map((album) => StoredAlbumFilter(album, null)).toSet(); final allMapEntries = filters.map((filter) => FilterGridItem(filter, source.recentEntry(filter))).toList(); allMapEntries.sort(FilterNavigationPage.compareFiltersByDate); options.addAll(allMapEntries.take(takeCount).map((v) => v.filter.album)); diff --git a/lib/widgets/common/action_controls/quick_choosers/tag_button.dart b/lib/widgets/common/action_controls/quick_choosers/tag_button.dart index 9437c1f24..beb62331e 100644 --- a/lib/widgets/common/action_controls/quick_choosers/tag_button.dart +++ b/lib/widgets/common/action_controls/quick_choosers/tag_button.dart @@ -1,5 +1,5 @@ import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/view/view.dart'; diff --git a/lib/widgets/common/action_mixins/entry_editor.dart b/lib/widgets/common/action_mixins/entry_editor.dart index 02cf9e9c8..43319fc3c 100644 --- a/lib/widgets/common/action_mixins/entry_editor.dart +++ b/lib/widgets/common/action_mixins/entry_editor.dart @@ -3,7 +3,7 @@ import 'package:aves/model/entry/extensions/metadata_edition.dart'; import 'package:aves/model/entry/extensions/multipage.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/placeholder.dart'; -import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/ref/mime_types.dart'; diff --git a/lib/widgets/common/action_mixins/entry_storage.dart b/lib/widgets/common/action_mixins/entry_storage.dart index 3406ee72f..f57a6e656 100644 --- a/lib/widgets/common/action_mixins/entry_storage.dart +++ b/lib/widgets/common/action_mixins/entry_storage.dart @@ -5,7 +5,7 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/multipage.dart'; import 'package:aves/model/entry/extensions/props.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/metadata/date_modifier.dart'; @@ -38,8 +38,10 @@ import 'package:provider/provider.dart'; mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { Future doExport(BuildContext context, Set targetEntries, EntryConvertOptions options) async { - final destinationAlbum = await pickAlbum(context: context, moveType: MoveType.export); - if (destinationAlbum == null) return; + final destinationAlbumFilter = await pickAlbum(context: context, moveType: MoveType.export, storedAlbumsOnly: true); + if (destinationAlbumFilter == null || destinationAlbumFilter is! StoredAlbumFilter) return; + + final destinationAlbum = destinationAlbumFilter.album; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; if (!await checkFreeSpaceForMove(context, targetEntries, destinationAlbum, MoveType.export)) return; @@ -125,7 +127,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) => CollectionPage( source: source, - filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, + filters: {StoredAlbumFilter(destinationAlbum, source.getStoredAlbumDisplayName(context, destinationAlbum))}, highlightTest: (entry) => newUris.contains(entry.uri), ), ), @@ -337,9 +339,10 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { case MoveType.copy: case MoveType.move: case MoveType.export: - final destinationAlbum = await pickAlbum(context: context, moveType: moveType); - if (destinationAlbum == null) return; + final destinationAlbumFilter = await pickAlbum(context: context, moveType: moveType, storedAlbumsOnly: true); + if (destinationAlbumFilter == null || destinationAlbumFilter is! StoredAlbumFilter) return; + final destinationAlbum = destinationAlbumFilter.album; settings.recentDestinationAlbums = settings.recentDestinationAlbums ..remove(destinationAlbum) ..insert(0, destinationAlbum); @@ -452,15 +455,15 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { bool highlightTest(AvesEntry entry) => newUris.contains(entry.uri); final collection = context.read(); - if (collection == null || collection.filters.any((f) => f is AlbumFilter || f is TrashFilter)) { + if (collection == null || collection.filters.any((f) => f is StoredAlbumFilter || f is TrashFilter)) { final source = context.read(); final targetFilters = collection?.filters.where((f) => f != TrashFilter.instance).toSet() ?? {}; // we could simply add the filter to the current collection // but navigating makes the change less jarring if (destinationAlbums.length == 1) { final destinationAlbum = destinationAlbums.single; - targetFilters.removeWhere((f) => f is AlbumFilter); - targetFilters.add(AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))); + targetFilters.removeWhere((f) => f is StoredAlbumFilter); + targetFilters.add(StoredAlbumFilter(destinationAlbum, source.getStoredAlbumDisplayName(context, destinationAlbum))); } unawaited(Navigator.maybeOf(context)?.pushAndRemoveUntil( MaterialPageRoute( diff --git a/lib/widgets/common/action_mixins/vault_aware.dart b/lib/widgets/common/action_mixins/vault_aware.dart index 22f6f9f74..13553a8da 100644 --- a/lib/widgets/common/action_mixins/vault_aware.dart +++ b/lib/widgets/common/action_mixins/vault_aware.dart @@ -1,4 +1,4 @@ -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/vaults/details.dart'; import 'package:aves/model/vaults/vaults.dart'; @@ -80,10 +80,10 @@ mixin VaultAwareMixin on FeedbackMixin { } Future unlockFilter(BuildContext context, CollectionFilter filter) { - return filter is AlbumFilter ? unlockAlbum(context, filter.album) : Future.value(true); + return filter is StoredAlbumFilter ? unlockAlbum(context, filter.album) : Future.value(true); } - Future unlockFilters(BuildContext context, Set filters) async { + Future unlockFilters(BuildContext context, Set filters) async { var unlocked = true; await Future.forEach(filters, (filter) async { if (unlocked) { @@ -93,7 +93,7 @@ mixin VaultAwareMixin on FeedbackMixin { return unlocked; } - void lockFilters(Set filters) => vaults.lock(filters.map((v) => v.album).toSet()); + void lockFilters(Set filters) => vaults.lock(filters.map((v) => v.album).toSet()); Future setVaultPass(BuildContext context, VaultDetails details) async { switch (details.lockType) { diff --git a/lib/widgets/common/expandable_filter_row.dart b/lib/widgets/common/expandable_filter_row.dart index a7111d555..a69652c43 100644 --- a/lib/widgets/common/expandable_filter_row.dart +++ b/lib/widgets/common/expandable_filter_row.dart @@ -167,7 +167,7 @@ class ExpandableFilterRow extends StatelessWidget { Widget _buildChip(CollectionFilter filter) { return AvesFilterChip( - // key `album-{path}` is expected by test driver + // key is expected by test driver key: Key(filter.key), filter: filter, allowGenericIcon: showGenericIcon, diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 357a3b759..4e33ce13c 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -3,12 +3,12 @@ import 'dart:math'; import 'package:aves/app_mode.dart'; import 'package:aves/model/covers.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/filters/path.dart'; import 'package:aves/model/filters/rating.dart'; -import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/colors.dart'; @@ -102,14 +102,11 @@ class AvesFilterChip extends StatefulWidget { static Future showDefaultLongPressMenu(BuildContext context, CollectionFilter filter, Offset tapPosition) async { if (context.read>().value.canNavigate) { final actions = [ - if (filter is AlbumFilter) ...[ - ChipAction.goToAlbumPage, - ChipAction.goToExplorerPage, - ], + if (filter is AlbumBaseFilter) ChipAction.goToAlbumPage, + if (filter is StoredAlbumFilter || filter is PathFilter) ChipAction.goToExplorerPage, if ((filter is LocationFilter && filter.level == LocationLevel.country)) ChipAction.goToCountryPage, if ((filter is LocationFilter && filter.level == LocationLevel.place)) ChipAction.goToPlacePage, if (filter is TagFilter) ChipAction.goToTagPage, - if (filter is PathFilter) ChipAction.goToExplorerPage, if (filter is RatingFilter && 1 < filter.rating && filter.rating < 5) ...[ if (filter.op != RatingFilter.opOrGreater) ChipAction.ratingOrGreater, if (filter.op != RatingFilter.opOrLower) ChipAction.ratingOrLower, diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index 0eb919a7a..bc840d801 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'package:aves/model/favourites.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/filters/path.dart'; -import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; diff --git a/lib/widgets/debug/database.dart b/lib/widgets/debug/database.dart index 39d81351d..a1120c8fa 100644 --- a/lib/widgets/debug/database.dart +++ b/lib/widgets/debug/database.dart @@ -1,4 +1,5 @@ import 'package:aves/model/covers.dart'; +import 'package:aves/model/dynamic_albums.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata/address.dart'; @@ -31,6 +32,7 @@ class _DebugAppDatabaseSectionState extends State with late Future> _dbVaultsLoader; late Future> _dbFavouritesLoader; late Future> _dbCoversLoader; + late Future> _dbDynamicAlbumsLoader; late Future> _dbVideoPlaybackLoader; @override @@ -247,6 +249,27 @@ class _DebugAppDatabaseSectionState extends State with ); }, ), + FutureBuilder( + future: _dbDynamicAlbumsLoader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + + if (snapshot.connectionState != ConnectionState.done) return const SizedBox(); + + return Row( + children: [ + Expanded( + child: Text('dynamic album rows: ${snapshot.data!.length} (${dynamicAlbums.count} in memory)'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () => dynamicAlbums.clear().then((_) => _reload()), + child: const Text('Clear'), + ), + ], + ); + }, + ), FutureBuilder( future: _dbVideoPlaybackLoader, builder: (context, snapshot) { @@ -279,7 +302,7 @@ class _DebugAppDatabaseSectionState extends State with await _disposeLoadedContent(); _startDbReport(); } - + void _startDbReport() { _dbFileSizeLoader = localMediaDb.dbFileSize(); _dbEntryLoader = localMediaDb.loadEntries(); @@ -290,10 +313,11 @@ class _DebugAppDatabaseSectionState extends State with _dbVaultsLoader = localMediaDb.loadAllVaults(); _dbFavouritesLoader = localMediaDb.loadAllFavourites(); _dbCoversLoader = localMediaDb.loadAllCovers(); + _dbDynamicAlbumsLoader = localMediaDb.loadAllDynamicAlbums(); _dbVideoPlaybackLoader = localMediaDb.loadAllVideoPlayback(); setState(() {}); } - + Future _disposeLoadedContent() async { (await _dbEntryLoader).forEach((v) => v.dispose()); } diff --git a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart index a15329c15..d9ed88020 100644 --- a/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_location_dialog.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/location.dart'; import 'package:aves/model/entry/extensions/metadata_edition.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/settings/enums/coordinate_format.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; diff --git a/lib/widgets/dialogs/entry_editors/tag_editor_page.dart b/lib/widgets/dialogs/entry_editors/tag_editor_page.dart index c0f7b3bc1..f2f88fe24 100644 --- a/lib/widgets/dialogs/entry_editors/tag_editor_page.dart +++ b/lib/widgets/dialogs/entry_editors/tag_editor_page.dart @@ -1,7 +1,7 @@ import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/placeholder.dart'; -import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/durations.dart'; diff --git a/lib/widgets/dialogs/filter_editors/add_dynamic_album_dialog.dart b/lib/widgets/dialogs/filter_editors/add_dynamic_album_dialog.dart new file mode 100644 index 000000000..b673b8d52 --- /dev/null +++ b/lib/widgets/dialogs/filter_editors/add_dynamic_album_dialog.dart @@ -0,0 +1,93 @@ +import 'package:aves/model/dynamic_albums.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:flutter/material.dart'; + +class AddDynamicAlbumDialog extends StatefulWidget { + static const routeName = '/dialog/add_dynamic_album'; + + const AddDynamicAlbumDialog({super.key}); + + @override + State createState() => _AddDynamicAlbumDialogState(); +} + +class _AddDynamicAlbumDialogState extends State { + final TextEditingController _nameController = TextEditingController(); + final ValueNotifier _existsNotifier = ValueNotifier(false); + final ValueNotifier _isValidNotifier = ValueNotifier(false); + + @override + void initState() { + super.initState(); + _validate(); + } + + @override + void dispose() { + _nameController.dispose(); + _existsNotifier.dispose(); + _isValidNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + return AvesDialog( + title: l10n.newDynamicAlbumDialogTitle, + content: ValueListenableBuilder( + valueListenable: _existsNotifier, + builder: (context, exists, child) { + return TextField( + controller: _nameController, + decoration: InputDecoration( + labelText: l10n.newAlbumDialogNameLabel, + helperText: exists ? l10n.dynamicAlbumAlreadyExists : '', + ), + autofocus: true, + onChanged: (_) => _validate(), + onSubmitted: (_) => _submit(context), + ); + }), + actions: [ + const CancelButton(), + ValueListenableBuilder( + valueListenable: _existsNotifier, + builder: (context, albumExists, child) { + return ValueListenableBuilder( + valueListenable: _isValidNotifier, + builder: (context, isValid, child) { + return TextButton( + onPressed: isValid ? () => _submit(context) : null, + child: Text(albumExists ? l10n.showButtonLabel : l10n.createAlbumButtonLabel), + ); + }, + ); + }, + ), + ], + ); + } + + String? _formatAlbumName() { + final name = _nameController.text.trim(); + if (name.isEmpty) return null; + + return name; + } + + void _validate() { + final name = _formatAlbumName(); + final isValid = name != null; + _isValidNotifier.value = isValid; + _existsNotifier.value = isValid && dynamicAlbums.contains(name); + } + + void _submit(BuildContext context) { + if (_isValidNotifier.value) { + Navigator.maybeOf(context)?.pop(_formatAlbumName()); + } + } +} diff --git a/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart b/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart index 02f4dd6a7..0d5eb89ca 100644 --- a/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart @@ -2,7 +2,7 @@ import 'dart:math'; import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/model/entry/entry.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -49,7 +49,7 @@ class _CoverSelectionDialogState extends State { CollectionFilter get filter => widget.filter; - bool get showAppTab => filter is AlbumFilter && settings.isInstalledAppAccessAllowed; + bool get showAppTab => filter is StoredAlbumFilter && settings.isInstalledAppAccessAllowed; bool get showColorTab => settings.themeColorMode == AvesThemeColorMode.polychrome; @@ -205,32 +205,35 @@ class _CoverSelectionDialogState extends State { overflow: TextOverflow.fade, maxLines: 1, ); - return RadioListTile( - value: isCustom, - groupValue: _isCustomEntry, - onChanged: (v) { - if (v == null) return; - if (v && _customEntry == null) { - _pickEntry(); - return; - } - _isCustomEntry = v; - setState(() {}); - }, - title: isCustom - ? Row( - children: [ - title, - const Spacer(), - if (_customEntry != null) - ItemPicker( - extent: itemPickerExtent, - entry: _customEntry!, - onTap: _pickEntry, - ), - ], - ) - : title, + return ListTileTheme.merge( + minVerticalPadding: isCustom && _customEntry != null ? 0 : null, + child: RadioListTile( + value: isCustom, + groupValue: _isCustomEntry, + onChanged: (v) { + if (v == null) return; + if (v && _customEntry == null) { + _pickEntry(); + return; + } + _isCustomEntry = v; + setState(() {}); + }, + title: isCustom + ? Row( + children: [ + title, + const Spacer(), + if (_customEntry != null) + ItemPicker( + extent: itemPickerExtent, + entry: _customEntry!, + onTap: _pickEntry, + ), + ], + ) + : title, + ), ); }, ).toList(); diff --git a/lib/widgets/dialogs/filter_editors/create_album_dialog.dart b/lib/widgets/dialogs/filter_editors/create_stored_album_dialog.dart similarity index 95% rename from lib/widgets/dialogs/filter_editors/create_album_dialog.dart rename to lib/widgets/dialogs/filter_editors/create_stored_album_dialog.dart index af47176c0..cd07ca0aa 100644 --- a/lib/widgets/dialogs/filter_editors/create_album_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/create_stored_album_dialog.dart @@ -12,16 +12,16 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -class CreateAlbumDialog extends StatefulWidget { - static const routeName = '/dialog/create_album'; +class CreateStoredAlbumDialog extends StatefulWidget { + static const routeName = '/dialog/create_stored_album'; - const CreateAlbumDialog({super.key}); + const CreateStoredAlbumDialog({super.key}); @override - State createState() => _CreateAlbumDialogState(); + State createState() => _CreateStoredAlbumDialogState(); } -class _CreateAlbumDialogState extends State { +class _CreateStoredAlbumDialogState extends State { final ScrollController _scrollController = ScrollController(); final TextEditingController _nameController = TextEditingController(); final FocusNode _nameFieldFocusNode = FocusNode(); diff --git a/lib/widgets/dialogs/filter_editors/edit_vault_dialog.dart b/lib/widgets/dialogs/filter_editors/edit_vault_dialog.dart index a3f363bf3..2017d2118 100644 --- a/lib/widgets/dialogs/filter_editors/edit_vault_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/edit_vault_dialog.dart @@ -1,5 +1,5 @@ import 'package:aves/model/device.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/vaults/details.dart'; @@ -127,7 +127,7 @@ class _EditVaultDialogState extends State with FeedbackMixin, V if (!v) { final album = initialDetails?.path; if (album != null) { - final filter = AlbumFilter(album, null); + final filter = StoredAlbumFilter(album, null); final source = context.read(); if (source.trashedEntries.any(filter.test)) { if (!await showConfirmationDialog( diff --git a/lib/widgets/dialogs/filter_editors/rename_dynamic_album_dialog.dart b/lib/widgets/dialogs/filter_editors/rename_dynamic_album_dialog.dart new file mode 100644 index 000000000..8222b6a1f --- /dev/null +++ b/lib/widgets/dialogs/filter_editors/rename_dynamic_album_dialog.dart @@ -0,0 +1,92 @@ +import 'package:aves/model/dynamic_albums.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:flutter/material.dart'; + +class RenameDynamicAlbumDialog extends StatefulWidget { + static const routeName = '/dialog/rename_dynamic_album'; + + final String name; + + const RenameDynamicAlbumDialog({ + super.key, + required this.name, + }); + + @override + State createState() => _RenameDynamicAlbumDialogState(); +} + +class _RenameDynamicAlbumDialogState extends State { + final TextEditingController _nameController = TextEditingController(); + final ValueNotifier _existsNotifier = ValueNotifier(false); + final ValueNotifier _isValidNotifier = ValueNotifier(false); + + String get initialValue => widget.name; + + @override + void initState() { + super.initState(); + _nameController.text = initialValue; + _validate(); + } + + @override + void dispose() { + _nameController.dispose(); + _existsNotifier.dispose(); + _isValidNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AvesDialog( + content: ValueListenableBuilder( + valueListenable: _existsNotifier, + builder: (context, exists, child) { + return TextField( + controller: _nameController, + decoration: InputDecoration( + labelText: context.l10n.renameAlbumDialogLabel, + helperText: exists ? context.l10n.dynamicAlbumAlreadyExists : '', + ), + autofocus: true, + onChanged: (_) => _validate(), + onSubmitted: (_) => _submit(context), + ); + }), + actions: [ + const CancelButton(), + ValueListenableBuilder( + valueListenable: _isValidNotifier, + builder: (context, isValid, child) { + return TextButton( + onPressed: isValid ? () => _submit(context) : null, + child: Text(context.l10n.applyButtonLabel), + ); + }, + ), + ], + ); + } + + String? _formatAlbumName() { + final name = _nameController.text.trim(); + if (name.isEmpty) return null; + + return name; + } + + Future _validate() async { + final newName = _formatAlbumName(); + _isValidNotifier.value = newName != null && !dynamicAlbums.contains(newName); + _existsNotifier.value = newName != null && dynamicAlbums.contains(newName) && newName != initialValue; + } + + void _submit(BuildContext context) { + if (_isValidNotifier.value) { + Navigator.maybeOf(context)?.pop(_formatAlbumName()); + } + } +} diff --git a/lib/widgets/dialogs/filter_editors/rename_album_dialog.dart b/lib/widgets/dialogs/filter_editors/rename_stored_album_dialog.dart similarity index 89% rename from lib/widgets/dialogs/filter_editors/rename_album_dialog.dart rename to lib/widgets/dialogs/filter_editors/rename_stored_album_dialog.dart index 0d254a30b..cee4f750f 100644 --- a/lib/widgets/dialogs/filter_editors/rename_album_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/rename_stored_album_dialog.dart @@ -5,21 +5,21 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:flutter/material.dart'; -class RenameAlbumDialog extends StatefulWidget { - static const routeName = '/dialog/rename_album'; +class RenameStoredAlbumDialog extends StatefulWidget { + static const routeName = '/dialog/rename_stored_album'; final String album; - const RenameAlbumDialog({ + const RenameStoredAlbumDialog({ super.key, required this.album, }); @override - State createState() => _RenameAlbumDialogState(); + State createState() => _RenameStoredAlbumDialogState(); } -class _RenameAlbumDialogState extends State { +class _RenameStoredAlbumDialogState extends State { final TextEditingController _nameController = TextEditingController(); final ValueNotifier _existsNotifier = ValueNotifier(false); final ValueNotifier _isValidNotifier = ValueNotifier(false); diff --git a/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart b/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart index 107383a5d..ce31a9357 100644 --- a/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart +++ b/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart @@ -1,5 +1,5 @@ import 'package:aves/app_mode.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/enums/accessibility_animations.dart'; @@ -19,7 +19,7 @@ import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/providers/query_provider.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; -import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart'; +import 'package:aves/widgets/dialogs/filter_editors/create_stored_album_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/edit_vault_dialog.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart'; @@ -30,9 +30,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; -Future pickAlbum({ +Future pickAlbum({ required BuildContext context, required MoveType? moveType, + required bool storedAlbumsOnly, }) async { final source = context.read(); if (source.targetScope != CollectionSource.fullScope) { @@ -41,13 +42,12 @@ Future pickAlbum({ source.canAnalyze = true; await source.init(scope: CollectionSource.fullScope); } - final filter = await Navigator.maybeOf(context)?.push( - MaterialPageRoute( + return await Navigator.maybeOf(context)?.push( + MaterialPageRoute( settings: const RouteSettings(name: _AlbumPickPage.routeName), - builder: (context) => _AlbumPickPage(source: source, moveType: moveType), + builder: (context) => _AlbumPickPage(source: source, moveType: moveType, storedAlbumsOnly: storedAlbumsOnly), ), ); - return filter?.album; } class _AlbumPickPage extends StatefulWidget { @@ -55,10 +55,12 @@ class _AlbumPickPage extends StatefulWidget { final CollectionSource source; final MoveType? moveType; + final bool storedAlbumsOnly; const _AlbumPickPage({ required this.source, required this.moveType, + required this.storedAlbumsOnly, }); @override @@ -111,11 +113,11 @@ class _AlbumPickPageState extends State<_AlbumPickPage> { return StreamBuilder( stream: source.eventBus.on(), builder: (context, snapshot) { - final gridItems = AlbumListPage.getAlbumGridItems(context, source); - return SelectionProvider>( + final gridItems = AlbumListPage.getAlbumGridItems(context, source, storedAlbumsOnly: widget.storedAlbumsOnly); + return SelectionProvider>( child: QueryProvider( startEnabled: settings.getShowTitleQuery(context.currentRouteName!), - child: FilterGridPage( + child: FilterGridPage( settingsRouteKey: AlbumListPage.routeName, appBar: FilterGridAppBar( source: source, @@ -150,7 +152,7 @@ class _AlbumPickPageState extends State<_AlbumPickPage> { List _buildActions( BuildContext context, AppMode appMode, - Selection> selection, + Selection> selection, AlbumChipSetActionDelegate actionDelegate, ) { final itemCount = actionDelegate.allItems.length; @@ -245,8 +247,8 @@ class _AlbumPickPageState extends State<_AlbumPickPage> { Future _createAlbum() async { final directory = await showDialog( context: context, - builder: (context) => const CreateAlbumDialog(), - routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName), + builder: (context) => const CreateStoredAlbumDialog(), + routeSettings: const RouteSettings(name: CreateStoredAlbumDialog.routeName), ); if (directory == null) return; @@ -282,8 +284,8 @@ class _AlbumPickPageState extends State<_AlbumPickPage> { } void _pickAlbum(String directory) { - source.createAlbum(directory); - final filter = AlbumFilter(directory, source.getAlbumDisplayName(context, directory)); - Navigator.maybeOf(context)?.pop(filter); + source.createStoredAlbum(directory); + final filter = StoredAlbumFilter(directory, source.getStoredAlbumDisplayName(context, directory)); + Navigator.maybeOf(context)?.pop(filter); } } diff --git a/lib/widgets/explorer/app_bar.dart b/lib/widgets/explorer/app_bar.dart index f4883915a..b182d11c9 100644 --- a/lib/widgets/explorer/app_bar.dart +++ b/lib/widgets/explorer/app_bar.dart @@ -16,9 +16,9 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_app_bar.dart'; import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/dialogs/select_storage_dialog.dart'; +import 'package:aves/widgets/explorer/crumb_line.dart'; import 'package:aves/widgets/explorer/explorer_action_delegate.dart'; import 'package:aves/widgets/search/search_delegate.dart'; -import 'package:aves/widgets/settings/privacy/file_picker/crumb_line.dart'; import 'package:aves_model/aves_model.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/settings/privacy/file_picker/crumb_line.dart b/lib/widgets/explorer/crumb_line.dart similarity index 100% rename from lib/widgets/settings/privacy/file_picker/crumb_line.dart rename to lib/widgets/explorer/crumb_line.dart diff --git a/lib/widgets/explorer/explorer_page.dart b/lib/widgets/explorer/explorer_page.dart index 859a70f72..390578e1b 100644 --- a/lib/widgets/explorer/explorer_page.dart +++ b/lib/widgets/explorer/explorer_page.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:io'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/path.dart'; import 'package:aves/model/source/album.dart'; @@ -194,7 +194,7 @@ class _ExplorerPageState extends State { final album = _getAlbumPath(source, Directory(dirPath)); if (album != null) { bottom = AvesFilterChip( - filter: AlbumFilter(album, source.getAlbumDisplayName(context, album)), + filter: StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album)), maxWidth: double.infinity, onTap: (filter) => _goToCollectionPage(context, filter), onLongPress: null, @@ -237,7 +237,7 @@ class _ExplorerPageState extends State { ? IconTheme.merge( data: baseIconTheme, child: AvesFilterChip( - filter: AlbumFilter(album, source.getAlbumDisplayName(context, album)), + filter: StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album)), showText: false, maxWidth: leadingDim, onTap: (filter) => _goToCollectionPage(context, filter), diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index f2b1641e6..2da03354f 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -1,13 +1,14 @@ import 'package:aves/model/app_inventory.dart'; import 'package:aves/model/covers.dart'; +import 'package:aves/model/dynamic_albums.dart'; import 'package:aves/model/entry/extensions/props.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; +import 'package:aves/model/filters/covered/dynamic_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart'; @@ -37,29 +38,32 @@ class AlbumListPage extends StatelessWidget { return ValueListenableBuilder( valueListenable: appInventory.areAppNamesReadyNotifier, builder: (context, areAppNamesReady, child) { - return StreamBuilder( - stream: source.eventBus.on(), - builder: (context, snapshot) { - final gridItems = getAlbumGridItems(context, source); - return StreamBuilder?>( - // to update sections by tier - stream: covers.packageChangeStream, - builder: (context, snapshot) => FilterNavigationPage( - source: source, - title: context.l10n.albumPageTitle, - sortFactor: settings.albumSortFactor, - showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, - actionDelegate: AlbumChipSetActionDelegate(gridItems), - filterSections: groupToSections(context, source, gridItems), - newFilters: source.getNewAlbumFilters(context), - applyQuery: applyQuery, - emptyBuilder: () => EmptyContent( - icon: AIcons.album, - text: context.l10n.albumEmpty, + return AnimatedBuilder( + animation: dynamicAlbums, + builder: (context, child) => StreamBuilder( + stream: source.eventBus.on(), + builder: (context, snapshot) { + final gridItems = getAlbumGridItems(context, source); + return StreamBuilder?>( + // to update sections by tier + stream: covers.packageChangeStream, + builder: (context, snapshot) => FilterNavigationPage( + source: source, + title: context.l10n.albumPageTitle, + sortFactor: settings.albumSortFactor, + showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, + actionDelegate: AlbumChipSetActionDelegate(gridItems), + filterSections: groupToSections(context, source, gridItems), + newFilters: source.getNewAlbumFilters(context), + applyQuery: applyQuery, + emptyBuilder: () => EmptyContent( + icon: AIcons.album, + text: context.l10n.albumEmpty, + ), ), - ), - ); - }, + ); + }, + ), ); }, ); @@ -69,21 +73,24 @@ class AlbumListPage extends StatelessWidget { // common with album selection page to move/copy entries - static List> applyQuery(BuildContext context, List> filters, String query) { - return filters.where((item) => (item.filter.displayName ?? item.filter.album).toUpperCase().contains(query)).toList(); + static List> applyQuery(BuildContext context, List> filters, String query) { + return filters.where((item) => item.filter.match(query)).toList(); } - static List> getAlbumGridItems(BuildContext context, CollectionSource source) { - final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getAlbumDisplayName(context, album))).toSet(); + static List> getAlbumGridItems(BuildContext context, CollectionSource source, {bool storedAlbumsOnly = false}) { + final filters = { + ...source.rawAlbums.map((album) => StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))), + if (!storedAlbumsOnly) ...dynamicAlbums.all, + }; return FilterNavigationPage.sort(settings.albumSortFactor, settings.albumSortReverse, source, filters); } - static Map>> groupToSections(BuildContext context, CollectionSource source, Iterable> sortedMapEntries) { + static Map>> groupToSections(BuildContext context, CollectionSource source, Iterable> sortedMapEntries) { final newFilters = source.getNewAlbumFilters(context); - final pinned = settings.pinnedFilters.whereType(); + final pinned = settings.pinnedFilters.whereType(); - final List> newMapEntries = [], pinnedMapEntries = [], unpinnedMapEntries = []; + final List> newMapEntries = [], pinnedMapEntries = [], unpinnedMapEntries = []; for (final item in sortedMapEntries) { final filter = item.filter; if (newFilters.contains(filter)) { @@ -95,24 +102,31 @@ class AlbumListPage extends StatelessWidget { } } - var sections = >>{}; + var sections = >>{}; switch (settings.albumGroupFactor) { case AlbumChipGroupFactor.importance: final specialKey = AlbumImportanceSectionKey.special(context); final appsKey = AlbumImportanceSectionKey.apps(context); final vaultKey = AlbumImportanceSectionKey.vault(context); final regularKey = AlbumImportanceSectionKey.regular(context); - sections = groupBy, ChipSectionKey>(unpinnedMapEntries, (kv) { - switch (covers.effectiveAlbumType(kv.filter.album)) { - case AlbumType.regular: - return regularKey; - case AlbumType.app: - return appsKey; - case AlbumType.vault: - return vaultKey; - default: - return specialKey; + final dynamicKey = AlbumImportanceSectionKey.dynamic(context); + sections = groupBy, ChipSectionKey>(unpinnedMapEntries, (kv) { + final filter = kv.filter; + if (filter is StoredAlbumFilter) { + switch (covers.effectiveAlbumType(filter.album)) { + case AlbumType.regular: + return regularKey; + case AlbumType.app: + return appsKey; + case AlbumType.vault: + return vaultKey; + default: + return specialKey; + } + } else if (filter is DynamicAlbumFilter) { + return dynamicKey; } + return specialKey; }); sections = { @@ -120,11 +134,12 @@ class AlbumListPage extends StatelessWidget { if (sections.containsKey(specialKey)) specialKey: sections[specialKey]!, if (sections.containsKey(appsKey)) appsKey: sections[appsKey]!, if (sections.containsKey(vaultKey)) vaultKey: sections[vaultKey]!, + if (sections.containsKey(dynamicKey)) dynamicKey: sections[dynamicKey]!, if (sections.containsKey(regularKey)) regularKey: sections[regularKey]!, }; case AlbumChipGroupFactor.mimeType: final visibleEntries = source.visibleEntries; - sections = groupBy, ChipSectionKey>(unpinnedMapEntries, (kv) { + sections = groupBy, ChipSectionKey>(unpinnedMapEntries, (kv) { final matches = visibleEntries.where(kv.filter.test); final hasImage = matches.any((v) => v.isImage); final hasVideo = matches.any((v) => v.isVideo); @@ -133,8 +148,8 @@ class AlbumListPage extends StatelessWidget { return MimeTypeSectionKey.mixed(context); }); case AlbumChipGroupFactor.volume: - sections = groupBy, ChipSectionKey>(unpinnedMapEntries, (kv) { - return StorageVolumeSectionKey(context, androidFileUtils.getStorageVolume(kv.filter.album)); + sections = groupBy, ChipSectionKey>(unpinnedMapEntries, (kv) { + return StorageVolumeSectionKey(context, kv.filter.storageVolume); }); case AlbumChipGroupFactor.none: return { 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 3b49cfb59..8fa81c36c 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -1,8 +1,11 @@ import 'dart:io'; import 'package:aves/app_mode.dart'; +import 'package:aves/model/covers.dart'; +import 'package:aves/model/dynamic_albums.dart'; import 'package:aves/model/entry/entry.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/dynamic_album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/settings/settings.dart'; @@ -14,6 +17,7 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/utils/collection_utils.dart'; import 'package:aves/view/view.dart'; import 'package:aves/widgets/common/action_mixins/entry_storage.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; @@ -21,9 +25,10 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; -import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart'; +import 'package:aves/widgets/dialogs/filter_editors/create_stored_album_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/edit_vault_dialog.dart'; -import 'package:aves/widgets/dialogs/filter_editors/rename_album_dialog.dart'; +import 'package:aves/widgets/dialogs/filter_editors/rename_dynamic_album_dialog.dart'; +import 'package:aves/widgets/dialogs/filter_editors/rename_stored_album_dialog.dart'; import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; @@ -33,13 +38,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; -class AlbumChipSetActionDelegate extends ChipSetActionDelegate with EntryStorageMixin { - final Iterable> _items; +class AlbumChipSetActionDelegate extends ChipSetActionDelegate with EntryStorageMixin { + final Iterable> _items; - AlbumChipSetActionDelegate(Iterable> items) : _items = items; + AlbumChipSetActionDelegate(Iterable> items) : _items = items; @override - Iterable> get allItems => _items; + Iterable> get allItems => _items; @override ChipSortFactor get sortFactor => settings.albumSortFactor; @@ -72,7 +77,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with required AppMode appMode, required bool isSelecting, required int itemCount, - required Set selectedFilters, + required Set selectedFilters, }) { final selectedSingleItem = selectedFilters.length == 1; final isMain = appMode == AppMode.main; @@ -82,14 +87,17 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with case ChipSetAction.createVault: return !settings.isReadOnly && appMode.canCreateFilter && !isSelecting; case ChipSetAction.delete: + return isMain && isSelecting && !settings.isReadOnly && !(selectedFilters.whereType().isEmpty && selectedFilters.whereType().isNotEmpty); + case ChipSetAction.remove: + return isMain && isSelecting && !settings.isReadOnly && selectedFilters.whereType().isEmpty && selectedFilters.whereType().isNotEmpty; case ChipSetAction.rename: return isMain && isSelecting && !settings.isReadOnly; case ChipSetAction.hide: - return isMain && selectedFilters.none((v) => vaults.isVault(v.album)); + return isMain && selectedFilters.none((v) => v.isVault); case ChipSetAction.configureVault: - return isMain && selectedSingleItem && vaults.isVault(selectedFilters.first.album); + return isMain && selectedSingleItem && selectedFilters.first.isVault; case ChipSetAction.lockVault: - return isMain && selectedFilters.any((v) => vaults.isVault(v.album)); + return isMain && selectedFilters.any((v) => v.isVault); default: return super.isVisible( action, @@ -106,25 +114,20 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with ChipSetAction action, { required bool isSelecting, required int itemCount, - required Set selectedFilters, + required Set selectedFilters, }) { final selectedItemCount = selectedFilters.length; final hasSelection = selectedItemCount > 0; switch (action) { + case ChipSetAction.delete: + return selectedFilters.whereType().isNotEmpty && selectedFilters.whereType().isEmpty; case ChipSetAction.rename: - if (selectedFilters.length != 1) return false; - - final dirPath = selectedFilters.first.album; - if (vaults.isVault(dirPath)) return true; - - // do not allow renaming volume root - final dir = androidFileUtils.relativeDirectoryFromPath(dirPath); - return dir != null && dir.relativeDir.isNotEmpty; + return selectedFilters.length == 1 && selectedFilters.first.canRename; case ChipSetAction.hide: return hasSelection; case ChipSetAction.lockVault: - return selectedFilters.map((v) => v.album).any((v) => vaults.isVault(v) && !vaults.isLocked(v)); + return selectedFilters.whereType().map((v) => v.album).any((v) => vaults.isVault(v) && !vaults.isLocked(v)); case ChipSetAction.configureVault: return true; default: @@ -143,14 +146,16 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with switch (action) { // general case ChipSetAction.createAlbum: - _createAlbum(context, locked: false); + _createStoredAlbum(context, locked: false); case ChipSetAction.createVault: - _createAlbum(context, locked: true); + _createStoredAlbum(context, locked: true); // single/multiple filters case ChipSetAction.delete: - _delete(context); + _deleteStoredAlbums(context); + case ChipSetAction.remove: + _removeDynamicAlbum(context); case ChipSetAction.lockVault: - lockFilters(getSelectedFilters(context)); + lockFilters(_getSelectedStoredAlbumFilters(context)); browse(context); // single filter case ChipSetAction.rename: @@ -163,6 +168,14 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with super.onActionSelected(context, action); } + Set _getSelectedStoredAlbumFilters(BuildContext context) { + return getSelectedFilters(context).whereType().toSet(); + } + + Set _getSelectedDynamicAlbumFilters(BuildContext context) { + return getSelectedFilters(context).whereType().toSet(); + } + @override Future configureView(BuildContext context) async { final initialValue = ( @@ -196,7 +209,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with } } - void _createAlbum(BuildContext context, {required bool locked}) async { + void _createStoredAlbum(BuildContext context, {required bool locked}) async { final l10n = context.l10n; final source = context.read(); @@ -218,7 +231,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with final details = await showDialog( context: context, builder: (context) => const EditVaultDialog(), - routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName), + routeSettings: const RouteSettings(name: CreateStoredAlbumDialog.routeName), ); if (details == null) return; @@ -227,15 +240,15 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with } else { directory = await showDialog( context: context, - builder: (context) => const CreateAlbumDialog(), - routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName), + builder: (context) => const CreateStoredAlbumDialog(), + routeSettings: const RouteSettings(name: CreateStoredAlbumDialog.routeName), ); if (directory == null) return; // wait for the dialog to hide await Future.delayed(ADurations.dialogTransitionLoose * timeDilation); } - final filter = AlbumFilter(directory, source.getAlbumDisplayName(context, directory)); + final filter = StoredAlbumFilter(directory, source.getStoredAlbumDisplayName(context, directory)); final albumExists = source.rawAlbums.contains(directory); if (albumExists) { @@ -243,7 +256,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with await _showAlbum(navigator, filter); } else { // create the album and mark it as new - source.createAlbum(directory); + source.createStoredAlbum(directory); final showAction = SnackBarAction( label: l10n.showButtonLabel, @@ -253,7 +266,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with } } - Future _showAlbum(NavigatorState? navigator, AlbumFilter filter) async { + Future _showAlbum(NavigatorState? navigator, StoredAlbumFilter filter) async { // local context may be deactivated when action is triggered after navigation if (navigator != null) { final context = navigator.context; @@ -273,9 +286,9 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with } } - Future _delete(BuildContext context) async { - final filters = getSelectedFilters(context); - final byBinUsage = groupBy(filters, (filter) { + Future _deleteStoredAlbums(BuildContext context) async { + final filters = _getSelectedStoredAlbumFilters(context); + final byBinUsage = groupBy(filters, (filter) { final details = vaults.getVault(filter.album); return details?.useBin ?? settings.enableBin; }); @@ -291,7 +304,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with Future _doDelete({ required BuildContext context, - required Set filters, + required Set filters, required bool enableBin, }) async { if (!await unlockFilters(context, filters)) return; @@ -388,44 +401,125 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with ); } + Future _removeDynamicAlbum(BuildContext context) async { + final l10n = context.l10n; + final confirmed = await showDialog( + context: context, + builder: (context) => AvesDialog( + content: Text(l10n.genericDangerWarningDialogMessage), + actions: [ + const CancelButton(), + TextButton( + onPressed: () => Navigator.maybeOf(context)?.pop(true), + child: Text(l10n.applyButtonLabel), + ), + ], + ), + routeSettings: const RouteSettings(name: AvesDialog.warningRouteName), + ); + if (confirmed == null || !confirmed) return; + + final albumFilters = _getSelectedDynamicAlbumFilters(context); + final names = albumFilters.map((v) => v.name).toSet(); + bool isRemoved(CollectionFilter v) => v is DynamicAlbumFilter && names.contains(v.name); + + await dynamicAlbums.remove(albumFilters); + + // cleanup + await covers.removeAll(albumFilters, notify: true); + settings.drawerAlbumBookmarks = settings.drawerAlbumBookmarks?..removeWhere(isRemoved); + settings.pinnedFilters = settings.pinnedFilters..removeWhere(isRemoved); + + browse(context); + } + Future _rename(BuildContext context) async { final filters = getSelectedFilters(context); if (filters.isEmpty) return; final filter = filters.first; - if (!await unlockFilter(context, filter)) return; + if (filter is StoredAlbumFilter) { + if (!await unlockFilter(context, filter)) return; - final album = filter.album; - if (!vaults.isVault(album)) { - final dir = androidFileUtils.relativeDirectoryFromPath(album); - // do not allow renaming volume root - if (dir == null || dir.relativeDir.isEmpty) return; + final album = filter.album; + if (!vaults.isVault(album)) { + final dir = androidFileUtils.relativeDirectoryFromPath(album); + // do not allow renaming volume root + if (dir == null || dir.relativeDir.isEmpty) return; - // check whether renaming is possible given OS restrictions, - // before asking to input a new name - final restrictedDirsLowerCase = await storageService.getRestrictedDirectoriesLowerCase(); - if (restrictedDirsLowerCase.contains(dir.copyWith(relativeDir: dir.relativeDir.toLowerCase()))) { - await showRestrictedDirectoryDialog(context, dir); - return; + // check whether renaming is possible given OS restrictions, + // before asking to input a new name + final restrictedDirsLowerCase = await storageService.getRestrictedDirectoriesLowerCase(); + if (restrictedDirsLowerCase.contains(dir.copyWith(relativeDir: dir.relativeDir.toLowerCase()))) { + await showRestrictedDirectoryDialog(context, dir); + return; + } } + + final newName = await showDialog( + context: context, + builder: (context) => RenameStoredAlbumDialog(album: album), + routeSettings: const RouteSettings(name: RenameStoredAlbumDialog.routeName), + ); + if (newName == null || newName.isEmpty) return; + + await _doRenameStoredAlbum(context, filter, newName); + } else if (filter is DynamicAlbumFilter) { + final newName = await showDialog( + context: context, + builder: (context) => RenameDynamicAlbumDialog(name: filter.name), + routeSettings: const RouteSettings(name: RenameStoredAlbumDialog.routeName), + ); + if (newName == null || newName.isEmpty) return; + + await _doRenameDynamicAlbum(context, filter, newName); } - - final newName = await showDialog( - context: context, - builder: (context) => RenameAlbumDialog(album: album), - routeSettings: const RouteSettings(name: RenameAlbumDialog.routeName), - ); - if (newName == null || newName.isEmpty) return; - - await _doRename(context, filter, newName); } - Future _doRename(BuildContext context, AlbumFilter filter, String newName) async { + Future _doRenameDynamicAlbum(BuildContext context, DynamicAlbumFilter albumFilter, String newName) async { + final oldName = albumFilter.name; + + // save cover and bookmark before renaming + final cover = await covers.remove(albumFilter, notify: false); + final bookmarks = settings.drawerAlbumBookmarks; + final pinnedFilters = settings.pinnedFilters; + + await dynamicAlbums.rename(albumFilter, newName); + final newFilter = DynamicAlbumFilter(newName, albumFilter.filter); + bool isRenamed(CollectionFilter v) => v is DynamicAlbumFilter && v.name == oldName; + + // update cover + if (cover != null) { + await covers.set( + filter: newFilter, + entryId: cover.$1, + packageName: cover.$2, + color: cover.$3, + notify: true, + ); + } + // update drawer bookmark + final bookmark = bookmarks?.firstWhereOrNull(isRenamed); + if (bookmark != null) { + bookmarks?.replace(bookmark, newFilter); + settings.drawerAlbumBookmarks = bookmarks; + } + // update pin + final pin = pinnedFilters.firstWhereOrNull(isRenamed); + if (pin != null) { + pinnedFilters.replace(pin, newFilter); + settings.pinnedFilters = pinnedFilters; + } + + browse(context); + } + + Future _doRenameStoredAlbum(BuildContext context, StoredAlbumFilter albumFilter, String newName) async { final l10n = context.l10n; final messenger = ScaffoldMessenger.of(context); final source = context.read(); - final album = filter.album; - final todoEntries = source.visibleEntries.where(filter.test).toSet(); + final album = albumFilter.album; + final todoEntries = source.visibleEntries.where(albumFilter.test).toSet(); final todoCount = todoEntries.length; final destinationAlbumParent = pContext.dirname(album); @@ -455,7 +549,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with onDone: (processed) async { final successOps = processed.where((e) => e.success).toSet(); final movedOps = successOps.where((e) => !e.skipped).toSet(); - await source.renameAlbum(album, destinationAlbum, todoEntries, movedOps); + await source.renameStoredAlbum(album, destinationAlbum, todoEntries, movedOps); browse(context); source.resumeMonitoring(); @@ -478,6 +572,8 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with if (filters.isEmpty) return; final filter = filters.first; + if (filter is! StoredAlbumFilter) return; + if (!await unlockFilter(context, filter)) return; final oldDetails = vaults.getVault(filter.album); @@ -491,7 +587,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with if (newDetails == null || oldDetails == newDetails) return; if (oldDetails.useBin && !newDetails.useBin) { - final filter = AlbumFilter(oldDetails.path, null); + final filter = StoredAlbumFilter(oldDetails.path, null); final source = context.read(); await _deleteEntriesForever(context, source.trashedEntries.where(filter.test).toSet()); } @@ -503,7 +599,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with // wipe the old pass, if any, so that it does not overwrite the new pass // when renaming the vault afterwards await securityService.writeValue(oldDetails.passKey, null); - await _doRename(context, filter, newName); + await _doRenameStoredAlbum(context, filter, newName); } else { await vaults.update(newDetails); browse(context); diff --git a/lib/widgets/filter_grids/common/action_delegates/chip.dart b/lib/widgets/filter_grids/common/action_delegates/chip.dart index 86b23b105..7f05b7fb5 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip.dart @@ -1,4 +1,4 @@ -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/path.dart'; import 'package:aves/model/filters/rating.dart'; @@ -35,9 +35,9 @@ class ChipActionDelegate with FeedbackMixin, VaultAwareMixin { case ChipAction.reverse: return true; case ChipAction.hide: - return !(filter is AlbumFilter && vaults.isVault(filter.album)); + return !(filter is StoredAlbumFilter && vaults.isVault(filter.album)); case ChipAction.lockVault: - return (filter is AlbumFilter && vaults.isVault(filter.album) && !vaults.isLocked(filter.album)); + return (filter is StoredAlbumFilter && vaults.isVault(filter.album) && !vaults.isLocked(filter.album)); } } @@ -54,7 +54,7 @@ class ChipActionDelegate with FeedbackMixin, VaultAwareMixin { _goTo(context, filter, TagListPage.routeName, (context) => const TagListPage()); case ChipAction.goToExplorerPage: String? path; - if (filter is AlbumFilter) { + if (filter is StoredAlbumFilter) { path = filter.album; } else if (filter is PathFilter) { path = filter.path; @@ -77,7 +77,7 @@ class ChipActionDelegate with FeedbackMixin, VaultAwareMixin { case ChipAction.hide: _hide(context, filter); case ChipAction.lockVault: - if (filter is AlbumFilter) { + if (filter is StoredAlbumFilter) { lockFilters({filter}); } } 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 b6b5537d7..ec4b49597 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -1,9 +1,9 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/covers.dart'; import 'package:aves/model/entry/entry.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/or.dart'; +import 'package:aves/model/filters/set_or.dart'; import 'package:aves/model/query.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; @@ -107,6 +107,7 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.showCollection: return appMode.canNavigate; case ChipSetAction.delete: + case ChipSetAction.remove: case ChipSetAction.lockVault: case ChipSetAction.showCountryStates: return false; @@ -148,6 +149,7 @@ abstract class ChipSetActionDelegate with FeedbackMi return (!isSelecting && hasItems) || (isSelecting && hasSelection); // selecting (single/multiple filters) case ChipSetAction.delete: + case ChipSetAction.remove: case ChipSetAction.hide: case ChipSetAction.pin: case ChipSetAction.unpin: @@ -204,6 +206,7 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.showCollection: _goToCollection(context); case ChipSetAction.delete: + case ChipSetAction.remove: case ChipSetAction.lockVault: case ChipSetAction.showCountryStates: break; @@ -264,7 +267,7 @@ abstract class ChipSetActionDelegate with FeedbackMi final filters = getSelectedFilters(context); if (filters.isEmpty) return; - final filter = filters.length > 1 ? OrFilter(filters) : filters.first; + final filter = filters.length > 1 ? SetOrFilter(filters) : filters.first; await Navigator.maybeOf(context)?.push( MaterialPageRoute( settings: const RouteSettings(name: CollectionPage.routeName), @@ -378,7 +381,7 @@ abstract class ChipSetActionDelegate with FeedbackMi ); if (selectedCover == null) return; - if (filter is AlbumFilter) { + if (filter is StoredAlbumFilter) { context.read().clearAppColor(filter.album); } diff --git a/lib/widgets/filter_grids/common/action_delegates/country_set.dart b/lib/widgets/filter_grids/common/action_delegates/country_set.dart index 418fe1640..85aaa3b87 100644 --- a/lib/widgets/filter_grids/common/action_delegates/country_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/country_set.dart @@ -1,7 +1,7 @@ import 'package:aves/app_mode.dart'; import 'package:aves/geo/states.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; diff --git a/lib/widgets/filter_grids/common/action_delegates/place_set.dart b/lib/widgets/filter_grids/common/action_delegates/place_set.dart index be9549e60..53ebf9a61 100644 --- a/lib/widgets/filter_grids/common/action_delegates/place_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/place_set.dart @@ -1,5 +1,5 @@ import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; import 'package:aves/widgets/filter_grids/places_page.dart'; diff --git a/lib/widgets/filter_grids/common/action_delegates/state_set.dart b/lib/widgets/filter_grids/common/action_delegates/state_set.dart index 8fb3d5cf9..4cd2e5cbc 100644 --- a/lib/widgets/filter_grids/common/action_delegates/state_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/state_set.dart @@ -1,5 +1,5 @@ import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; import 'package:aves/widgets/filter_grids/states_page.dart'; diff --git a/lib/widgets/filter_grids/common/action_delegates/tag_set.dart b/lib/widgets/filter_grids/common/action_delegates/tag_set.dart index 251c855a4..0ee0e31b6 100644 --- a/lib/widgets/filter_grids/common/action_delegates/tag_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/tag_set.dart @@ -1,6 +1,6 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/common/services.dart'; @@ -50,7 +50,7 @@ class TagChipSetActionDelegate extends ChipSetActionDelegate { final isMain = appMode == AppMode.main; switch (action) { - case ChipSetAction.delete: + case ChipSetAction.remove: return isMain && isSelecting && !settings.isReadOnly; default: return super.isVisible( @@ -68,30 +68,31 @@ class TagChipSetActionDelegate extends ChipSetActionDelegate { reportService.log('$runtimeType handles $action'); switch (action) { // single/multiple filters - case ChipSetAction.delete: - _delete(context); + case ChipSetAction.remove: + _remove(context); default: break; } super.onActionSelected(context, action); } - Future _delete(BuildContext context) async { + Future _remove(BuildContext context) async { final filters = getSelectedFilters(context); final source = context.read(); final todoEntries = source.visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet(); final todoTags = filters.map((v) => v.tag).toSet(); + final l10n = context.l10n; final confirmed = await showDialog( context: context, builder: (context) => AvesDialog( - content: Text(context.l10n.genericDangerWarningDialogMessage), + content: Text(l10n.genericDangerWarningDialogMessage), actions: [ const CancelButton(), TextButton( onPressed: () => Navigator.maybeOf(context)?.pop(true), - child: Text(context.l10n.applyButtonLabel), + child: Text(l10n.applyButtonLabel), ), ], ), diff --git a/lib/widgets/filter_grids/common/covered_filter_chip.dart b/lib/widgets/filter_grids/common/covered_filter_chip.dart index bf6d318aa..18b6822e7 100644 --- a/lib/widgets/filter_grids/common/covered_filter_chip.dart +++ b/lib/widgets/filter_grids/common/covered_filter_chip.dart @@ -2,10 +2,11 @@ import 'dart:math'; import 'package:aves/model/app_inventory.dart'; import 'package:aves/model/covers.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/dynamic_album.dart'; +import 'package:aves/model/filters/covered/location.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; -import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/location/country.dart'; @@ -69,11 +70,18 @@ class CoveredFilterChip extends StatelessWidget { builder: (context, snapshot) => Consumer( builder: (context, source, child) { switch (filter) { - case AlbumFilter filter: + case StoredAlbumFilter filter: { final album = filter.album; - return StreamBuilder( - stream: source.eventBus.on().where((event) => event.directories == null || event.directories!.contains(album)), + return StreamBuilder( + stream: source.eventBus.on().where((event) => event.directories == null || event.directories!.contains(album)), + builder: (context, snapshot) => _buildChip(context, source), + ); + } + case DynamicAlbumFilter _: + { + return StreamBuilder( + stream: source.eventBus.on(), builder: (context, snapshot) => _buildChip(context, source), ); } @@ -103,10 +111,10 @@ class CoveredFilterChip extends StatelessWidget { Widget _buildChip(BuildContext context, CollectionSource source) { final _filter = filter; - final entry = _filter is AlbumFilter && vaults.isLocked(_filter.album) ? null : source.coverEntry(_filter); + final entry = _filter is StoredAlbumFilter && vaults.isLocked(_filter.album) ? null : source.coverEntry(_filter); final titlePadding = min(4.0, extent / 32); Key? chipKey; - if (_filter is AlbumFilter) { + if (_filter is StoredAlbumFilter) { // when we asynchronously fetch installed app names, // album filters themselves do not change, but decoration derived from it does chipKey = ValueKey(appInventory.areAppNamesReadyNotifier.value); @@ -172,52 +180,35 @@ class CoveredFilterChip extends StatelessWidget { Color _detailColor(BuildContext context) => Theme.of(context).colorScheme.onSurfaceVariant; Widget _buildDetails(BuildContext context, CollectionSource source, T filter) { - final countFormatter = NumberFormat.decimalPattern(context.locale); - - final padding = min(8.0, extent / 16); - final iconSize = detailIconSize(extent); - final fontSize = detailFontSize(extent); return Row( mainAxisSize: MainAxisSize.min, children: [ - if (pinned) - AnimatedPadding( - padding: EdgeInsetsDirectional.only(end: padding), - duration: ADurations.chipDecorationAnimation, - child: Icon( - AIcons.pin, - color: _detailColor(context), - size: iconSize, - ), - ), - if (filter is AlbumFilter && androidFileUtils.isOnRemovableStorage(filter.album)) - AnimatedPadding( - padding: EdgeInsetsDirectional.only(end: padding), - duration: ADurations.chipDecorationAnimation, - child: Icon( - AIcons.storageCard, - color: _detailColor(context), - size: iconSize, - ), - ), - if (filter is AlbumFilter && vaults.isVault(filter.album)) - AnimatedPadding( - padding: EdgeInsetsDirectional.only(end: padding), - duration: ADurations.chipDecorationAnimation, - child: Icon( - AIcons.locked, - color: _detailColor(context), - size: iconSize, - ), - ), + if (pinned) _buildDetailIcon(context, AIcons.pin), + if (filter is StoredAlbumFilter && androidFileUtils.isOnRemovableStorage(filter.album)) _buildDetailIcon(context, AIcons.storageCard), + if (filter is StoredAlbumFilter && vaults.isVault(filter.album)) _buildDetailIcon(context, AIcons.locked), + if (filter is DynamicAlbumFilter) _buildDetailIcon(context, AIcons.dynamicAlbum), Text( - locked ? AText.valueNotAvailable : countFormatter.format(source.count(filter)), + locked ? AText.valueNotAvailable : NumberFormat.decimalPattern(context.locale).format(source.count(filter)), style: TextStyle( color: _detailColor(context), - fontSize: fontSize, + fontSize: detailFontSize(extent), ), ), ], ); } + + Widget _buildDetailIcon(BuildContext context, IconData icon) { + final padding = min(8.0, extent / 16); + final iconSize = detailIconSize(extent); + return AnimatedPadding( + padding: EdgeInsetsDirectional.only(end: padding), + duration: ADurations.chipDecorationAnimation, + child: Icon( + icon, + color: _detailColor(context), + size: iconSize, + ), + ); + } } diff --git a/lib/widgets/filter_grids/common/enums.dart b/lib/widgets/filter_grids/common/enums.dart index e132044ad..f84d77168 100644 --- a/lib/widgets/filter_grids/common/enums.dart +++ b/lib/widgets/filter_grids/common/enums.dart @@ -2,7 +2,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; -enum AlbumImportance { newAlbum, pinned, special, apps, vaults, regular } +enum AlbumImportance { newAlbum, pinned, special, apps, vaults, dynamic, regular } extension ExtraAlbumImportance on AlbumImportance { String getText(BuildContext context) { @@ -13,6 +13,7 @@ extension ExtraAlbumImportance on AlbumImportance { AlbumImportance.special => l10n.albumTierSpecial, AlbumImportance.apps => l10n.albumTierApps, AlbumImportance.vaults => l10n.albumTierVaults, + AlbumImportance.dynamic => l10n.albumTierDynamic, AlbumImportance.regular => l10n.albumTierRegular, }; } @@ -24,6 +25,7 @@ extension ExtraAlbumImportance on AlbumImportance { AlbumImportance.special => AIcons.important, AlbumImportance.apps => AIcons.app, AlbumImportance.vaults => AIcons.locked, + AlbumImportance.dynamic => AIcons.dynamicAlbum, AlbumImportance.regular => AIcons.album, }; } diff --git a/lib/widgets/filter_grids/common/filter_tile.dart b/lib/widgets/filter_grids/common/filter_tile.dart index f4c1cdf06..d78e155e2 100644 --- a/lib/widgets/filter_grids/common/filter_tile.dart +++ b/lib/widgets/filter_grids/common/filter_tile.dart @@ -1,5 +1,5 @@ import 'package:aves/app_mode.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; @@ -134,7 +134,7 @@ class FilterTile extends StatelessWidget { Widget build(BuildContext context) { final filter = gridItem.filter; final pinned = settings.pinnedFilters.contains(filter); - final locked = filter is AlbumFilter && vaults.isLocked(filter.album); + final locked = filter is StoredAlbumFilter && vaults.isLocked(filter.album); final onChipTap = onTap != null ? (filter) => onTap?.call() : null; switch (tileLayout) { diff --git a/lib/widgets/filter_grids/common/list_details.dart b/lib/widgets/filter_grids/common/list_details.dart index 3f974cdcb..d8788c80f 100644 --- a/lib/widgets/filter_grids/common/list_details.dart +++ b/lib/widgets/filter_grids/common/list_details.dart @@ -1,5 +1,6 @@ import 'package:aves/model/entry/entry.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/dynamic_album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/format.dart'; @@ -116,11 +117,12 @@ class FilterListDetails extends StatelessWidget { Widget _buildCountRow(BuildContext context, FilterListDetailsThemeData detailsTheme, bool hasTitleLeading) { final _filter = filter; - final removableStorage = _filter is AlbumFilter && androidFileUtils.isOnRemovableStorage(_filter.album); + final removableStorage = _filter is StoredAlbumFilter && androidFileUtils.isOnRemovableStorage(_filter.album); List leadingIcons = [ if (pinned) const Icon(AIcons.pin), if (removableStorage) const Icon(AIcons.storageCard), + if (_filter is DynamicAlbumFilter) const Icon(AIcons.dynamicAlbum), ]; Widget? leading; diff --git a/lib/widgets/filter_grids/common/section_keys.dart b/lib/widgets/filter_grids/common/section_keys.dart index 507dcc57c..26efbe654 100644 --- a/lib/widgets/filter_grids/common/section_keys.dart +++ b/lib/widgets/filter_grids/common/section_keys.dart @@ -35,6 +35,8 @@ class AlbumImportanceSectionKey extends ChipSectionKey { factory AlbumImportanceSectionKey.vault(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.vaults); + factory AlbumImportanceSectionKey.dynamic(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.dynamic); + factory AlbumImportanceSectionKey.regular(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.regular); @override diff --git a/lib/widgets/filter_grids/countries_page.dart b/lib/widgets/filter_grids/countries_page.dart index a26858939..c02f418a9 100644 --- a/lib/widgets/filter_grids/countries_page.dart +++ b/lib/widgets/filter_grids/countries_page.dart @@ -1,5 +1,5 @@ import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/location/country.dart'; diff --git a/lib/widgets/filter_grids/places_page.dart b/lib/widgets/filter_grids/places_page.dart index c0b456a75..a03702341 100644 --- a/lib/widgets/filter_grids/places_page.dart +++ b/lib/widgets/filter_grids/places_page.dart @@ -1,5 +1,5 @@ import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/location/place.dart'; diff --git a/lib/widgets/filter_grids/states_page.dart b/lib/widgets/filter_grids/states_page.dart index b905bf794..3fe194840 100644 --- a/lib/widgets/filter_grids/states_page.dart +++ b/lib/widgets/filter_grids/states_page.dart @@ -1,6 +1,6 @@ import 'package:aves/geo/states.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/location/place.dart'; diff --git a/lib/widgets/filter_grids/tags_page.dart b/lib/widgets/filter_grids/tags_page.dart index 70deab605..7012ca3ff 100644 --- a/lib/widgets/filter_grids/tags_page.dart +++ b/lib/widgets/filter_grids/tags_page.dart @@ -1,5 +1,5 @@ import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/tag.dart'; diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index ec6478290..03a75d7ca 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -7,9 +7,9 @@ import 'package:aves/model/app/permissions.dart'; import 'package:aves/model/app_inventory.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/catalog.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/settings/enums/home_page.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -241,7 +241,7 @@ class _HomePageState extends State { await reportService.log('Initialize source to view item in directory $directory'); final source = context.read(); source.canAnalyze = false; - await source.init(scope: {AlbumFilter(directory, null)}); + await source.init(scope: {StoredAlbumFilter(directory, null)}); } } else { await _initViewerEssentials(); @@ -324,7 +324,7 @@ class _HomePageState extends State { collection = CollectionLens( source: source, - filters: {AlbumFilter(album, source.getAlbumDisplayName(context, album))}, + filters: {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))}, listenToSource: false, // if we group bursts, opening a burst sub-entry should: // - identify and select the containing main entry, diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 0639e3739..4d4743f49 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -5,7 +5,7 @@ import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/location.dart'; import 'package:aves/model/filters/coordinate.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/media/geotiff.dart'; import 'package:aves/model/settings/enums/accessibility_animations.dart'; diff --git a/lib/widgets/navigation/drawer/app_drawer.dart b/lib/widgets/navigation/drawer/app_drawer.dart index 565694312..c05b8d09f 100644 --- a/lib/widgets/navigation/drawer/app_drawer.dart +++ b/lib/widgets/navigation/drawer/app_drawer.dart @@ -1,4 +1,5 @@ -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/dynamic_album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; @@ -46,14 +47,29 @@ class AppDrawer extends StatefulWidget { @override State createState() => _AppDrawerState(); - static List getDefaultAlbums(BuildContext context) { + static List _getDefaultAlbums(BuildContext context) { final source = context.read(); final specialAlbums = source.rawAlbums.where((album) { final type = androidFileUtils.getAlbumType(album); return [AlbumType.camera, AlbumType.download, AlbumType.screenshots].contains(type); }).toList() ..sort(source.compareAlbumsByName); - return specialAlbums; + return specialAlbums.map((v) => StoredAlbumFilter(v, source.getStoredAlbumDisplayName(context, v))).toList(); + } + + static List? _getCustomAlbums(BuildContext context) { + final source = context.read(); + return settings.drawerAlbumBookmarks?.map((v) { + if (v is StoredAlbumFilter) { + final album = v.album; + return StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album)); + } + return v; + }).toList(); + } + + static List effectiveAlbumBookmarks(BuildContext context) { + return _getCustomAlbums(context) ?? _getDefaultAlbums(context); } } @@ -288,17 +304,22 @@ class _AppDrawerState extends State with WidgetsBindingObserver { return StreamBuilder( stream: source.eventBus.on(), builder: (context, snapshot) { - final albums = settings.drawerAlbumBookmarks ?? AppDrawer.getDefaultAlbums(context); + final albums = AppDrawer.effectiveAlbumBookmarks(context); if (albums.isEmpty) return const SizedBox(); return Column( children: [ const Divider(), - ...albums.map((album) => AlbumNavTile( - album: album, + ...albums.map((filter) => AlbumNavTile( + filter: filter, isSelected: () { if (currentFilters == null || currentFilters.length > 1) return false; final currentFilter = currentFilters.firstOrNull; - return currentFilter is AlbumFilter && currentFilter.album == album; + if (currentFilter is StoredAlbumFilter && filter is StoredAlbumFilter) { + return currentFilter.album == filter.album; + } else if (currentFilter is DynamicAlbumFilter && filter is DynamicAlbumFilter) { + return currentFilter.name == filter.name; + } + return false; }, )), ], diff --git a/lib/widgets/navigation/drawer/collection_nav_tile.dart b/lib/widgets/navigation/drawer/collection_nav_tile.dart index f29f6b2c4..88a1b5b5a 100644 --- a/lib/widgets/navigation/drawer/collection_nav_tile.dart +++ b/lib/widgets/navigation/drawer/collection_nav_tile.dart @@ -1,8 +1,7 @@ -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/navigation/drawer/tile.dart'; @@ -69,23 +68,21 @@ class CollectionNavTile extends StatelessWidget { } class AlbumNavTile extends StatelessWidget { - final String album; + final AlbumBaseFilter filter; final bool Function() isSelected; const AlbumNavTile({ super.key, - required this.album, + required this.filter, required this.isSelected, }); @override Widget build(BuildContext context) { - final source = context.read(); - final filter = AlbumFilter(album, source.getAlbumDisplayName(context, album)); return CollectionNavTile( leading: DrawerFilterIcon(filter: filter), title: DrawerFilterTitle(filter: filter), - trailing: androidFileUtils.isOnRemovableStorage(album) + trailing: filter.storageVolume?.isRemovable ?? false ? const Icon( AIcons.storageCard, size: 16, diff --git a/lib/widgets/navigation/tv_rail.dart b/lib/widgets/navigation/tv_rail.dart index a55c2cb84..4cc3ddfea 100644 --- a/lib/widgets/navigation/tv_rail.dart +++ b/lib/widgets/navigation/tv_rail.dart @@ -1,6 +1,7 @@ import 'dart:math'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/dynamic_album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/enums/home_page.dart'; import 'package:aves/model/settings/settings.dart'; @@ -218,15 +219,18 @@ class _TvRailState extends State { } List<_NavEntry> _buildAlbumLinks(BuildContext context) { - final source = context.read(); final currentFilters = currentCollection?.filters; - final albums = settings.drawerAlbumBookmarks ?? AppDrawer.getDefaultAlbums(context); - return albums.map((album) { - final filter = AlbumFilter(album, source.getAlbumDisplayName(context, album)); + final albums = AppDrawer.effectiveAlbumBookmarks(context); + return albums.map((filter) { bool isSelected() { if (currentFilters == null || currentFilters.length > 1) return false; final currentFilter = currentFilters.firstOrNull; - return currentFilter is AlbumFilter && currentFilter.album == album; + if (currentFilter is StoredAlbumFilter && filter is StoredAlbumFilter) { + return currentFilter.album == filter.album; + } else if (currentFilter is DynamicAlbumFilter && filter is DynamicAlbumFilter) { + return currentFilter.name == filter.name; + } + return false; } return _NavEntry( diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 3c92ebf23..d648f4b57 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -1,15 +1,16 @@ -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/dynamic_albums.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/aspect_ratio.dart'; import 'package:aves/model/filters/date.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/missing.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/recent.dart'; -import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; @@ -192,23 +193,27 @@ class CollectionSearchDelegate extends AvesSearchDelegate with FeedbackMixin, Va } Widget _buildAlbumFilters(_ContainQuery containQuery) { - return StreamBuilder( - stream: source.eventBus.on(), - builder: (context, snapshot) { - final filters = source.rawAlbums - .map((album) => AlbumFilter( - album, - source.getAlbumDisplayName(context, album), - )) - .where((filter) => containQuery(filter.displayName ?? filter.album)) - .toList() - ..sort(); - return _buildFilterRow( - context: context, - title: context.l10n.searchAlbumsSectionTitle, - filters: filters, - ); - }, + return AnimatedBuilder( + animation: dynamicAlbums, + builder: (context, child) => StreamBuilder( + stream: source.eventBus.on(), + builder: (context, snapshot) { + final filters = [ + ...source.rawAlbums + .map((album) => StoredAlbumFilter( + album, + source.getStoredAlbumDisplayName(context, album), + )) + .where((filter) => containQuery(filter.displayName ?? filter.album)), + ...dynamicAlbums.all, + ]..sort(); + return _buildFilterRow( + context: context, + title: context.l10n.searchAlbumsSectionTitle, + filters: filters, + ); + }, + ), ); } diff --git a/lib/widgets/settings/app_export/items.dart b/lib/widgets/settings/app_export/items.dart index ac5f20eb3..e8c7c786c 100644 --- a/lib/widgets/settings/app_export/items.dart +++ b/lib/widgets/settings/app_export/items.dart @@ -1,17 +1,19 @@ import 'package:aves/model/covers.dart'; +import 'package:aves/model/dynamic_albums.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/widgets.dart'; -enum AppExportItem { covers, favourites, settings } +enum AppExportItem { covers, dynamicAlbums, favourites, settings } extension ExtraAppExportItem on AppExportItem { String getText(BuildContext context) { final l10n = context.l10n; return switch (this) { AppExportItem.covers => l10n.appExportCovers, + AppExportItem.dynamicAlbums => l10n.appExportDynamicAlbums, AppExportItem.favourites => l10n.appExportFavourites, AppExportItem.settings => l10n.appExportSettings, }; @@ -20,6 +22,7 @@ extension ExtraAppExportItem on AppExportItem { dynamic export(CollectionSource source) { return switch (this) { AppExportItem.covers => covers.export(source), + AppExportItem.dynamicAlbums => dynamicAlbums.export(), AppExportItem.favourites => favourites.export(source), AppExportItem.settings => settings.export(), }; @@ -29,6 +32,8 @@ extension ExtraAppExportItem on AppExportItem { switch (this) { case AppExportItem.covers: covers.import(jsonMap, source); + case AppExportItem.dynamicAlbums: + dynamicAlbums.import(jsonMap); case AppExportItem.favourites: favourites.import(jsonMap, source); case AppExportItem.settings: diff --git a/lib/widgets/settings/navigation/drawer.dart b/lib/widgets/settings/navigation/drawer.dart index 718374269..417ff86fa 100644 --- a/lib/widgets/settings/navigation/drawer.dart +++ b/lib/widgets/settings/navigation/drawer.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/recent.dart'; import 'package:aves/model/settings/settings.dart'; @@ -28,7 +29,7 @@ class NavigationDrawerEditorPage extends StatefulWidget { class _NavigationDrawerEditorPageState extends State { final List _typeItems = []; final Set _visibleTypes = {}; - final List _albumItems = []; + final List _albumItems = []; final List _pageItems = []; final Set _visiblePages = {}; @@ -54,7 +55,7 @@ class _NavigationDrawerEditorPageState extends State _typeItems.addAll(userTypeLinks); _typeItems.addAll(_typeOptions.where((v) => !userTypeLinks.contains(v))); - _albumItems.addAll(settings.drawerAlbumBookmarks ?? AppDrawer.getDefaultAlbums(context)); + _albumItems.addAll(AppDrawer.effectiveAlbumBookmarks(context)); final userPageLinks = settings.drawerPageBookmarks; _visiblePages.addAll(userPageLinks); diff --git a/lib/widgets/settings/navigation/drawer_tab_albums.dart b/lib/widgets/settings/navigation/drawer_tab_albums.dart index 5ee754397..d3d088a1b 100644 --- a/lib/widgets/settings/navigation/drawer_tab_albums.dart +++ b/lib/widgets/settings/navigation/drawer_tab_albums.dart @@ -1,6 +1,5 @@ -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/model/source/collection_source.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/buttons/outlined_button.dart'; @@ -8,10 +7,9 @@ import 'package:aves/widgets/dialogs/pick_dialogs/album_pick_page.dart'; import 'package:aves/widgets/navigation/drawer/tile.dart'; import 'package:aves/widgets/settings/navigation/drawer_editor_banner.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; class DrawerAlbumTab extends StatefulWidget { - final List items; + final List items; const DrawerAlbumTab({ super.key, @@ -23,11 +21,10 @@ class DrawerAlbumTab extends StatefulWidget { } class _DrawerAlbumTabState extends State { - List get items => widget.items; + List get items => widget.items; @override Widget build(BuildContext context) { - final source = context.read(); return Column( children: [ if (!settings.useTvLayout) ...[ @@ -37,11 +34,10 @@ class _DrawerAlbumTabState extends State { Flexible( child: ReorderableListView.builder( itemBuilder: (context, index) { - final album = items[index]; - final filter = AlbumFilter(album, source.getAlbumDisplayName(context, album)); - void onPressed() => setState(() => items.remove(album)); + final filter = items[index]; + void onPressed() => setState(() => items.remove(filter)); return ListTile( - key: ValueKey(album), + key: ValueKey(filter.key), leading: DrawerFilterIcon(filter: filter), title: DrawerFilterTitle(filter: filter), trailing: IconButton( @@ -68,9 +64,9 @@ class _DrawerAlbumTabState extends State { icon: const Icon(AIcons.add), label: context.l10n.settingsNavigationDrawerAddAlbum, onPressed: () async { - final album = await pickAlbum(context: context, moveType: null); - if (album == null || items.contains(album)) return; - setState(() => items.add(album)); + final albumFilter = await pickAlbum(context: context, moveType: null, storedAlbumsOnly: false); + if (albumFilter == null || items.contains(albumFilter)) return; + setState(() => items.add(albumFilter)); }, ), ], diff --git a/lib/widgets/settings/privacy/file_picker/file_picker_page.dart b/lib/widgets/settings/privacy/file_picker/file_picker_page.dart deleted file mode 100644 index 59a7b56cc..000000000 --- a/lib/widgets/settings/privacy/file_picker/file_picker_page.dart +++ /dev/null @@ -1,224 +0,0 @@ -import 'dart:io'; - -import 'package:aves/model/settings/enums/accessibility_animations.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/services/common/services.dart'; -import 'package:aves/theme/durations.dart'; -import 'package:aves/theme/icons.dart'; -import 'package:aves/utils/android_file_utils.dart'; -import 'package:aves/view/view.dart'; -import 'package:aves/widgets/common/basic/font_size_icon_theme.dart'; -import 'package:aves/widgets/common/basic/popup/menu_row.dart'; -import 'package:aves/widgets/common/basic/scaffold.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/identity/buttons/outlined_button.dart'; -import 'package:aves/widgets/common/identity/empty.dart'; -import 'package:aves/widgets/settings/privacy/file_picker/crumb_line.dart'; -import 'package:aves_model/aves_model.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:provider/provider.dart'; - -class FilePickerPage extends StatefulWidget { - static const routeName = '/file_picker'; - - const FilePickerPage({super.key}); - - @override - State createState() => _FilePickerPageState(); -} - -class _FilePickerPageState extends State { - late VolumeRelativeDirectory _directory; - List? _contents; - - Set get volumes => androidFileUtils.storageVolumes; - - String get currentDirectoryPath => pContext.join(_directory.volumePath, _directory.relativeDir); - - @override - void initState() { - super.initState(); - final primaryVolume = volumes.firstWhereOrNull((v) => v.isPrimary); - if (primaryVolume != null) { - _goTo(primaryVolume.path); - } - } - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final showHidden = settings.filePickerShowHiddenFiles; - final visibleContents = _contents?.where((v) { - if (showHidden) { - return true; - } else { - final isHidden = pContext.split(v.path).last.startsWith('.'); - return !isHidden; - } - }).toList(); - final animations = context.select((v) => v.accessibilityAnimations); - return PopScope( - canPop: _directory.relativeDir.isEmpty, - onPopInvokedWithResult: (didPop, result) { - if (didPop) return; - - final parent = pContext.dirname(currentDirectoryPath); - _goTo(parent); - setState(() {}); - }, - child: AvesScaffold( - appBar: AppBar( - title: Text(_getTitle(context)), - actions: [ - FontSizeIconTheme( - child: PopupMenuButton<_PickerAction>( - itemBuilder: (context) { - return [ - PopupMenuItem( - value: _PickerAction.toggleHiddenView, - child: MenuRow(text: showHidden ? l10n.filePickerDoNotShowHiddenFiles : l10n.filePickerShowHiddenFiles), - ), - ]; - }, - onSelected: (action) async { - // wait for the popup menu to hide before proceeding with the action - await Future.delayed(animations.popUpAnimationDelay * timeDilation); - switch (action) { - case _PickerAction.toggleHiddenView: - settings.filePickerShowHiddenFiles = !showHidden; - setState(() {}); - } - }, - popUpAnimationStyle: animations.popUpAnimationStyle, - ), - ), - ], - ), - drawer: _buildDrawer(context), - body: SafeArea( - child: Column( - children: [ - _buildCrumbLine(context), - const Divider(height: 0), - Expanded( - child: visibleContents == null - ? const SizedBox() - : visibleContents.isEmpty - ? Center( - child: EmptyContent( - icon: AIcons.folder, - text: l10n.filePickerNoItems, - ), - ) - : ListView.builder( - itemCount: visibleContents.length, - itemBuilder: (context, index) { - return index < visibleContents.length ? _buildContentLine(context, visibleContents[index]) : const SizedBox(); - }, - ), - ), - const Divider(height: 0), - Padding( - padding: const EdgeInsets.all(8), - child: AvesOutlinedButton( - label: l10n.filePickerUseThisFolder, - onPressed: () => Navigator.maybeOf(context)?.pop(currentDirectoryPath), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildCrumbLine(BuildContext context) { - final crumbStyle = Theme.of(context).textTheme.bodyMedium!; - return SizedBox( - height: kMinInteractiveDimension, - child: DefaultTextStyle( - style: crumbStyle.copyWith( - color: crumbStyle.color!.withAlpha((255.0 * .4).round()), - fontWeight: FontWeight.w500, - ), - child: CrumbLine( - directory: _directory, - onTap: (path) { - _goTo(path); - setState(() {}); - }, - ), - ), - ); - } - - String _getTitle(BuildContext context) { - if (_directory.relativeDir.isEmpty) { - return _directory.getVolumeDescription(context); - } - return pContext.split(_directory.relativeDir).last; - } - - Widget _buildDrawer(BuildContext context) { - return Drawer( - child: ListView( - children: [ - SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - context.l10n.filePickerOpenFrom, - style: Theme.of(context).textTheme.headlineSmall, - ), - ), - ), - ...volumes.map((v) { - final icon = v.isRemovable ? AIcons.storageCard : AIcons.storageMain; - return ListTile( - leading: Icon(icon), - title: Text(v.getDescription(context)), - onTap: () async { - Navigator.maybeOf(context)?.pop(); - await Future.delayed(ADurations.drawerTransitionLoose); - _goTo(v.path); - setState(() {}); - }, - selected: _directory.volumePath == v.path, - ); - }) - ], - ), - ); - } - - Widget _buildContentLine(BuildContext context, FileSystemEntity content) { - return ListTile( - leading: const Icon(AIcons.folder), - title: Text('${Unicode.FSI}${pContext.split(content.path).last}${Unicode.PDI}'), - onTap: () { - _goTo(content.path); - setState(() {}); - }, - ); - } - - void _goTo(String path) { - _directory = androidFileUtils.relativeDirectoryFromPath(path)!; - _contents = null; - final contents = []; - Directory(currentDirectoryPath).list().listen((event) { - final entity = event.absolute; - if (entity is Directory) { - contents.add(entity); - } - }, onDone: () { - _contents = contents..sort((a, b) => compareAsciiUpperCaseNatural(pContext.split(a.path).last, pContext.split(b.path).last)); - setState(() {}); - }); - } -} - -enum _PickerAction { toggleHiddenView } diff --git a/lib/widgets/settings/settings_mobile_page.dart b/lib/widgets/settings/settings_mobile_page.dart index 664e1cfc4..aa7f17fa7 100644 --- a/lib/widgets/settings/settings_mobile_page.dart +++ b/lib/widgets/settings/settings_mobile_page.dart @@ -170,8 +170,8 @@ class _SettingsMobilePageState extends State with FeedbackMi return item.import(importable[item], source); }); showFeedback(context, FeedbackType.info, context.l10n.genericSuccessFeedback); - } catch (error) { - debugPrint('failed to import app json, error=$error'); + } catch (error, stack) { + debugPrint('failed to import app json, error=$error\n$stack'); showFeedback(context, FeedbackType.warn, context.l10n.genericFailureFeedback); } } diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index 843460915..8100e3ce1 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'package:aves/model/entry/entry.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/filters/rating.dart'; -import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; @@ -204,7 +204,7 @@ class _StatsPageState extends State with FeedbackMixin, VaultAwareMix ..._buildFilterSection(context, l10n.statsTopStatesSectionTitle, _entryCountPerState, (v) => LocationFilter(LocationLevel.state, v)), ..._buildFilterSection(context, l10n.statsTopPlacesSectionTitle, _entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)), ..._buildFilterSection(context, l10n.statsTopTagsSectionTitle, _entryCountPerTag, TagFilter.new), - ..._buildFilterSection(context, l10n.statsTopAlbumsSectionTitle, _entryCountPerAlbum, (v) => AlbumFilter(v, source.getAlbumDisplayName(context, v))), + ..._buildFilterSection(context, l10n.statsTopAlbumsSectionTitle, _entryCountPerAlbum, (v) => StoredAlbumFilter(v, source.getStoredAlbumDisplayName(context, v))), if (showRatings) ..._buildFilterSection(context, l10n.searchRatingSectionTitle, _entryCountPerRating, RatingFilter.new, sortByCount: false, maxRowCount: null), ], ), diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index 02146ba4c..5d011c49e 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -224,15 +224,16 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi } Future _convertMotionPhotoToStillImage(BuildContext context, AvesEntry targetEntry) async { + final l10n = context.l10n; final confirmed = await showDialog( context: context, builder: (context) => AvesDialog( - content: Text(context.l10n.genericDangerWarningDialogMessage), + content: Text(l10n.genericDangerWarningDialogMessage), actions: [ const CancelButton(), TextButton( onPressed: () => Navigator.maybeOf(context)?.pop(true), - child: Text(context.l10n.applyButtonLabel), + child: Text(l10n.applyButtonLabel), ), ], ), diff --git a/lib/widgets/viewer/action/video_action_delegate.dart b/lib/widgets/viewer/action/video_action_delegate.dart index 78375aad4..52662e9ff 100644 --- a/lib/widgets/viewer/action/video_action_delegate.dart +++ b/lib/widgets/viewer/action/video_action_delegate.dart @@ -4,7 +4,7 @@ import 'dart:math'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/location.dart'; import 'package:aves/model/entry/extensions/props.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; @@ -140,7 +140,7 @@ class VideoActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) => CollectionPage( source: source, - filters: {AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum))}, + filters: {StoredAlbumFilter(destinationAlbum, source.getStoredAlbumDisplayName(context, destinationAlbum))}, highlightTest: (entry) => entry.uri == newUri, ), ), diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index 13390eab7..99e1662b2 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -6,12 +6,12 @@ import 'package:aves/model/entry/extensions/favourites.dart'; import 'package:aves/model/entry/extensions/multipage.dart'; import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/favourites.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/date.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/rating.dart'; -import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -131,7 +131,7 @@ class _BasicSectionState extends State { if (entry.isPureVideo && entry.is360) TypeFilter.sphericalVideo, if (entry.isPureVideo && !entry.is360) MimeFilter.video, if (dateTime != null) DateFilter(DateLevel.ymd, dateTime.date), - if (album != null) AlbumFilter(album, collection?.source.getAlbumDisplayName(context, album)), + if (album != null) StoredAlbumFilter(album, collection?.source.getStoredAlbumDisplayName(context, album)), if (entry.rating != 0) RatingFilter(entry.rating), ...tags.map(TagFilter.new), }; diff --git a/lib/widgets/viewer/info/location_section.dart b/lib/widgets/viewer/info/location_section.dart index c3a75c341..2f13b4ff6 100644 --- a/lib/widgets/viewer/info/location_section.dart +++ b/lib/widgets/viewer/info/location_section.dart @@ -1,7 +1,7 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/location.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/settings/enums/coordinate_format.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; diff --git a/lib/widgets/viewer/slideshow_page.dart b/lib/widgets/viewer/slideshow_page.dart index a67ca879d..67ce7e9ac 100644 --- a/lib/widgets/viewer/slideshow_page.dart +++ b/lib/widgets/viewer/slideshow_page.dart @@ -1,6 +1,6 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/entry/entry.dart'; -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -147,7 +147,7 @@ class _SlideshowPageState extends State { settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) => CollectionPage( source: source, - filters: album != null ? {AlbumFilter(album, source.getAlbumDisplayName(context, album))} : null, + filters: album != null ? {StoredAlbumFilter(album, source.getStoredAlbumDisplayName(context, album))} : null, highlightTest: (entry) => entry.uri == uri, ), ), diff --git a/plugins/aves_model/lib/src/actions/chip_set.dart b/plugins/aves_model/lib/src/actions/chip_set.dart index 491e241e5..ae7a53043 100644 --- a/plugins/aves_model/lib/src/actions/chip_set.dart +++ b/plugins/aves_model/lib/src/actions/chip_set.dart @@ -15,6 +15,7 @@ enum ChipSetAction { stats, // selecting (single/multiple filters) delete, + remove, hide, pin, unpin, @@ -54,6 +55,7 @@ class ChipSetActions { ChipSetAction.pin, ChipSetAction.unpin, ChipSetAction.delete, + ChipSetAction.remove, ChipSetAction.rename, ChipSetAction.showCountryStates, ChipSetAction.hide, diff --git a/plugins/aves_model/lib/src/actions/entry_set.dart b/plugins/aves_model/lib/src/actions/entry_set.dart index 171497d6a..90bc1df4c 100644 --- a/plugins/aves_model/lib/src/actions/entry_set.dart +++ b/plugins/aves_model/lib/src/actions/entry_set.dart @@ -7,6 +7,7 @@ enum EntrySetAction { // browsing searchCollection, toggleTitleSearch, + addDynamicAlbum, addShortcut, setHome, emptyBin, @@ -47,6 +48,7 @@ class EntrySetActions { static const pageBrowsing = [ EntrySetAction.searchCollection, EntrySetAction.toggleTitleSearch, + EntrySetAction.addDynamicAlbum, EntrySetAction.addShortcut, EntrySetAction.setHome, null, diff --git a/test/fake/db.dart b/test/fake/db.dart index b5503674a..429481c87 100644 --- a/test/fake/db.dart +++ b/test/fake/db.dart @@ -1,5 +1,6 @@ import 'package:aves/model/covers.dart'; import 'package:aves/model/db/db.dart'; +import 'package:aves/model/dynamic_albums.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/filters.dart'; @@ -113,6 +114,9 @@ class FakeAvesDb extends Fake implements LocalMediaDb { @override Future removeCovers(Set filters) => SynchronousFuture(null); + @override + Future> loadAllDynamicAlbums() => SynchronousFuture({}); + // video playback @override diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index e799a9cae..9e922806f 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -6,8 +6,8 @@ import 'package:aves/model/covers.dart'; import 'package:aves/model/db/db.dart'; import 'package:aves/model/entry/extensions/favourites.dart'; import 'package:aves/model/favourites.dart'; -import 'package:aves/model/filters/album.dart'; -import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/settings/settings.dart'; @@ -117,7 +117,7 @@ void main() { }); test('album/country/tag hidden on launch when their items are hidden by entry prop', () async { - settings.hiddenFilters = {AlbumFilter(testAlbum, 'whatever')}; + settings.hiddenFilters = {StoredAlbumFilter(testAlbum, 'whatever')}; final image1 = FakeMediaStoreService.newImage(testAlbum, 'image1'); (mediaStoreService as FakeMediaStoreService).entries = { @@ -195,7 +195,7 @@ void main() { expect(source.rawAlbums.length, 1); expect(covers.count, 0); - final albumFilter = AlbumFilter(testAlbum, 'whatever'); + final albumFilter = StoredAlbumFilter(testAlbum, 'whatever'); expect(albumFilter.test(image1), true); expect(covers.count, 0); expect(covers.of(albumFilter), null); @@ -217,7 +217,7 @@ void main() { final source = await _initSource(); await image1.toggleFavourite(); - final albumFilter = AlbumFilter(testAlbum, 'whatever'); + final albumFilter = StoredAlbumFilter(testAlbum, 'whatever'); await covers.set(filter: albumFilter, entryId: image1.id, packageName: null, color: null); await source.updateAfterRename( todoEntries: {image1}, @@ -241,7 +241,7 @@ void main() { final source = await _initSource(); await image1.toggleFavourite(); - final albumFilter = AlbumFilter(image1.directory!, 'whatever'); + final albumFilter = StoredAlbumFilter(image1.directory!, 'whatever'); await covers.set(filter: albumFilter, entryId: image1.id, packageName: null, color: null); await source.removeEntries({image1.uri}, includeTrash: true); @@ -261,8 +261,8 @@ void main() { expect(source.rawAlbums.contains(sourceAlbum), true); expect(source.rawAlbums.contains(destinationAlbum), false); - final sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever'); - final destinationAlbumFilter = AlbumFilter(destinationAlbum, 'whatever'); + final sourceAlbumFilter = StoredAlbumFilter(sourceAlbum, 'whatever'); + final destinationAlbumFilter = StoredAlbumFilter(destinationAlbum, 'whatever'); expect(sourceAlbumFilter.test(image1), true); expect(destinationAlbumFilter.test(image1), false); @@ -312,7 +312,7 @@ void main() { final source = await _initSource(); expect(source.rawAlbums.length, 1); - final sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever'); + final sourceAlbumFilter = StoredAlbumFilter(sourceAlbum, 'whatever'); await covers.set(filter: sourceAlbumFilter, entryId: image1.id, packageName: null, color: null); await source.updateAfterMove( @@ -337,14 +337,14 @@ void main() { final source = await _initSource(); await image1.toggleFavourite(); - var albumFilter = AlbumFilter(sourceAlbum, 'whatever'); + var albumFilter = StoredAlbumFilter(sourceAlbum, 'whatever'); await covers.set(filter: albumFilter, entryId: image1.id, packageName: null, color: null); - await source.renameAlbum(sourceAlbum, destinationAlbum, { + await source.renameStoredAlbum(sourceAlbum, destinationAlbum, { image1 }, { FakeMediaStoreService.moveOpEventForMove(image1, sourceAlbum, destinationAlbum), }); - albumFilter = AlbumFilter(destinationAlbum, 'whatever'); + albumFilter = StoredAlbumFilter(destinationAlbum, 'whatever'); expect(favourites.count, 1); expect(image1.isFavourite, true); @@ -377,20 +377,20 @@ void main() { delegates: AppLocalizations.localizationsDelegates, child: Builder( builder: (context) { - expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Elea/Zeno'), 'Elea/Zeno'); - expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Citium/Zeno'), 'Citium/Zeno'); - expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Cleanthes'), 'Cleanthes'); - expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Chrysippus'), 'Chrysippus'); - expect(source.getAlbumDisplayName(context, '${FakeStorageService.removablePath}Pictures/Chrysippus'), 'Chrysippus (${FakeStorageService.removableDescription})'); - expect(source.getAlbumDisplayName(context, FakeStorageService.primaryRootAlbum), FakeStorageService.primaryDescription); - expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Seneca'), 'Pictures/Seneca'); - expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Seneca'), 'Seneca'); - expect(source.getAlbumDisplayName(context, '${FakeStorageService.removablePath}Pictures/Cicero'), 'Cicero'); - expect(source.getAlbumDisplayName(context, '${FakeStorageService.removablePath}Marcus Aurelius'), 'Marcus Aurelius'); - expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Hannah Arendt'), 'Hannah Arendt'); - expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Arendt'), 'Arendt'); - expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Something'), 'Pictures/Something'); - expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Movies/SomeThing'), 'Movies/SomeThing'); + expect(source.getStoredAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Elea/Zeno'), 'Elea/Zeno'); + expect(source.getStoredAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Citium/Zeno'), 'Citium/Zeno'); + expect(source.getStoredAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Cleanthes'), 'Cleanthes'); + expect(source.getStoredAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Chrysippus'), 'Chrysippus'); + expect(source.getStoredAlbumDisplayName(context, '${FakeStorageService.removablePath}Pictures/Chrysippus'), 'Chrysippus (${FakeStorageService.removableDescription})'); + expect(source.getStoredAlbumDisplayName(context, FakeStorageService.primaryRootAlbum), FakeStorageService.primaryDescription); + expect(source.getStoredAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Seneca'), 'Pictures/Seneca'); + expect(source.getStoredAlbumDisplayName(context, '${FakeStorageService.primaryPath}Seneca'), 'Seneca'); + expect(source.getStoredAlbumDisplayName(context, '${FakeStorageService.removablePath}Pictures/Cicero'), 'Cicero'); + expect(source.getStoredAlbumDisplayName(context, '${FakeStorageService.removablePath}Marcus Aurelius'), 'Marcus Aurelius'); + expect(source.getStoredAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Hannah Arendt'), 'Hannah Arendt'); + expect(source.getStoredAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Arendt'), 'Arendt'); + expect(source.getStoredAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Something'), 'Pictures/Something'); + expect(source.getStoredAlbumDisplayName(context, '${FakeStorageService.primaryPath}Movies/SomeThing'), 'Movies/SomeThing'); return const Placeholder(); }, ), diff --git a/test/model/filters_test.dart b/test/model/filters_test.dart index db4cca9a5..39e35ed96 100644 --- a/test/model/filters_test.dart +++ b/test/model/filters_test.dart @@ -1,10 +1,10 @@ -import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/aspect_ratio.dart'; import 'package:aves/model/filters/coordinate.dart'; import 'package:aves/model/filters/date.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/missing.dart'; import 'package:aves/model/filters/path.dart'; @@ -12,7 +12,7 @@ import 'package:aves/model/filters/placeholder.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/recent.dart'; -import 'package:aves/model/filters/tag.dart'; +import 'package:aves/model/filters/covered/tag.dart'; import 'package:aves/model/filters/type.dart'; import 'package:aves/services/common/services.dart'; import 'package:latlong2/latlong.dart'; @@ -35,7 +35,7 @@ void main() { test('Filter serialization', () { CollectionFilter? jsonRoundTrip(filter) => CollectionFilter.fromJson(filter.toJson()); - final album = AlbumFilter('path/to/album', 'album'); + final album = StoredAlbumFilter('path/to/album', 'album'); expect(album, jsonRoundTrip(album)); final aspectRatio = AspectRatioFilter.landscape;