#1107 dynamic albums

This commit is contained in:
Thibault Deckers 2024-12-03 00:25:12 +01:00
parent 76f0764d27
commit 303425e699
97 changed files with 1439 additions and 753 deletions

View file

@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
### Added
- Albums: dynamic albums from filter sets
## <a id="v1.11.19"></a>[v1.11.19] - 2024-11-24
### Added

View file

@ -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",

View file

@ -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<Set<CollectionFilter>?> _entryChangeStreamController = StreamController.broadcast();
final StreamController<Set<CollectionFilter>?> _packageChangeStreamController = StreamController.broadcast();
@ -39,22 +41,60 @@ class Covers {
Set<CoverRow> 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<CoverProps?> 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<void> removeAll(Set<CollectionFilter> filters, {bool notify = true}) async {
final entryIdChanged = <CollectionFilter>{};
final packageNameChanged = <CollectionFilter>{};
final colorChanged = <CollectionFilter>{};
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<void> 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<void> _removeEntryFromRows(Set<CoverRow> 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<Map<String, dynamic>>? 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,

View file

@ -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<void> removeCovers(Set<CollectionFilter> filters);
// dynamic albums
Future<void> clearDynamicAlbums();
Future<Set<DynamicAlbumRow>> loadAllDynamicAlbums();
Future<void> addDynamicAlbums(Set<DynamicAlbumRow> rows);
Future<void> removeDynamicAlbums(Set<String> names);
// video playback
Future<void> clearVideoPlayback();

View file

@ -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<void> removeVaults(Set<VaultDetails> 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<void> clearDynamicAlbums() async {
final count = await _db.delete(dynamicAlbumTable, where: '1');
debugPrint('$runtimeType clearDynamicAlbums deleted $count rows');
}
@override
Future<Set<DynamicAlbumRow>> loadAllDynamicAlbums() async {
final result = <DynamicAlbumRow>{};
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<void> addDynamicAlbums(Set<DynamicAlbumRow> 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<void> removeDynamicAlbums(Set<String> 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<void> removeVideoPlayback(Set<int> 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);

View file

@ -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<void> _upgradeFrom11(Database db) async {
debugPrint('upgrading DB from v11');
await db.execute('CREATE TABLE $dynamicAlbumTable('
'name TEXT PRIMARY KEY'
', filter TEXT'
')');
}
}

View file

@ -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<DynamicAlbumFilter> _rows = {};
DynamicAlbums._private() {
if (kFlutterMemoryAllocationsEnabled) ChangeNotifier.maybeDispatchObjectCreation(this);
}
Future<void> init() async {
_rows = (await localMediaDb.loadAllDynamicAlbums()).map((v) => DynamicAlbumFilter(v.name, v.filter)).toSet();
}
int get count => _rows.length;
Set<DynamicAlbumFilter> get all => Set.unmodifiable(_rows);
Future<void> add(DynamicAlbumFilter filter) async {
await localMediaDb.addDynamicAlbums({DynamicAlbumRow(name: filter.name, filter: filter.filter)});
_rows.add(filter);
notifyListeners();
}
Future<void> remove(Set<DynamicAlbumFilter> filters) async {
await localMediaDb.removeDynamicAlbums(filters.map((filter) => filter.name).toSet());
_rows.removeAll(filters);
notifyListeners();
}
Future<void> clear() async {
await localMediaDb.clearDynamicAlbums();
_rows.clear();
notifyListeners();
}
Future<void> 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<String>? 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<Object?> 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<String, dynamic> toMap() => {
'name': name,
'filter': filter.toJson(),
};
}

View file

@ -59,7 +59,7 @@ class Favourites with ChangeNotifier {
Map<String, List<String>>? 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<String, StorageVolume?>(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');

View file

@ -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> color(BuildContext context) {
final customColor = covers.of(this)?.$3;
if (customColor != null) {
return SynchronousFuture(customColor);
}
return super.color(context);
}
}

View file

@ -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<Object?> get props => [name, filter, reversed];
DynamicAlbumFilter(this.name, this.filter, {super.reversed = false});
static DynamicAlbumFilter? fromMap(Map<String, dynamic> json) {
final filter = CollectionFilter.fromJson(json['filter']);
if (filter == null) return null;
return DynamicAlbumFilter(
json['name'],
filter,
reversed: json['reversed'] ?? false,
);
}
@override
Map<String, dynamic> 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;
}

View file

@ -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 = ';';

View file

@ -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<Object?> 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<String, dynamic> json) {
return AlbumFilter(
factory StoredAlbumFilter.fromMap(Map<String, dynamic> 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);
}

View file

@ -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;

View file

@ -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<Collecti
static const List<String> 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<Collecti
AspectRatioFilter.type,
MissingFilter.type,
PathFilter.type,
OrFilter.type,
];
final bool reversed;
@ -54,14 +57,14 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
static CollectionFilter? _fromMap(Map<String, dynamic> 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<Collecti
return MimeFilter.fromMap(jsonMap);
case MissingFilter.type:
return MissingFilter.fromMap(jsonMap);
case OrFilter.type:
return OrFilter.fromMap(jsonMap);
case SetAndFilter.type:
return SetAndFilter.fromMap(jsonMap);
case SetOrFilter.type:
return SetOrFilter.fromMap(jsonMap);
case PathFilter.type:
return PathFilter.fromMap(jsonMap);
case PlaceholderFilter.type:
@ -82,6 +87,8 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
return RatingFilter.fromMap(jsonMap);
case RecentlyAddedFilter.type:
return RecentlyAddedFilter.fromMap(jsonMap);
case StoredAlbumFilter.type:
return StoredAlbumFilter.fromMap(jsonMap);
case TagFilter.type:
return TagFilter.fromMap(jsonMap);
case TypeFilter.type:
@ -155,20 +162,6 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
}
}
@immutable
abstract class CoveredCollectionFilter extends CollectionFilter {
const CoveredCollectionFilter({required super.reversed});
@override
Future<Color> color(BuildContext context) {
final customColor = covers.of(this)?.$3;
if (customColor != null) {
return SynchronousFuture(customColor);
}
return super.color(context);
}
}
@immutable
class FilterGridItem<T extends CollectionFilter> with EquatableMixin {
final T filter;

View file

@ -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<CollectionFilter> _filters;
late final EntryFilter _test;
late final IconData? _genericIcon;
@override
List<Object?> get props => [_filters, reversed];
CollectionFilter get _first => _filters.first;
SetAndFilter(Set<CollectionFilter> 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<String, dynamic> json) {
final filters = (json['filters'] as List).cast<String>().map(CollectionFilter.fromJson).nonNulls.toSet();
if (filters.isEmpty) return null;
return SetAndFilter(
filters,
reversed: json['reversed'] ?? false,
);
}
@override
Map<String, dynamic> 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)}';
}

View file

@ -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<CollectionFilter> _filters;
@ -18,11 +18,11 @@ class OrFilter extends CollectionFilter {
CollectionFilter get _first => _filters.first;
OrFilter(Set<CollectionFilter> filters, {super.reversed = false}) {
SetOrFilter(Set<CollectionFilter> 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<String, dynamic> json) {
return OrFilter(
(json['filters'] as List).cast<String>().map(CollectionFilter.fromJson).nonNulls.toSet(),
static SetOrFilter? fromMap(Map<String, dynamic> json) {
final filters = (json['filters'] as List).cast<String>().map(CollectionFilter.fromJson).nonNulls.toSet();
if (filters.isEmpty) return null;
return SetOrFilter(
filters,
reversed: json['reversed'] ?? false,
);
}

View file

@ -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<CollectionFilter?> newValue) => set(SettingKeys.drawerTypeBookmarksKey, newValue.map((filter) => filter?.toJson() ?? '').toList());
List<String>? get drawerAlbumBookmarks => getStringList(SettingKeys.drawerAlbumBookmarksKey);
List<AlbumBaseFilter>? get drawerAlbumBookmarks => getStringList(SettingKeys.drawerAlbumBookmarksKey)?.map(CollectionFilter.fromJson).whereType<AlbumBaseFilter>().toList();
set drawerAlbumBookmarks(List<String>? newValue) => set(SettingKeys.drawerAlbumBookmarksKey, newValue);
set drawerAlbumBookmarks(List<AlbumBaseFilter>? newValue) => set(SettingKeys.drawerAlbumBookmarksKey, newValue?.map((filter) => filter.toJson()).toList());
List<String> get drawerPageBookmarks => getStringList(SettingKeys.drawerPageBookmarksKey) ?? SettingsDefaults.drawerPageBookmarks;

View file

@ -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<String> get rawAlbums => List.unmodifiable(_directories);
Set<AlbumFilter> getNewAlbumFilters(BuildContext context) => Set.unmodifiable(_newAlbums.map((v) => AlbumFilter(v, getAlbumDisplayName(context, v))));
Set<StoredAlbumFilter> 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<AlbumFilter>().map((v) => v.album).contains(album)) return false;
if (settings.pinnedFilters.whereType<StoredAlbumFilter>().map((v) => v.album).contains(album)) return false;
return true;
}
// filter summary
// by directory
// by filter key
final Map<String, int> _filterEntryCountMap = {}, _filterSizeMap = {};
final Map<String, AvesEntry?> _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<String, String> _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<String?>? directories;
const AlbumSummaryInvalidatedEvent(this.directories);
const StoredAlbumSummaryInvalidatedEvent(this.directories);
}

View file

@ -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:

View file

@ -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<String>?) {
@ -144,7 +144,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
Set<CollectionFilter> _getAppHiddenFilters() => {
...settings.hiddenFilters,
...vaults.vaultDirectories.where(vaults.isLocked).map((v) => AlbumFilter(v, null)),
...vaults.vaultDirectories.where(vaults.isLocked).map((v) => StoredAlbumFilter(v, null)),
};
Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) {
@ -288,11 +288,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
}
}
Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> entries, Set<MoveOpEvent> movedOps) async {
final oldFilter = AlbumFilter(sourceAlbum, null);
final newFilter = AlbumFilter(destinationAlbum, null);
Future<void> renameStoredAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> entries, Set<MoveOpEvent> 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);
}
}

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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<AlbumFilter>().map((v) => v.album).toSet());
addDirectories(albums: settings.pinnedFilters.whereType<StoredAlbumFilter>().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<AlbumFilter>();
final scopeAlbumFilters = _targetScope?.whereType<StoredAlbumFilter>();
final scopeDirectory = scopeAlbumFilters != null && scopeAlbumFilters.length == 1 ? scopeAlbumFilters.first.album : null;
final Set<AvesEntry> topEntries = {};

View file

@ -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';

View file

@ -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;

View file

@ -1,5 +1,3 @@
extension ExtraList<E> on List<E> {
bool replace(E old, E newItem) {
final index = indexOf(old);
@ -10,6 +8,15 @@ extension ExtraList<E> on List<E> {
}
}
extension ExtraSet<E> on Set<E> {
bool replace(E old, E newItem) {
if (!remove(old)) return false;
add(newItem);
return true;
}
}
extension ExtraMapNullableKey<K extends Object, V> on Map<K?, V> {
Map<K, V> whereNotNullKey() => <K, V>{for (var v in keys.nonNulls) v: this[v] as V};
}

View file

@ -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,

View file

@ -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,

View file

@ -322,7 +322,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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<CollectionAppBar> 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<CollectionAppBar> with SingleTickerPr
// browsing
case EntrySetAction.searchCollection:
case EntrySetAction.toggleTitleSearch:
case EntrySetAction.addDynamicAlbum:
case EntrySetAction.addShortcut:
case EntrySetAction.setHome:
// browsing or selecting

View file

@ -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;
}
});

View file

@ -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<CollectionSource>().getAlbumDisplayName(context, entry.directory!);
String _getAlbumName(BuildContext context, AvesEntry entry) => context.read<CollectionSource>().getStoredAlbumDisplayName(context, entry.directory!);
}

View file

@ -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<Query>().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<void> _addShortcut(BuildContext context) async {
final collection = context.read<CollectionLens>();
final filters = collection.filters;
static String? _getDefaultNameForFilters(BuildContext context, Set<CollectionFilter> 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<CollectionFilter>.from(filters)..sort();
defaultName = sortedFilters.first.getLabel(context).replaceAll('\n', ' ');
}
return defaultName;
}
Future<void> _addDynamicAlbum(BuildContext context) async {
final l10n = context.l10n;
final collection = context.read<CollectionLens>();
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<String>(
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<void> _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<HighlightInfo>();
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<void> _addShortcut(BuildContext context) async {
final collection = context.read<CollectionLens>();
final filters = collection.filters;
String? defaultName = _getDefaultNameForFilters(context, filters);
final result = await showDialog<(AvesEntry?, String)>(
context: context,
builder: (context) => AddShortcutDialog(

View file

@ -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),
);

View file

@ -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,
);
}

View file

@ -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<Str
@override
CollectionFilter buildFilter(BuildContext context, String option) {
final source = context.read<CollectionSource>();
return AlbumFilter(option, source.getAlbumDisplayName(context, option));
return StoredAlbumFilter(option, source.getStoredAlbumDisplayName(context, option));
}
}

View file

@ -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<MoveButton, String> {
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));

View file

@ -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';

View file

@ -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';

View file

@ -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<void> doExport(BuildContext context, Set<AvesEntry> 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<CollectionLens?>();
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<CollectionSource>();
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(

View file

@ -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<bool> 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<bool> unlockFilters(BuildContext context, Set<AlbumFilter> filters) async {
Future<bool> unlockFilters(BuildContext context, Set<StoredAlbumFilter> 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<AlbumFilter> filters) => vaults.lock(filters.map((v) => v.album).toSet());
void lockFilters(Set<StoredAlbumFilter> filters) => vaults.lock(filters.map((v) => v.album).toSet());
Future<bool> setVaultPass(BuildContext context, VaultDetails details) async {
switch (details.lockType) {

View file

@ -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,

View file

@ -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<void> showDefaultLongPressMenu(BuildContext context, CollectionFilter filter, Offset tapPosition) async {
if (context.read<ValueNotifier<AppMode>>().value.canNavigate) {
final actions = <ChipAction>[
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,

View file

@ -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';

View file

@ -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<DebugAppDatabaseSection> with
late Future<Set<VaultDetails>> _dbVaultsLoader;
late Future<Set<FavouriteRow>> _dbFavouritesLoader;
late Future<Set<CoverRow>> _dbCoversLoader;
late Future<Set<DynamicAlbumRow>> _dbDynamicAlbumsLoader;
late Future<Set<VideoPlaybackRow>> _dbVideoPlaybackLoader;
@override
@ -247,6 +249,27 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
);
},
),
FutureBuilder<Set>(
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<Set>(
future: _dbVideoPlaybackLoader,
builder: (context, snapshot) {
@ -290,6 +313,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
_dbVaultsLoader = localMediaDb.loadAllVaults();
_dbFavouritesLoader = localMediaDb.loadAllFavourites();
_dbCoversLoader = localMediaDb.loadAllCovers();
_dbDynamicAlbumsLoader = localMediaDb.loadAllDynamicAlbums();
_dbVideoPlaybackLoader = localMediaDb.loadAllVideoPlayback();
setState(() {});
}

View file

@ -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';

View file

@ -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';

View file

@ -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<AddDynamicAlbumDialog> createState() => _AddDynamicAlbumDialogState();
}
class _AddDynamicAlbumDialogState extends State<AddDynamicAlbumDialog> {
final TextEditingController _nameController = TextEditingController();
final ValueNotifier<bool> _existsNotifier = ValueNotifier(false);
final ValueNotifier<bool> _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<bool>(
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<bool>(
valueListenable: _existsNotifier,
builder: (context, albumExists, child) {
return ValueListenableBuilder<bool>(
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());
}
}
}

View file

@ -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<CoverSelectionDialog> {
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<CoverSelectionDialog> {
overflow: TextOverflow.fade,
maxLines: 1,
);
return RadioListTile<bool>(
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<bool>(
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();

View file

@ -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<CreateAlbumDialog> createState() => _CreateAlbumDialogState();
State<CreateStoredAlbumDialog> createState() => _CreateStoredAlbumDialogState();
}
class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
class _CreateStoredAlbumDialogState extends State<CreateStoredAlbumDialog> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _nameController = TextEditingController();
final FocusNode _nameFieldFocusNode = FocusNode();

View file

@ -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<EditVaultDialog> 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<CollectionSource>();
if (source.trashedEntries.any(filter.test)) {
if (!await showConfirmationDialog(

View file

@ -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<RenameDynamicAlbumDialog> createState() => _RenameDynamicAlbumDialogState();
}
class _RenameDynamicAlbumDialogState extends State<RenameDynamicAlbumDialog> {
final TextEditingController _nameController = TextEditingController();
final ValueNotifier<bool> _existsNotifier = ValueNotifier(false);
final ValueNotifier<bool> _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<bool>(
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<bool>(
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<void> _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());
}
}
}

View file

@ -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<RenameAlbumDialog> createState() => _RenameAlbumDialogState();
State<RenameStoredAlbumDialog> createState() => _RenameStoredAlbumDialogState();
}
class _RenameAlbumDialogState extends State<RenameAlbumDialog> {
class _RenameStoredAlbumDialogState extends State<RenameStoredAlbumDialog> {
final TextEditingController _nameController = TextEditingController();
final ValueNotifier<bool> _existsNotifier = ValueNotifier(false);
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);

View file

@ -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<String?> pickAlbum({
Future<AlbumBaseFilter?> pickAlbum({
required BuildContext context,
required MoveType? moveType,
required bool storedAlbumsOnly,
}) async {
final source = context.read<CollectionSource>();
if (source.targetScope != CollectionSource.fullScope) {
@ -41,13 +42,12 @@ Future<String?> pickAlbum({
source.canAnalyze = true;
await source.init(scope: CollectionSource.fullScope);
}
final filter = await Navigator.maybeOf(context)?.push(
MaterialPageRoute<AlbumFilter>(
return await Navigator.maybeOf(context)?.push(
MaterialPageRoute<AlbumBaseFilter>(
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<AlbumsChangedEvent>(),
builder: (context, snapshot) {
final gridItems = AlbumListPage.getAlbumGridItems(context, source);
return SelectionProvider<FilterGridItem<AlbumFilter>>(
final gridItems = AlbumListPage.getAlbumGridItems(context, source, storedAlbumsOnly: widget.storedAlbumsOnly);
return SelectionProvider<FilterGridItem<AlbumBaseFilter>>(
child: QueryProvider(
startEnabled: settings.getShowTitleQuery(context.currentRouteName!),
child: FilterGridPage<AlbumFilter>(
child: FilterGridPage<AlbumBaseFilter>(
settingsRouteKey: AlbumListPage.routeName,
appBar: FilterGridAppBar(
source: source,
@ -150,7 +152,7 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
List<Widget> _buildActions(
BuildContext context,
AppMode appMode,
Selection<FilterGridItem<AlbumFilter>> selection,
Selection<FilterGridItem<AlbumBaseFilter>> selection,
AlbumChipSetActionDelegate actionDelegate,
) {
final itemCount = actionDelegate.allItems.length;
@ -245,8 +247,8 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
Future<void> _createAlbum() async {
final directory = await showDialog<String>(
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<AlbumFilter>(filter);
source.createStoredAlbum(directory);
final filter = StoredAlbumFilter(directory, source.getStoredAlbumDisplayName(context, directory));
Navigator.maybeOf(context)?.pop<StoredAlbumFilter>(filter);
}
}

View file

@ -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';

View file

@ -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<ExplorerPage> {
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<ExplorerPage> {
? 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),

View file

@ -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<bool>(
valueListenable: appInventory.areAppNamesReadyNotifier,
builder: (context, areAppNamesReady, child) {
return StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) {
final gridItems = getAlbumGridItems(context, source);
return StreamBuilder<Set<CollectionFilter>?>(
// to update sections by tier
stream: covers.packageChangeStream,
builder: (context, snapshot) => FilterNavigationPage<AlbumFilter, AlbumChipSetActionDelegate>(
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<AlbumsChangedEvent>(),
builder: (context, snapshot) {
final gridItems = getAlbumGridItems(context, source);
return StreamBuilder<Set<CollectionFilter>?>(
// to update sections by tier
stream: covers.packageChangeStream,
builder: (context, snapshot) => FilterNavigationPage<AlbumBaseFilter, AlbumChipSetActionDelegate>(
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<FilterGridItem<AlbumFilter>> applyQuery(BuildContext context, List<FilterGridItem<AlbumFilter>> filters, String query) {
return filters.where((item) => (item.filter.displayName ?? item.filter.album).toUpperCase().contains(query)).toList();
static List<FilterGridItem<AlbumBaseFilter>> applyQuery(BuildContext context, List<FilterGridItem<AlbumBaseFilter>> filters, String query) {
return filters.where((item) => item.filter.match(query)).toList();
}
static List<FilterGridItem<AlbumFilter>> getAlbumGridItems(BuildContext context, CollectionSource source) {
final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getAlbumDisplayName(context, album))).toSet();
static List<FilterGridItem<AlbumBaseFilter>> getAlbumGridItems(BuildContext context, CollectionSource source, {bool storedAlbumsOnly = false}) {
final filters = <AlbumBaseFilter>{
...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<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> groupToSections(BuildContext context, CollectionSource source, Iterable<FilterGridItem<AlbumFilter>> sortedMapEntries) {
static Map<ChipSectionKey, List<FilterGridItem<AlbumBaseFilter>>> groupToSections(BuildContext context, CollectionSource source, Iterable<FilterGridItem<AlbumBaseFilter>> sortedMapEntries) {
final newFilters = source.getNewAlbumFilters(context);
final pinned = settings.pinnedFilters.whereType<AlbumFilter>();
final pinned = settings.pinnedFilters.whereType<AlbumBaseFilter>();
final List<FilterGridItem<AlbumFilter>> newMapEntries = [], pinnedMapEntries = [], unpinnedMapEntries = [];
final List<FilterGridItem<AlbumBaseFilter>> 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 = <ChipSectionKey, List<FilterGridItem<AlbumFilter>>>{};
var sections = <ChipSectionKey, List<FilterGridItem<AlbumBaseFilter>>>{};
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<FilterGridItem<AlbumFilter>, 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<FilterGridItem<AlbumBaseFilter>, 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<FilterGridItem<AlbumFilter>, ChipSectionKey>(unpinnedMapEntries, (kv) {
sections = groupBy<FilterGridItem<AlbumBaseFilter>, 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<FilterGridItem<AlbumFilter>, ChipSectionKey>(unpinnedMapEntries, (kv) {
return StorageVolumeSectionKey(context, androidFileUtils.getStorageVolume(kv.filter.album));
sections = groupBy<FilterGridItem<AlbumBaseFilter>, ChipSectionKey>(unpinnedMapEntries, (kv) {
return StorageVolumeSectionKey(context, kv.filter.storageVolume);
});
case AlbumChipGroupFactor.none:
return {

View file

@ -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<AlbumFilter> with EntryStorageMixin {
final Iterable<FilterGridItem<AlbumFilter>> _items;
class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumBaseFilter> with EntryStorageMixin {
final Iterable<FilterGridItem<AlbumBaseFilter>> _items;
AlbumChipSetActionDelegate(Iterable<FilterGridItem<AlbumFilter>> items) : _items = items;
AlbumChipSetActionDelegate(Iterable<FilterGridItem<AlbumBaseFilter>> items) : _items = items;
@override
Iterable<FilterGridItem<AlbumFilter>> get allItems => _items;
Iterable<FilterGridItem<AlbumBaseFilter>> get allItems => _items;
@override
ChipSortFactor get sortFactor => settings.albumSortFactor;
@ -72,7 +77,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
required AppMode appMode,
required bool isSelecting,
required int itemCount,
required Set<AlbumFilter> selectedFilters,
required Set<AlbumBaseFilter> selectedFilters,
}) {
final selectedSingleItem = selectedFilters.length == 1;
final isMain = appMode == AppMode.main;
@ -82,14 +87,17 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
case ChipSetAction.createVault:
return !settings.isReadOnly && appMode.canCreateFilter && !isSelecting;
case ChipSetAction.delete:
return isMain && isSelecting && !settings.isReadOnly && !(selectedFilters.whereType<StoredAlbumFilter>().isEmpty && selectedFilters.whereType<DynamicAlbumFilter>().isNotEmpty);
case ChipSetAction.remove:
return isMain && isSelecting && !settings.isReadOnly && selectedFilters.whereType<StoredAlbumFilter>().isEmpty && selectedFilters.whereType<DynamicAlbumFilter>().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<AlbumFilter> with
ChipSetAction action, {
required bool isSelecting,
required int itemCount,
required Set<AlbumFilter> selectedFilters,
required Set<AlbumBaseFilter> selectedFilters,
}) {
final selectedItemCount = selectedFilters.length;
final hasSelection = selectedItemCount > 0;
switch (action) {
case ChipSetAction.delete:
return selectedFilters.whereType<StoredAlbumFilter>().isNotEmpty && selectedFilters.whereType<DynamicAlbumFilter>().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<StoredAlbumFilter>().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<AlbumFilter> 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<AlbumFilter> with
super.onActionSelected(context, action);
}
Set<StoredAlbumFilter> _getSelectedStoredAlbumFilters(BuildContext context) {
return getSelectedFilters(context).whereType<StoredAlbumFilter>().toSet();
}
Set<DynamicAlbumFilter> _getSelectedDynamicAlbumFilters(BuildContext context) {
return getSelectedFilters(context).whereType<DynamicAlbumFilter>().toSet();
}
@override
Future<void> configureView(BuildContext context) async {
final initialValue = (
@ -196,7 +209,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> 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<CollectionSource>();
@ -218,7 +231,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
final details = await showDialog<VaultDetails>(
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<AlbumFilter> with
} else {
directory = await showDialog<String>(
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<AlbumFilter> 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<AlbumFilter> with
}
}
Future<void> _showAlbum(NavigatorState? navigator, AlbumFilter filter) async {
Future<void> _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<AlbumFilter> with
}
}
Future<void> _delete(BuildContext context) async {
final filters = getSelectedFilters(context);
final byBinUsage = groupBy<AlbumFilter, bool>(filters, (filter) {
Future<void> _deleteStoredAlbums(BuildContext context) async {
final filters = _getSelectedStoredAlbumFilters(context);
final byBinUsage = groupBy<StoredAlbumFilter, bool>(filters, (filter) {
final details = vaults.getVault(filter.album);
return details?.useBin ?? settings.enableBin;
});
@ -291,7 +304,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
Future<void> _doDelete({
required BuildContext context,
required Set<AlbumFilter> filters,
required Set<StoredAlbumFilter> filters,
required bool enableBin,
}) async {
if (!await unlockFilters(context, filters)) return;
@ -388,44 +401,125 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
);
}
Future<void> _removeDynamicAlbum(BuildContext context) async {
final l10n = context.l10n;
final confirmed = await showDialog<bool>(
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<void> _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<String>(
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<String>(
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<String>(
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<void> _doRename(BuildContext context, AlbumFilter filter, String newName) async {
Future<void> _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<void> _doRenameStoredAlbum(BuildContext context, StoredAlbumFilter albumFilter, String newName) async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(context);
final source = context.read<CollectionSource>();
final album = filter.album;
final todoEntries = source.visibleEntries.where(filter.test).toSet();
final 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<AlbumFilter> 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<AlbumFilter> 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<AlbumFilter> 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<CollectionSource>();
await _deleteEntriesForever(context, source.trashedEntries.where(filter.test).toSet());
}
@ -503,7 +599,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> 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);

View file

@ -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});
}
}

View file

@ -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<T extends CollectionFilter> 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<T extends CollectionFilter> 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<T extends CollectionFilter> 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<T extends CollectionFilter> 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<T extends CollectionFilter> with FeedbackMi
);
if (selectedCover == null) return;
if (filter is AlbumFilter) {
if (filter is StoredAlbumFilter) {
context.read<AvesColorsData>().clearAppColor(filter.album);
}

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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<TagFilter> {
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<TagFilter> {
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<void> _delete(BuildContext context) async {
Future<void> _remove(BuildContext context) async {
final filters = getSelectedFilters(context);
final source = context.read<CollectionSource>();
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<bool>(
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),
),
],
),

View file

@ -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<T extends CollectionFilter> extends StatelessWidget {
builder: (context, snapshot) => Consumer<CollectionSource>(
builder: (context, source, child) {
switch (filter) {
case AlbumFilter filter:
case StoredAlbumFilter filter:
{
final album = filter.album;
return StreamBuilder<AlbumSummaryInvalidatedEvent>(
stream: source.eventBus.on<AlbumSummaryInvalidatedEvent>().where((event) => event.directories == null || event.directories!.contains(album)),
return StreamBuilder<StoredAlbumSummaryInvalidatedEvent>(
stream: source.eventBus.on<StoredAlbumSummaryInvalidatedEvent>().where((event) => event.directories == null || event.directories!.contains(album)),
builder: (context, snapshot) => _buildChip(context, source),
);
}
case DynamicAlbumFilter _:
{
return StreamBuilder<DynamicAlbumSummaryInvalidatedEvent>(
stream: source.eventBus.on<DynamicAlbumSummaryInvalidatedEvent>(),
builder: (context, snapshot) => _buildChip(context, source),
);
}
@ -103,10 +111,10 @@ class CoveredFilterChip<T extends CollectionFilter> 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<double>(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<T extends CollectionFilter> 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<double>(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<double>(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,
),
);
}
}

View file

@ -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,
};
}

View file

@ -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<T extends CollectionFilter> 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) {

View file

@ -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<T extends CollectionFilter> 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<Widget> leadingIcons = [
if (pinned) const Icon(AIcons.pin),
if (removableStorage) const Icon(AIcons.storageCard),
if (_filter is DynamicAlbumFilter) const Icon(AIcons.dynamicAlbum),
];
Widget? leading;

View file

@ -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

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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<HomePage> {
await reportService.log('Initialize source to view item in directory $directory');
final source = context.read<CollectionSource>();
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<HomePage> {
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,

View file

@ -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';

View file

@ -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<AppDrawer> createState() => _AppDrawerState();
static List<String> getDefaultAlbums(BuildContext context) {
static List<AlbumBaseFilter> _getDefaultAlbums(BuildContext context) {
final source = context.read<CollectionSource>();
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<AlbumBaseFilter>? _getCustomAlbums(BuildContext context) {
final source = context.read<CollectionSource>();
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<AlbumBaseFilter> effectiveAlbumBookmarks(BuildContext context) {
return _getCustomAlbums(context) ?? _getDefaultAlbums(context);
}
}
@ -288,17 +304,22 @@ class _AppDrawerState extends State<AppDrawer> with WidgetsBindingObserver {
return StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
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;
},
)),
],

View file

@ -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<CollectionSource>();
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,

View file

@ -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<TvRail> {
}
List<_NavEntry> _buildAlbumLinks(BuildContext context) {
final source = context.read<CollectionSource>();
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(

View file

@ -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<AlbumsChangedEvent>(),
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<AlbumsChangedEvent>(),
builder: (context, snapshot) {
final filters = <AlbumBaseFilter>[
...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,
);
},
),
);
}

View file

@ -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:

View file

@ -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<NavigationDrawerEditorPage> {
final List<CollectionFilter?> _typeItems = [];
final Set<CollectionFilter?> _visibleTypes = {};
final List<String> _albumItems = [];
final List<AlbumBaseFilter> _albumItems = [];
final List<String> _pageItems = [];
final Set<String> _visiblePages = {};
@ -54,7 +55,7 @@ class _NavigationDrawerEditorPageState extends State<NavigationDrawerEditorPage>
_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);

View file

@ -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<String> items;
final List<AlbumBaseFilter> items;
const DrawerAlbumTab({
super.key,
@ -23,11 +21,10 @@ class DrawerAlbumTab extends StatefulWidget {
}
class _DrawerAlbumTabState extends State<DrawerAlbumTab> {
List<String> get items => widget.items;
List<AlbumBaseFilter> get items => widget.items;
@override
Widget build(BuildContext context) {
final source = context.read<CollectionSource>();
return Column(
children: [
if (!settings.useTvLayout) ...[
@ -37,11 +34,10 @@ class _DrawerAlbumTabState extends State<DrawerAlbumTab> {
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<DrawerAlbumTab> {
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));
},
),
],

View file

@ -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<FilePickerPage> createState() => _FilePickerPageState();
}
class _FilePickerPageState extends State<FilePickerPage> {
late VolumeRelativeDirectory _directory;
List<Directory>? _contents;
Set<StorageVolume> 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<Settings, AccessibilityAnimations>((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>[];
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 }

View file

@ -170,8 +170,8 @@ class _SettingsMobilePageState extends State<SettingsMobilePage> 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);
}
}

View file

@ -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<StatsPage> with FeedbackMixin, VaultAwareMix
..._buildFilterSection<String>(context, l10n.statsTopStatesSectionTitle, _entryCountPerState, (v) => LocationFilter(LocationLevel.state, v)),
..._buildFilterSection<String>(context, l10n.statsTopPlacesSectionTitle, _entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)),
..._buildFilterSection<String>(context, l10n.statsTopTagsSectionTitle, _entryCountPerTag, TagFilter.new),
..._buildFilterSection<String>(context, l10n.statsTopAlbumsSectionTitle, _entryCountPerAlbum, (v) => AlbumFilter(v, source.getAlbumDisplayName(context, v))),
..._buildFilterSection<String>(context, l10n.statsTopAlbumsSectionTitle, _entryCountPerAlbum, (v) => StoredAlbumFilter(v, source.getStoredAlbumDisplayName(context, v))),
if (showRatings) ..._buildFilterSection<int>(context, l10n.searchRatingSectionTitle, _entryCountPerRating, RatingFilter.new, sortByCount: false, maxRowCount: null),
],
),

View file

@ -224,15 +224,16 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
}
Future<void> _convertMotionPhotoToStillImage(BuildContext context, AvesEntry targetEntry) async {
final l10n = context.l10n;
final confirmed = await showDialog<bool>(
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),
),
],
),

View file

@ -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,
),
),

View file

@ -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<BasicSection> {
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),
};

View file

@ -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';

View file

@ -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<SlideshowPage> {
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,
),
),

View file

@ -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,

View file

@ -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,

View file

@ -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<void> removeCovers(Set<CollectionFilter> filters) => SynchronousFuture(null);
@override
Future<Set<DynamicAlbumRow>> loadAllDynamicAlbums() => SynchronousFuture({});
// video playback
@override

View file

@ -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();
},
),

View file

@ -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;