diff --git a/CHANGELOG.md b/CHANGELOG.md index 104ca257c..10ce00968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added +- Albums / Countries / Tags: allow custom app / color along cover item - Info: improved GeoTIFF section - Cataloguing: locating from GeoTIFF metadata (requires rescan, limited to some projections) - Info: action to overlay GeoTIFF on map (limited to some projections) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 712fb88d7..2899f23bd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -308,6 +308,7 @@ "setCoverDialogTitle": "Set Cover", "setCoverDialogLatest": "Latest item", + "setCoverDialogAuto": "Auto", "setCoverDialogCustom": "Custom", "hideFilterConfirmationDialogMessage": "Matching photos and videos will be hidden from your collection. You can show them again from the “Privacy” settings.\n\nAre you sure you want to hide them?", @@ -399,6 +400,13 @@ "tileLayoutGrid": "Grid", "tileLayoutList": "List", + "coverDialogTabCover": "Cover", + "coverDialogTabApp": "App", + "coverDialogTabColor": "Color", + + "appPickDialogTitle": "Pick App", + "appPickDialogNone": "None", + "aboutPageTitle": "About", "aboutLinkSources": "Sources", "aboutLinkLicense": "License", diff --git a/lib/model/covers.dart b/lib/model/covers.dart index 3a1acb3b2..5d4338a31 100644 --- a/lib/model/covers.dart +++ b/lib/model/covers.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; @@ -7,10 +9,22 @@ import 'package:aves/utils/android_file_utils.dart'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:tuple/tuple.dart'; final Covers covers = Covers._private(); -class Covers with ChangeNotifier { +class Covers { + final StreamController?> _entryChangeStreamController = StreamController.broadcast(); + final StreamController?> _packageChangeStreamController = StreamController.broadcast(); + final StreamController?> _colorChangeStreamController = StreamController.broadcast(); + + Stream?> get entryChangeStream => _entryChangeStreamController.stream; + + Stream?> get packageChangeStream => _packageChangeStreamController.stream; + + Stream?> get colorChangeStream => _colorChangeStreamController.stream; + Set _rows = {}; Covers._private(); @@ -23,58 +37,88 @@ class Covers with ChangeNotifier { Set get all => Set.unmodifiable(_rows); - int? coverEntryId(CollectionFilter filter) => _rows.firstWhereOrNull((row) => row.filter == filter)?.entryId; + Tuple3? of(CollectionFilter filter) { + final row = _rows.firstWhereOrNull((row) => row.filter == filter); + return row != null ? Tuple3(row.entryId, row.packageName, row.color) : null; + } - Future set(CollectionFilter filter, int? entryId) async { + Future set({ + required CollectionFilter filter, + required int? entryId, + required String? packageName, + required Color? color, + }) async { // erase contextual properties from filters before saving them if (filter is AlbumFilter) { filter = AlbumFilter(filter.album, null); } - _rows.removeWhere((row) => row.filter == filter); - if (entryId == null) { + final oldRows = _rows.where((row) => row.filter == filter).toSet(); + _rows.removeAll(oldRows); + final oldRow = oldRows.firstOrNull; + final oldEntry = oldRow?.entryId; + final oldPackage = oldRow?.packageName; + final oldColor = oldRow?.color; + + if (entryId == null && packageName == null && color == null) { await metadataDb.removeCovers({filter}); } else { - final row = CoverRow(filter: filter, entryId: entryId); + final row = CoverRow( + filter: filter, + entryId: entryId, + packageName: packageName, + color: color, + ); _rows.add(row); await metadataDb.addCovers({row}); } - notifyListeners(); + if (oldEntry != entryId) _entryChangeStreamController.add({filter}); + if (oldPackage != packageName) _packageChangeStreamController.add({filter}); + if (oldColor != color) _colorChangeStreamController.add({filter}); } - Future moveEntry(AvesEntry entry, {required bool persist}) async { + Future _removeEntryFromRows(Set rows) { + return Future.forEach( + rows, + (row) => set( + filter: row.filter, + entryId: null, + packageName: row.packageName, + color: row.color, + )); + } + + Future moveEntry(AvesEntry entry) async { final entryId = entry.id; - final rows = _rows.where((row) => row.entryId == entryId).toSet(); - for (final row in rows) { - final filter = row.filter; - if (!filter.test(entry)) { - _rows.remove(row); - if (persist) { - await metadataDb.removeCovers({filter}); - } - } - } - - notifyListeners(); + await _removeEntryFromRows(_rows.where((row) => row.entryId == entryId && !row.filter.test(entry)).toSet()); } - Future removeEntries(Set entries) => removeIds(entries.map((entry) => entry.id).toSet()); - Future removeIds(Set entryIds) async { - final removedRows = _rows.where((row) => entryIds.contains(row.entryId)).toSet(); - - await metadataDb.removeCovers(removedRows.map((row) => row.filter).toSet()); - _rows.removeAll(removedRows); - - notifyListeners(); + await _removeEntryFromRows(_rows.where((row) => entryIds.contains(row.entryId)).toSet()); } Future clear() async { await metadataDb.clearCovers(); _rows.clear(); - notifyListeners(); + _entryChangeStreamController.add(null); + _packageChangeStreamController.add(null); + _colorChangeStreamController.add(null); + } + + AlbumType effectiveAlbumType(String albumPath) { + final filterPackage = of(AlbumFilter(albumPath, null))?.item2; + if (filterPackage != null) { + return filterPackage.isEmpty ? AlbumType.regular : AlbumType.app; + } else { + return androidFileUtils.getAlbumType(albumPath); + } + } + + String? effectiveAlbumPackage(String albumPath) { + final filterPackage = of(AlbumFilter(albumPath, null))?.item2; + return filterPackage ?? androidFileUtils.getAlbumAppPackageName(albumPath); } // import/export @@ -85,16 +129,17 @@ class Covers with ChangeNotifier { .map((row) { final entryId = row.entryId; final path = visibleEntries.firstWhereOrNull((entry) => entryId == entry.id)?.path; - if (path == null) return null; - final volume = androidFileUtils.getStorageVolume(path)?.path; - if (volume == null) return null; + final relativePath = volume != null ? path?.substring(volume.length) : null; + final packageName = row.packageName; + final colorValue = row.color?.value; - final relativePath = path.substring(volume.length); return { 'filter': row.filter.toJson(), - 'volume': volume, - 'relativePath': relativePath, + if (volume != null) 'volume': volume, + if (relativePath != null) 'relativePath': relativePath, + if (packageName != null) 'packageName': packageName, + if (colorValue != null) 'color': colorValue, }; }) .whereNotNull() @@ -116,18 +161,27 @@ class Covers with ChangeNotifier { return; } - final volume = row['volume']; - final relativePath = row['relativePath']; - if (volume is String && relativePath is String) { + final volume = row['volume'] as String?; + final relativePath = row['relativePath'] as String?; + final packageName = row['packageName'] as String?; + final colorValue = row['color'] as int?; + + AvesEntry? entry; + if (volume != null && relativePath != null) { final path = pContext.join(volume, relativePath); - final entry = visibleEntries.firstWhereOrNull((entry) => entry.path == path && filter.test(entry)); - if (entry != null) { - covers.set(filter, entry.id); - } else { - debugPrint('failed to import cover for path=$path, filter=$filter'); + entry = visibleEntries.firstWhereOrNull((entry) => entry.path == path && filter.test(entry)); + if (entry == null) { + debugPrint('failed to import cover entry for path=$path, filter=$filter'); } - } else { - debugPrint('failed to import cover for volume=$volume, relativePath=$relativePath, filter=$filter'); + } + + if (entry != null || packageName != null || colorValue != null) { + covers.set( + filter: filter, + entryId: entry?.id, + packageName: packageName, + color: colorValue != null ? Color(colorValue) : null, + ); } }); } @@ -136,27 +190,38 @@ class Covers with ChangeNotifier { @immutable class CoverRow extends Equatable { final CollectionFilter filter; - final int entryId; + final int? entryId; + final String? packageName; + final Color? color; @override - List get props => [filter, entryId]; + List get props => [filter, entryId, packageName, color]; const CoverRow({ required this.filter, required this.entryId, + required this.packageName, + required this.color, }); static CoverRow? fromMap(Map map) { final filter = CollectionFilter.fromJson(map['filter']); if (filter == null) return null; + + final colorValue = map['color'] as int?; + final color = colorValue != null ? Color(colorValue) : null; return CoverRow( filter: filter, - entryId: map['entryId'], + entryId: map['entryId'] as int?, + packageName: map['packageName'] as String?, + color: color, ); } Map toMap() => { 'filter': filter.toJson(), 'entryId': entryId, + 'packageName': packageName, + 'color': color?.value, }; } diff --git a/lib/model/db/db_metadata_sqflite.dart b/lib/model/db/db_metadata_sqflite.dart index 994a7d643..854be034d 100644 --- a/lib/model/db/db_metadata_sqflite.dart +++ b/lib/model/db/db_metadata_sqflite.dart @@ -85,6 +85,8 @@ class SqfliteMetadataDb implements MetadataDb { await db.execute('CREATE TABLE $coverTable(' 'filter TEXT PRIMARY KEY' ', entryId INTEGER' + ', packageName TEXT' + ', color INTEGER' ')'); await db.execute('CREATE TABLE $trashTable(' 'id INTEGER PRIMARY KEY' @@ -97,7 +99,7 @@ class SqfliteMetadataDb implements MetadataDb { ')'); }, onUpgrade: MetadataDbUpgrader.upgradeDb, - version: 7, + version: 8, ); final maxIdRows = await _db.rawQuery('SELECT max(id) AS maxId FROM $entryTable'); diff --git a/lib/model/db/db_metadata_sqflite_upgrade.dart b/lib/model/db/db_metadata_sqflite_upgrade.dart index c818e6c18..1ffe9af32 100644 --- a/lib/model/db/db_metadata_sqflite_upgrade.dart +++ b/lib/model/db/db_metadata_sqflite_upgrade.dart @@ -35,6 +35,9 @@ class MetadataDbUpgrader { case 6: await _upgradeFrom6(db); break; + case 7: + await _upgradeFrom7(db); + break; } oldVersion++; } @@ -269,4 +272,10 @@ class MetadataDbUpgrader { ', dateMillis INTEGER' ')'); } + + static Future _upgradeFrom7(Database db) async { + debugPrint('upgrading DB from v7'); + await db.execute('ALTER TABLE $coverTable ADD COLUMN packageName TEXT;'); + await db.execute('ALTER TABLE $coverTable ADD COLUMN color INTEGER;'); + } } diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index ffe4cd293..e9a3cc57e 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/covers.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/colors.dart'; @@ -8,7 +9,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; -class AlbumFilter extends CollectionFilter { +class AlbumFilter extends CoveredCollectionFilter { static const type = 'album'; final String album; @@ -53,10 +54,14 @@ class AlbumFilter extends CollectionFilter { @override Future color(BuildContext context) { - final colors = context.watch(); + // custom color has precedence over others, even custom app color + final customColor = covers.of(this)?.item3; + if (customColor != null) return SynchronousFuture(customColor); + + final colors = context.read(); // do not use async/await and rely on `SynchronousFuture` // to prevent rebuilding of the `FutureBuilder` listening on this future - final albumType = androidFileUtils.getAlbumType(album); + final albumType = covers.effectiveAlbumType(album); switch (albumType) { case AlbumType.regular: break; diff --git a/lib/model/filters/favourite.dart b/lib/model/filters/favourite.dart index 964992f8c..35ee91b65 100644 --- a/lib/model/filters/favourite.dart +++ b/lib/model/filters/favourite.dart @@ -35,7 +35,7 @@ class FavouriteFilter extends CollectionFilter { @override Future color(BuildContext context) { - final colors = context.watch(); + final colors = context.read(); return SynchronousFuture(colors.favourite); } diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index 567d45a62..eccbfb3f7 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/coordinate.dart'; @@ -95,7 +96,7 @@ abstract class CollectionFilter extends Equatable implements Comparable null; Future color(BuildContext context) { - final colors = context.watch(); + final colors = context.read(); return SynchronousFuture(colors.fromString(getLabel(context))); } @@ -114,6 +115,20 @@ abstract class CollectionFilter extends Equatable implements Comparable color(BuildContext context) { + final customColor = covers.of(this)?.item3; + if (customColor != null) { + return SynchronousFuture(customColor); + } + return super.color(context); + } +} + @immutable class FilterGridItem with EquatableMixin { final T filter; diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index 6d09d3e21..9bdca8654 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -5,7 +5,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; -class LocationFilter extends CollectionFilter { +class LocationFilter extends CoveredCollectionFilter { static const type = 'location'; static const locationSeparator = ';'; diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 9e81157cc..51338cb6f 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -77,7 +77,7 @@ class MimeFilter extends CollectionFilter { @override Future color(BuildContext context) { - final colors = context.watch(); + final colors = context.read(); switch (mime) { case MimeTypes.anyImage: return SynchronousFuture(colors.image); diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index e8fe793ea..b16ba2484 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -73,7 +73,7 @@ class QueryFilter extends CollectionFilter { return super.color(context); } - final colors = context.watch(); + final colors = context.read(); return SynchronousFuture(colors.neutral); } diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index 0c6105ba7..750e420cb 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -3,7 +3,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/widgets.dart'; -class TagFilter extends CollectionFilter { +class TagFilter extends CoveredCollectionFilter { static const type = 'tag'; final String tag; diff --git a/lib/model/filters/trash.dart b/lib/model/filters/trash.dart index a8f902c85..536318fa7 100644 --- a/lib/model/filters/trash.dart +++ b/lib/model/filters/trash.dart @@ -1,7 +1,7 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/theme/icons.dart'; -import 'package:flutter/material.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; class TrashFilter extends CollectionFilter { static const type = 'trash'; diff --git a/lib/model/filters/type.dart b/lib/model/filters/type.dart index e4565a671..300f6bc19 100644 --- a/lib/model/filters/type.dart +++ b/lib/model/filters/type.dart @@ -101,7 +101,7 @@ class TypeFilter extends CollectionFilter { @override Future color(BuildContext context) { - final colors = context.watch(); + final colors = context.read(); switch (itemType) { case _animated: return SynchronousFuture(colors.animated); diff --git a/lib/model/query.dart b/lib/model/query.dart index c1c510a6d..1078a71fc 100644 --- a/lib/model/query.dart +++ b/lib/model/query.dart @@ -28,7 +28,7 @@ class Query extends ChangeNotifier { void toggle() => enabled = !enabled; - final StreamController _enabledStreamController = StreamController.broadcast(); + final StreamController _enabledStreamController = StreamController.broadcast(); Stream get enabledStream => _enabledStreamController.stream; diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index e35066bfd..f3f4df159 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -19,7 +19,7 @@ final Settings settings = Settings._private(); class Settings extends ChangeNotifier { final EventChannel _platformSettingsChangeChannel = const EventChannel('deckers.thibault/aves/settings_change'); - final StreamController _updateStreamController = StreamController.broadcast(); + final StreamController _updateStreamController = StreamController.broadcast(); Stream get updateStream => _updateStreamController.stream; diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index e1dc3c4fa..cd8d69453 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -219,9 +219,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM entry.uri = 'file://${entry.trashDetails?.path}'; } - await covers.moveEntry(entry, persist: persist); - if (persist) { + await covers.moveEntry(entry); final id = entry.id; await metadataDb.updateEntry(id, entry); await metadataDb.updateCatalogMetadata(id, entry.catalogMetadata); @@ -236,7 +235,15 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM final bookmark = settings.drawerAlbumBookmarks?.indexOf(sourceAlbum); final pinned = settings.pinnedFilters.contains(oldFilter); - await covers.set(newFilter, covers.coverEntryId(oldFilter)); + + final existingCover = covers.of(oldFilter); + await covers.set( + filter: newFilter, + entryId: existingCover?.item1, + packageName: existingCover?.item2, + color: existingCover?.item3, + ); + renameNewAlbum(sourceAlbum, destinationAlbum); await updateAfterMove( todoEntries: entries, @@ -441,7 +448,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM } AvesEntry? coverEntry(CollectionFilter filter) { - final id = covers.coverEntryId(filter); + final id = covers.of(filter)?.item1; if (id != null) { final entry = visibleEntries.firstWhereOrNull((entry) => entry.id == id); if (entry != null) return entry; diff --git a/lib/services/common/service_policy.dart b/lib/services/common/service_policy.dart index c8fe1f09b..c2b7f5627 100644 --- a/lib/services/common/service_policy.dart +++ b/lib/services/common/service_policy.dart @@ -8,7 +8,7 @@ import 'package:tuple/tuple.dart'; final ServicePolicy servicePolicy = ServicePolicy._private(); class ServicePolicy { - final StreamController _queueStreamController = StreamController.broadcast(); + final StreamController _queueStreamController = StreamController.broadcast(); final Map> _paused = {}; final SplayTreeMap> _queues = SplayTreeMap(); final LinkedHashMap _runningQueue = LinkedHashMap(); diff --git a/lib/theme/colors.dart b/lib/theme/colors.dart index bb17bd746..af6d581ec 100644 --- a/lib/theme/colors.dart +++ b/lib/theme/colors.dart @@ -1,7 +1,7 @@ import 'package:aves/image_providers/app_icon_image_provider.dart'; +import 'package:aves/model/covers.dart'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/utils/android_file_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:palette_generator/palette_generator.dart'; @@ -55,7 +55,7 @@ abstract class AvesColorsData { Future? appColor(String album) { if (_appColors.containsKey(album)) return SynchronousFuture(_appColors[album]!); - final packageName = androidFileUtils.getAlbumAppPackageName(album); + final packageName = covers.effectiveAlbumPackage(album); if (packageName == null) return null; return PaletteGenerator.fromImageProvider( @@ -69,6 +69,8 @@ abstract class AvesColorsData { }); } + void clearAppColor(String album) => _appColors.remove(album); + static const Color _neutralOnDark = Colors.white; static const Color _neutralOnLight = Color(0xAA000000); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index 8a32d8260..c0e66ff4f 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -9,6 +9,7 @@ class AIcons { static const IconData accessibility = Icons.accessibility_new_outlined; static const IconData android = Icons.android; + static const IconData app = Icons.apps_outlined; static const IconData bin = Icons.delete_outlined; static const IconData broken = Icons.broken_image_outlined; static const IconData checked = Icons.done_outlined; @@ -20,6 +21,7 @@ class AIcons { static const IconData folder = Icons.folder_outlined; static const IconData grid = Icons.grid_on_outlined; static const IconData home = Icons.home_outlined; + static const IconData important = Icons.label_important_outline; static const IconData language = Icons.translate_outlined; static const IconData location = Icons.place_outlined; static const IconData locationUnlocated = Icons.location_off_outlined; diff --git a/lib/widgets/collection/grid/headers/album.dart b/lib/widgets/collection/grid/headers/album.dart index 7cb21b09b..5cf468649 100644 --- a/lib/widgets/collection/grid/headers/album.dart +++ b/lib/widgets/collection/grid/headers/album.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/section_keys.dart'; @@ -49,7 +50,7 @@ class AlbumSectionHeader extends StatelessWidget { context: context, maxWidth: maxWidth, title: source.getAlbumDisplayName(context, directory), - hasLeading: androidFileUtils.getAlbumType(directory) != AlbumType.regular, + hasLeading: covers.effectiveAlbumType(directory) != AlbumType.regular, hasTrailing: androidFileUtils.isOnRemovableStorage(directory), ); } diff --git a/lib/widgets/common/basic/color_list_tile.dart b/lib/widgets/common/basic/color_list_tile.dart index b03085c7f..91860f690 100644 --- a/lib/widgets/common/basic/color_list_tile.dart +++ b/lib/widgets/common/basic/color_list_tile.dart @@ -9,7 +9,7 @@ class ColorListTile extends StatelessWidget { final Color value; final ValueSetter onChanged; - static const radius = 16.0; + static const double radius = 16.0; const ColorListTile({ Key? key, diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 7473c4ecb..dbcbc09cc 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/chip_actions.dart'; +import 'package:aves/model/covers.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; @@ -114,6 +117,7 @@ class AvesFilterChip extends StatefulWidget { } class _AvesFilterChipState extends State { + final List _subscriptions = []; late Future _colorFuture; late Color _outlineColor; late bool _tapped; @@ -131,6 +135,14 @@ class _AvesFilterChipState extends State { void initState() { super.initState(); _tapped = false; + _subscriptions.add(covers.packageChangeStream.listen(_onCoverColorChange)); + _subscriptions.add(covers.colorChangeStream.listen(_onCoverColorChange)); + _subscriptions.add(settings.updateStream.where((event) => event.key == Settings.themeColorModeKey).listen((_) { + // delay so that contextual colors reflect the new settings + WidgetsBinding.instance!.addPostFrameCallback((_) { + _onCoverColorChange(null); + }); + })); } @override @@ -148,6 +160,14 @@ class _AvesFilterChipState extends State { } } + @override + void dispose() { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + super.dispose(); + } + void _initColorLoader() { // For app albums, `filter.color` yields a regular async `Future` the first time // but it yields a `SynchronousFuture` when called again on a known album. @@ -159,6 +179,13 @@ class _AvesFilterChipState extends State { _outlineColor = context.read().neutral; } + void _onCoverColorChange(Set? event) { + if (event == null || event.contains(filter)) { + _initColorLoader(); + setState(() {}); + } + } + @override Widget build(BuildContext context) { final decoration = widget.decoration; diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index d6780b12c..53448ec14 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -1,4 +1,5 @@ import 'package:aves/image_providers/app_icon_image_provider.dart'; +import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; @@ -267,7 +268,8 @@ class IconUtils { }) { size ??= IconTheme.of(context).size; Widget buildIcon(IconData icon) => Icon(icon, size: size); - switch (androidFileUtils.getAlbumType(albumPath)) { + + switch (covers.effectiveAlbumType(albumPath)) { case AlbumType.camera: return buildIcon(AIcons.cameraAlbum); case AlbumType.screenshots: @@ -278,14 +280,17 @@ class IconUtils { case AlbumType.download: return buildIcon(AIcons.downloadAlbum); case AlbumType.app: - return Image( - image: AppIconImage( - packageName: androidFileUtils.getAlbumAppPackageName(albumPath)!, - size: size!, - ), - width: size, - height: size, - ); + final package = covers.effectiveAlbumPackage(albumPath); + return package != null + ? Image( + image: AppIconImage( + packageName: package, + size: size!, + ), + width: size, + height: size, + ) + : null; case AlbumType.regular: default: return null; diff --git a/lib/widgets/debug/android_apps.dart b/lib/widgets/debug/android_apps.dart index fb6180883..b82710219 100644 --- a/lib/widgets/debug/android_apps.dart +++ b/lib/widgets/debug/android_apps.dart @@ -39,7 +39,7 @@ class _DebugAndroidAppSectionState extends State with Au future: _loader, builder: (context, snapshot) { if (snapshot.hasError) return Text(snapshot.error.toString()); - if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink(); + if (snapshot.connectionState != ConnectionState.done) return const SizedBox(); final packages = snapshot.data!.toList()..sort((a, b) => compareAsciiUpperCase(a.packageName, b.packageName)); final enabledTheme = IconTheme.of(context); final disabledTheme = enabledTheme.merge(const IconThemeData(opacity: .2)); diff --git a/lib/widgets/dialogs/add_shortcut_dialog.dart b/lib/widgets/dialogs/add_shortcut_dialog.dart index 799e2200e..b7e8c9cff 100644 --- a/lib/widgets/dialogs/add_shortcut_dialog.dart +++ b/lib/widgets/dialogs/add_shortcut_dialog.dart @@ -38,7 +38,7 @@ class _AddShortcutDialogState extends State { if (_collection != null) { final entries = _collection.sortedEntries; if (entries.isNotEmpty) { - final coverEntries = _collection.filters.map(covers.coverEntryId).whereNotNull().map((id) => entries.firstWhereOrNull((entry) => entry.id == id)).whereNotNull(); + final coverEntries = _collection.filters.map((filter) => covers.of(filter)?.item1).whereNotNull().map((id) => entries.firstWhereOrNull((entry) => entry.id == id)).whereNotNull(); _coverEntry = coverEntries.firstOrNull ?? entries.first; } } @@ -110,7 +110,7 @@ class _AddShortcutDialogState extends State { final _collection = widget.collection; if (_collection == null) return; - final entry = await Navigator.push( + final entry = await Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: ItemPickDialog.routeName), diff --git a/lib/widgets/dialogs/app_pick_dialog.dart b/lib/widgets/dialogs/app_pick_dialog.dart new file mode 100644 index 000000000..8819fedd9 --- /dev/null +++ b/lib/widgets/dialogs/app_pick_dialog.dart @@ -0,0 +1,146 @@ +import 'package:aves/image_providers/app_icon_image_provider.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/common/basic/query_bar.dart'; +import 'package:aves/widgets/common/basic/reselectable_radio_list_tile.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +class AppPickDialog extends StatefulWidget { + static const routeName = '/app_pick'; + + final String? initialValue; + + const AppPickDialog({ + Key? key, + required this.initialValue, + }) : super(key: key); + + @override + State createState() => _AppPickDialogState(); +} + +class _AppPickDialogState extends State { + late String? _selectedValue; + late Future> _loader; + final ValueNotifier _queryNotifier = ValueNotifier(''); + + static const double iconSize = 32; + + @override + void initState() { + super.initState(); + _selectedValue = widget.initialValue; + _loader = androidAppService.getPackages(); + } + + @override + Widget build(BuildContext context) { + return MediaQueryDataProvider( + child: Scaffold( + appBar: AppBar( + title: Text(context.l10n.appPickDialogTitle), + ), + body: SafeArea( + child: FutureBuilder>( + future: _loader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + final allPackages = snapshot.data; + if (allPackages == null) return const SizedBox(); + final packages = allPackages.where((package) => package.categoryLauncher).toList()..sort((a, b) => compareAsciiUpperCase(_displayName(a), _displayName(b))); + return Column( + children: [ + QueryBar(queryNotifier: _queryNotifier), + ValueListenableBuilder( + valueListenable: _queryNotifier, + builder: (context, query, child) { + final visiblePackages = packages.where((package) { + return { + package.packageName, + package.currentLabel, + package.englishLabel, + ...package.potentialDirs, + }.any((v) => v != null && v.toLowerCase().contains(query.toLowerCase())); + }).toList(); + final showNoneOption = query.isEmpty; + final itemCount = visiblePackages.length + (showNoneOption ? 1 : 0); + return Expanded( + child: ListView.builder( + itemBuilder: (context, index) { + if (showNoneOption) { + if (index == 0) { + return ReselectableRadioListTile( + value: '', + groupValue: _selectedValue, + onChanged: (v) => Navigator.pop(context, v), + reselectable: true, + title: Text( + context.l10n.appPickDialogNone, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), + ); + } + index--; + } + + final package = visiblePackages[index]; + return ReselectableRadioListTile( + value: package.packageName, + groupValue: _selectedValue, + onChanged: (v) => Navigator.pop(context, v), + reselectable: true, + title: Text.rich( + TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 16), + child: Image( + image: AppIconImage( + packageName: package.packageName, + size: iconSize, + ), + width: iconSize, + height: iconSize, + ), + ), + ), + TextSpan( + text: _displayName(package), + ), + ], + ), + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), + ); + }, + itemCount: itemCount, + ), + ); + }, + ), + ], + ); + }, + ), + ), + ), + ); + } + + String _displayName(Package package) => package.currentLabel ?? package.packageName; +} diff --git a/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart b/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart index 3b4c70f47..25b00d606 100644 --- a/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/edit_date_dialog.dart @@ -376,7 +376,7 @@ class _EditEntryDateDialogState extends State { final _collection = widget.collection; if (_collection == null) return; - final entry = await Navigator.push( + final entry = await Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: ItemPickDialog.routeName), diff --git a/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart b/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart index e81574f0a..057eb3346 100644 --- a/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/cover_selection_dialog.dart @@ -1,24 +1,38 @@ +import 'dart:math'; + +import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/basic/color_list_tile.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/common/fx/borders.dart'; +import 'package:aves/widgets/dialogs/app_pick_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/item_pick_dialog.dart'; import 'package:aves/widgets/dialogs/item_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; class CoverSelectionDialog extends StatefulWidget { final CollectionFilter filter; final AvesEntry? customEntry; + final String? customPackage; + final Color? customColor; const CoverSelectionDialog({ Key? key, required this.filter, required this.customEntry, + required this.customPackage, + required this.customColor, }) : super(key: key); @override @@ -26,83 +40,315 @@ class CoverSelectionDialog extends StatefulWidget { } class _CoverSelectionDialogState extends State { - late bool _isCustom; + late bool _isCustomEntry, _isCustomPackage, _isCustomColor; AvesEntry? _customEntry; + String? _customPackage; + Color? _customColor; CollectionFilter get filter => widget.filter; + bool get showAppTab => filter is AlbumFilter && settings.isInstalledAppAccessAllowed; + + bool get showColorTab => settings.themeColorMode == AvesThemeColorMode.polychrome; + + static const double itemPickerExtent = 46; + static const double appPickerExtent = 32; + static const double colorPickerRadius = 16; + + double tabBarHeight(BuildContext context) => 64 * max(1, MediaQuery.textScaleFactorOf(context)); + + static const double tabIndicatorWeight = 2; + @override void initState() { super.initState(); + _customEntry = widget.customEntry; - _isCustom = _customEntry != null; + _isCustomEntry = _customEntry != null; + + _customPackage = widget.customPackage; + _isCustomPackage = _customPackage != null; + + _customColor = widget.customColor; + _isCustomColor = _customColor != null; } @override Widget build(BuildContext context) { - return MediaQueryDataProvider( - child: Builder( - builder: (context) { - final l10n = context.l10n; - return AvesDialog( - title: l10n.setCoverDialogTitle, - scrollableContent: [ - ...[false, true].map( - (isCustom) { - final title = Text( - isCustom ? l10n.setCoverDialogCustom : l10n.setCoverDialogLatest, - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ); - return RadioListTile( - value: isCustom, - groupValue: _isCustom, - onChanged: (v) { - if (v == null) return; - if (v && _customEntry == null) { - _pickEntry(); - return; - } - _isCustom = v; - setState(() {}); - }, - title: isCustom - ? Row( - children: [ - title, - const Spacer(), - if (_customEntry != null) - ItemPicker( - extent: 46, - entry: _customEntry!, - onTap: _pickEntry, - ), - ], - ) - : title, - ); - }, - ), - ], - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(MaterialLocalizations.of(context).cancelButtonLabel), - ), - TextButton( - onPressed: () => Navigator.pop(context, Tuple2(_isCustom, _customEntry)), - child: Text(l10n.applyButtonLabel), - ), - ], + final l10n = context.l10n; + final tabs = >[ + Tuple2( + _buildTab( + context, + const Key('tab-entry'), + AIcons.image, + l10n.coverDialogTabCover, + ), + Column(children: _buildEntryOptions()), + ), + if (showAppTab) + Tuple2( + _buildTab( + context, + const Key('tab-package'), + AIcons.app, + l10n.coverDialogTabApp, + ), + Column(children: _buildAppOptions()), + ), + if (showColorTab) + Tuple2( + _buildTab( + context, + const Key('tab-color'), + AIcons.opacity, + l10n.coverDialogTabColor, + ), + Column(children: _buildColorOptions()), + ), + ]; + + final contentWidget = DecoratedBox( + decoration: AvesDialog.contentDecoration(context), + child: LayoutBuilder( + builder: (context, constraints) { + final availableBodyHeight = constraints.maxHeight - tabBarHeight(context) - tabIndicatorWeight; + final maxHeight = min(availableBodyHeight, tabBodyMaxHeight(context)); + return DefaultTabController( + length: 1 + (showAppTab ? 1 : 0) + (showColorTab ? 1 : 0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Material( + borderRadius: const BorderRadius.vertical( + top: AvesDialog.cornerRadius, + ), + clipBehavior: Clip.antiAlias, + child: TabBar( + indicatorWeight: tabIndicatorWeight, + tabs: tabs.map((t) => t.item1).toList(), + ), + ), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: maxHeight, + ), + child: TabBarView( + physics: const NeverScrollableScrollPhysics(), + children: tabs + .map((t) => SingleChildScrollView( + child: t.item2, + )) + .toList(), + ), + ), + ], + ), ); }, ), ); + + const actionsPadding = EdgeInsets.symmetric(horizontal: 8); + const double actionsSpacing = 8.0; + final actionsWidget = Padding( + padding: actionsPadding.add(const EdgeInsets.all(actionsSpacing)), + child: OverflowBar( + alignment: MainAxisAlignment.end, + spacing: actionsSpacing, + overflowAlignment: OverflowBarAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(MaterialLocalizations.of(context).cancelButtonLabel), + ), + TextButton( + onPressed: () { + final entry = _isCustomEntry ? _customEntry : null; + final package = _isCustomPackage ? _customPackage : null; + final color = _isCustomColor ? _customColor : null; + return Navigator.pop(context, Tuple3(entry, package, color)); + }, + child: Text(l10n.applyButtonLabel), + ) + ], + ), + ); + + Widget dialogChild = LayoutBuilder( + builder: (context, constraints) { + final availableBodyWidth = constraints.maxWidth; + final maxWidth = min(availableBodyWidth, tabBodyMaxWidth(context)); + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible(child: contentWidget), + actionsWidget, + ], + ), + ); + }, + ); + + return Dialog( + shape: AvesDialog.shape(context), + child: dialogChild, + ); + } + + List _buildEntryOptions() { + final l10n = context.l10n; + return [false, true].map( + (isCustom) { + final title = Text( + isCustom ? l10n.setCoverDialogCustom : l10n.setCoverDialogLatest, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + return RadioListTile( + value: isCustom, + groupValue: _isCustomEntry, + onChanged: (v) { + if (v == null) return; + if (v && _customEntry == null) { + _pickEntry(); + return; + } + _isCustomEntry = v; + setState(() {}); + }, + title: isCustom + ? Row( + children: [ + title, + const Spacer(), + if (_customEntry != null) + ItemPicker( + extent: itemPickerExtent, + entry: _customEntry!, + onTap: _pickEntry, + ), + ], + ) + : title, + ); + }, + ).toList(); + } + + List _buildAppOptions() { + final l10n = context.l10n; + return [false, true].map( + (isCustom) { + final title = Text( + isCustom ? l10n.setCoverDialogCustom : l10n.setCoverDialogAuto, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + return RadioListTile( + value: isCustom, + groupValue: _isCustomPackage, + onChanged: (v) { + if (v == null) return; + if (v && _customPackage == null) { + _pickPackage(); + return; + } + _isCustomPackage = v; + setState(() {}); + }, + title: isCustom + ? Row( + children: [ + title, + const Spacer(), + if (_customPackage != null) + GestureDetector( + onTap: _pickPackage, + child: _customPackage!.isNotEmpty + ? Image( + image: AppIconImage( + packageName: _customPackage!, + size: appPickerExtent, + ), + width: appPickerExtent, + height: appPickerExtent, + ) + : Container( + height: appPickerExtent, + width: appPickerExtent, + decoration: BoxDecoration( + border: AvesBorder.border(context), + shape: BoxShape.circle, + ), + child: const Icon(AIcons.clear), + ), + ), + ], + ) + : title, + ); + }, + ).toList(); + } + + List _buildColorOptions() { + final l10n = context.l10n; + return [false, true].map( + (isCustom) { + final title = Text( + isCustom ? l10n.setCoverDialogCustom : l10n.setCoverDialogAuto, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + return RadioListTile( + value: isCustom, + groupValue: _isCustomColor, + onChanged: (v) { + if (v == null) return; + if (v && _customColor == null) { + _pickColor(); + return; + } + _isCustomColor = v; + setState(() {}); + }, + title: isCustom + ? Row( + children: [ + title, + const Spacer(), + if (_customColor != null) + GestureDetector( + onTap: _pickColor, + child: Container( + height: colorPickerRadius * 2, + width: colorPickerRadius * 2, + decoration: BoxDecoration( + color: _customColor, + border: AvesBorder.border(context), + shape: BoxShape.circle, + ), + ), + ), + ], + ) + : title, + ); + }, + ).toList(); } Future _pickEntry() async { - final entry = await Navigator.push( + final entry = await Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: ItemPickDialog.routeName), @@ -117,8 +363,104 @@ class _CoverSelectionDialogState extends State { ); if (entry != null) { _customEntry = entry; - _isCustom = true; + _isCustomEntry = true; setState(() {}); } } + + Future _pickPackage() async { + final package = await Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: AppPickDialog.routeName), + builder: (context) => AppPickDialog( + initialValue: _customPackage, + ), + fullscreenDialog: true, + ), + ); + if (package != null) { + _customPackage = package; + _isCustomPackage = true; + setState(() {}); + } + } + + Future _pickColor() async { + final color = await showDialog( + context: context, + builder: (context) => ColorPickerDialog( + // avoid a pure material color as the default, so that + // picker controls are not on edge and palette panel is more stable + initialValue: _customColor ?? const Color(0xff3f51b5), + ), + ); + if (color != null) { + _customColor = color; + _isCustomColor = true; + setState(() {}); + } + } + + // tabs + + Tab _buildTab( + BuildContext context, + Key key, + IconData icon, + String text, { + Color? color, + }) { + // cannot use `IconTheme` over `TabBar` to change size, + // because `TabBar` does so internally + final textScaleFactor = MediaQuery.textScaleFactorOf(context); + final iconSize = IconTheme.of(context).size! * textScaleFactor; + return Tab( + key: key, + height: tabBarHeight(context), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: iconSize, + color: color, + ), + const SizedBox(height: 4), + Text( + text, + style: TextStyle(color: color), + softWrap: false, + overflow: TextOverflow.fade, + ), + ], + ), + ); + } + + // based on `ListTile` height computation (one line, no subtitle, not dense) + double singleOptionTileHeight(BuildContext context) => 56.0 + Theme.of(context).visualDensity.baseSizeAdjustment.dy; + + double tabBodyMaxWidth(BuildContext context) { + final l10n = context.l10n; + final _optionLines = { + l10n.setCoverDialogLatest, + l10n.setCoverDialogAuto, + l10n.setCoverDialogCustom, + }.fold('', (previousValue, element) => '$previousValue\n$element'); + + final para = RenderParagraph( + TextSpan(text: _optionLines, style: Theme.of(context).textTheme.subtitle1!), + textDirection: TextDirection.ltr, + textScaleFactor: MediaQuery.textScaleFactorOf(context), + )..layout(const BoxConstraints(), parentUsesSize: true); + final textWidth = para.getMaxIntrinsicWidth(double.infinity); + + // from `RadioListTile` layout + const contentPadding = 32; + const leadingWidth = kMinInteractiveDimension + 8; + return contentPadding + leadingWidth + textWidth + itemPickerExtent; + } + + double tabBodyMaxHeight(BuildContext context) => 2 * singleOptionTileHeight(context); } diff --git a/lib/widgets/dialogs/tile_view_dialog.dart b/lib/widgets/dialogs/tile_view_dialog.dart index eb957e3d2..75fd54435 100644 --- a/lib/widgets/dialogs/tile_view_dialog.dart +++ b/lib/widgets/dialogs/tile_view_dialog.dart @@ -40,6 +40,8 @@ class _TileViewDialogState extends State> with Map get layoutOptions => widget.layoutOptions; + static const int groupTabIndex = 1; + double tabBarHeight(BuildContext context) => 64 * max(1, MediaQuery.textScaleFactorOf(context)); static const double tabIndicatorWeight = 2; @@ -144,7 +146,6 @@ class _TileViewDialogState extends State> with final maxHeight = min(availableBodyHeight, tabBodyMaxHeight(context)); return Column( mainAxisSize: MainAxisSize.min, - // crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Material( borderRadius: const BorderRadius.vertical( @@ -225,6 +226,24 @@ class _TileViewDialogState extends State> with ); } + Widget _buildRadioListTile(T value, String title, T? Function() get, void Function(T value) set) { + return RadioListTile( + // key is expected by test driver + key: Key(value.toString()), + value: value, + groupValue: get(), + onChanged: (v) => setState(() => set(v!)), + title: Text( + title, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ), + ); + } + + // tabs + Tab _buildTab( BuildContext context, Key key, @@ -262,7 +281,7 @@ class _TileViewDialogState extends State> with bool get canGroup => _selectedSort == EntrySortFactor.date || _selectedSort is ChipSortFactor; void _onTabChange() { - if (!canGroup && _tabController.index == 1) { + if (!canGroup && _tabController.index == groupTabIndex) { _tabController.index = _tabController.previousIndex; } } @@ -291,20 +310,4 @@ class _TileViewDialogState extends State> with layoutOptions, ].map((v) => v.length).fold(0, max) * singleOptionTileHeight(context); - - Widget _buildRadioListTile(T value, String title, T? Function() get, void Function(T value) set) { - return RadioListTile( - // key is expected by test driver - key: Key(value.toString()), - value: value, - groupValue: get(), - onChanged: (v) => setState(() => set(v!)), - title: Text( - title, - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ), - ); - } } diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index e82640bd2..f44836da6 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/covers.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/settings.dart'; @@ -38,17 +39,21 @@ class AlbumListPage extends StatelessWidget { stream: source.eventBus.on(), builder: (context, snapshot) { final gridItems = getAlbumGridItems(context, source); - return FilterNavigationPage( - source: source, - title: context.l10n.albumPageTitle, - sortFactor: settings.albumSortFactor, - showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, - actionDelegate: AlbumChipSetActionDelegate(gridItems), - filterSections: groupToSections(context, source, gridItems), - newFilters: source.getNewAlbumFilters(context), - emptyBuilder: () => EmptyContent( - icon: AIcons.album, - text: context.l10n.albumEmpty, + return StreamBuilder?>( + // to update sections by tier + stream: covers.packageChangeStream, + builder: (context, snapshot) => FilterNavigationPage( + source: source, + title: context.l10n.albumPageTitle, + sortFactor: settings.albumSortFactor, + showHeaders: settings.albumGroupFactor != AlbumChipGroupFactor.none, + actionDelegate: AlbumChipSetActionDelegate(gridItems), + filterSections: groupToSections(context, source, gridItems), + newFilters: source.getNewAlbumFilters(context), + emptyBuilder: () => EmptyContent( + icon: AIcons.album, + text: context.l10n.albumEmpty, + ), ), ); }, @@ -89,7 +94,7 @@ class AlbumListPage extends StatelessWidget { final appsKey = AlbumImportanceSectionKey.apps(context); final regularKey = AlbumImportanceSectionKey.regular(context); sections = groupBy, ChipSectionKey>(unpinnedMapEntries, (kv) { - switch (androidFileUtils.getAlbumType(kv.filter.album)) { + switch (covers.effectiveAlbumType(kv.filter.album)) { case AlbumType.regular: return regularKey; case AlbumType.app: diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index f20f02796..cc94f0b1a 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -2,12 +2,14 @@ import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/chip_set_actions.dart'; import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; +import 'package:aves/theme/colors.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; @@ -277,19 +279,33 @@ abstract class ChipSetActionDelegate with FeedbackMi } void _setCover(BuildContext context, T filter) async { - final entryId = covers.coverEntryId(filter); - final customEntry = context.read().visibleEntries.firstWhereOrNull((entry) => entry.id == entryId); - final coverSelection = await showDialog>( + final existingCover = covers.of(filter); + final entryId = existingCover?.item1; + final customEntry = entryId != null ? context.read().visibleEntries.firstWhereOrNull((entry) => entry.id == entryId) : null; + final selectedCover = await showDialog>( context: context, builder: (context) => CoverSelectionDialog( filter: filter, customEntry: customEntry, + customPackage: existingCover?.item2, + customColor: existingCover?.item3, ), ); - if (coverSelection == null) return; + if (selectedCover == null) return; - final isCustom = coverSelection.item1; - await covers.set(filter, isCustom ? coverSelection.item2?.id : null); + if (filter is AlbumFilter) { + context.read().clearAppColor(filter.album); + } + + final selectedEntry = selectedCover.item1; + final selectedPackage = selectedCover.item2; + final selectedColor = selectedCover.item3; + await covers.set( + filter: filter, + entryId: selectedEntry?.id, + packageName: selectedPackage, + color: selectedColor, + ); _browse(context); } diff --git a/lib/widgets/filter_grids/common/covered_filter_chip.dart b/lib/widgets/filter_grids/common/covered_filter_chip.dart index 30b6ad5e3..42aae6ea3 100644 --- a/lib/widgets/filter_grids/common/covered_filter_chip.dart +++ b/lib/widgets/filter_grids/common/covered_filter_chip.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; @@ -61,37 +62,40 @@ class CoveredFilterChip extends StatelessWidget { @override Widget build(BuildContext context) { - return Consumer( - builder: (context, source, child) { - switch (T) { - case AlbumFilter: - { - final album = (filter as AlbumFilter).album; - return StreamBuilder( - stream: source.eventBus.on().where((event) => event.directories == null || event.directories!.contains(album)), - builder: (context, snapshot) => _buildChip(context, source), - ); - } - case LocationFilter: - { - final countryCode = (filter as LocationFilter).countryCode; - return StreamBuilder( - stream: source.eventBus.on().where((event) => event.countryCodes == null || event.countryCodes!.contains(countryCode)), - builder: (context, snapshot) => _buildChip(context, source), - ); - } - case TagFilter: - { - final tag = (filter as TagFilter).tag; - return StreamBuilder( - stream: source.eventBus.on().where((event) => event.tags == null || event.tags!.contains(tag)), - builder: (context, snapshot) => _buildChip(context, source), - ); - } - default: - return const SizedBox(); - } - }, + return StreamBuilder?>( + stream: covers.entryChangeStream.where((event) => event == null || event.contains(filter)), + builder: (context, snapshot) => Consumer( + builder: (context, source, child) { + switch (T) { + case AlbumFilter: + { + final album = (filter as AlbumFilter).album; + return StreamBuilder( + stream: source.eventBus.on().where((event) => event.directories == null || event.directories!.contains(album)), + builder: (context, snapshot) => _buildChip(context, source), + ); + } + case LocationFilter: + { + final countryCode = (filter as LocationFilter).countryCode; + return StreamBuilder( + stream: source.eventBus.on().where((event) => event.countryCodes == null || event.countryCodes!.contains(countryCode)), + builder: (context, snapshot) => _buildChip(context, source), + ); + } + case TagFilter: + { + final tag = (filter as TagFilter).tag; + return StreamBuilder( + stream: source.eventBus.on().where((event) => event.tags == null || event.tags!.contains(tag)), + builder: (context, snapshot) => _buildChip(context, source), + ); + } + default: + return const SizedBox(); + } + }, + ), ); } diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 4710d47f5..cfe3cd07c 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -1,5 +1,4 @@ import 'package:aves/app_mode.dart'; -import 'package:aves/model/covers.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/selection.dart'; @@ -84,24 +83,21 @@ class FilterGridPage extends StatelessWidget { child: GestureAreaProtectorStack( child: SafeArea( bottom: false, - child: AnimatedBuilder( - animation: covers, - builder: (context, child) => FilterGrid( - // key is expected by test driver - key: const Key('filter-grid'), - settingsRouteKey: settingsRouteKey, - appBar: appBar, - appBarHeight: appBarHeight, - sections: sections, - newFilters: newFilters, - sortFactor: sortFactor, - showHeaders: showHeaders, - selectable: selectable, - queryNotifier: queryNotifier, - applyQuery: applyQuery, - emptyBuilder: emptyBuilder, - heroType: heroType, - ), + child: FilterGrid( + // key is expected by test driver + key: const Key('filter-grid'), + settingsRouteKey: settingsRouteKey, + appBar: appBar, + appBarHeight: appBarHeight, + sections: sections, + newFilters: newFilters, + sortFactor: sortFactor, + showHeaders: showHeaders, + selectable: selectable, + queryNotifier: queryNotifier, + applyQuery: applyQuery, + emptyBuilder: emptyBuilder, + heroType: heroType, ), ), ), diff --git a/lib/widgets/filter_grids/common/section_keys.dart b/lib/widgets/filter_grids/common/section_keys.dart index 4b883709b..d6dec813e 100644 --- a/lib/widgets/filter_grids/common/section_keys.dart +++ b/lib/widgets/filter_grids/common/section_keys.dart @@ -62,9 +62,9 @@ extension ExtraAlbumImportance on AlbumImportance { case AlbumImportance.pinned: return AIcons.pin; case AlbumImportance.special: - return Icons.label_important_outline; + return AIcons.important; case AlbumImportance.apps: - return Icons.apps_outlined; + return AIcons.app; case AlbumImportance.regular: return AIcons.album; } diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index 818e0f14a..17a1e7a07 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -24,7 +24,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi final AvesEntry entry; final CollectionLens? collection; - final StreamController> _eventStreamController = StreamController>.broadcast(); + final StreamController> _eventStreamController = StreamController.broadcast(); Stream> get eventStream => _eventStreamController.stream; diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index f0957910e..eebf5c197 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -169,15 +169,15 @@ void main() { const albumFilter = AlbumFilter(testAlbum, 'whatever'); expect(albumFilter.test(image1), true); expect(covers.count, 0); - expect(covers.coverEntryId(albumFilter), null); + expect(covers.of(albumFilter), null); - await covers.set(albumFilter, image1.id); + await covers.set(filter: albumFilter, entryId: image1.id, packageName: null, color: null); expect(covers.count, 1); - expect(covers.coverEntryId(albumFilter), image1.id); + expect(covers.of(albumFilter)?.item1, image1.id); - await covers.set(albumFilter, null); + await covers.set(filter: albumFilter, entryId: null, packageName: null, color: null); expect(covers.count, 0); - expect(covers.coverEntryId(albumFilter), null); + expect(covers.of(albumFilter), null); }); test('favourites and covers are kept when renaming entries', () async { @@ -189,7 +189,7 @@ void main() { final source = await _initSource(); await image1.toggleFavourite(); const albumFilter = AlbumFilter(testAlbum, 'whatever'); - await covers.set(albumFilter, image1.id); + await covers.set(filter: albumFilter, entryId: image1.id, packageName: null, color: null); await source.updateAfterRename( todoEntries: {image1}, movedOps: { @@ -201,7 +201,7 @@ void main() { expect(favourites.count, 1); expect(image1.isFavourite, true); expect(covers.count, 1); - expect(covers.coverEntryId(albumFilter), image1.id); + expect(covers.of(albumFilter)?.item1, image1.id); }); test('favourites and covers are cleared when removing entries', () async { @@ -213,13 +213,13 @@ void main() { final source = await _initSource(); await image1.toggleFavourite(); final albumFilter = AlbumFilter(image1.directory!, 'whatever'); - await covers.set(albumFilter, image1.id); + await covers.set(filter: albumFilter, entryId: image1.id, packageName: null, color: null); await source.removeEntries({image1.uri}, includeTrash: true); expect(source.rawAlbums.length, 0); expect(favourites.count, 0); expect(covers.count, 0); - expect(covers.coverEntryId(albumFilter), null); + expect(covers.of(albumFilter), null); }); test('albums are updated when moving entries', () async { @@ -284,7 +284,7 @@ void main() { final source = await _initSource(); expect(source.rawAlbums.length, 1); const sourceAlbumFilter = AlbumFilter(sourceAlbum, 'whatever'); - await covers.set(sourceAlbumFilter, image1.id); + await covers.set(filter: sourceAlbumFilter, entryId: image1.id, packageName: null, color: null); await source.updateAfterMove( todoEntries: {image1}, @@ -297,7 +297,7 @@ void main() { expect(source.rawAlbums.length, 2); expect(covers.count, 0); - expect(covers.coverEntryId(sourceAlbumFilter), null); + expect(covers.of(sourceAlbumFilter), null); }); test('favourites and covers are kept when renaming albums', () async { @@ -309,7 +309,7 @@ void main() { final source = await _initSource(); await image1.toggleFavourite(); var albumFilter = const AlbumFilter(sourceAlbum, 'whatever'); - await covers.set(albumFilter, image1.id); + await covers.set(filter: albumFilter, entryId: image1.id, packageName: null, color: null); await source.renameAlbum(sourceAlbum, destinationAlbum, { image1 }, { @@ -320,7 +320,7 @@ void main() { expect(favourites.count, 1); expect(image1.isFavourite, true); expect(covers.count, 1); - expect(covers.coverEntryId(albumFilter), image1.id); + expect(covers.of(albumFilter)?.item1, image1.id); }); testWidgets('unique album names', (tester) async { diff --git a/untranslated.json b/untranslated.json index 07abc34c8..5281f4551 100644 --- a/untranslated.json +++ b/untranslated.json @@ -2,49 +2,97 @@ "de": [ "entryActionShowGeoTiffOnMap", "entryActionConvertMotionPhotoToStillImage", - "convertMotionPhotoToStillImageWarningDialogMessage" + "setCoverDialogAuto", + "convertMotionPhotoToStillImageWarningDialogMessage", + "coverDialogTabCover", + "coverDialogTabApp", + "coverDialogTabColor", + "appPickDialogTitle", + "appPickDialogNone" ], "es": [ "entryActionShowGeoTiffOnMap", "entryActionConvertMotionPhotoToStillImage", - "convertMotionPhotoToStillImageWarningDialogMessage" + "setCoverDialogAuto", + "convertMotionPhotoToStillImageWarningDialogMessage", + "coverDialogTabCover", + "coverDialogTabApp", + "coverDialogTabColor", + "appPickDialogTitle", + "appPickDialogNone" ], "fr": [ "entryActionShowGeoTiffOnMap", "entryActionConvertMotionPhotoToStillImage", - "convertMotionPhotoToStillImageWarningDialogMessage" + "setCoverDialogAuto", + "convertMotionPhotoToStillImageWarningDialogMessage", + "coverDialogTabCover", + "coverDialogTabApp", + "coverDialogTabColor", + "appPickDialogTitle", + "appPickDialogNone" ], "id": [ "entryActionShowGeoTiffOnMap", "entryActionConvertMotionPhotoToStillImage", - "convertMotionPhotoToStillImageWarningDialogMessage" + "setCoverDialogAuto", + "convertMotionPhotoToStillImageWarningDialogMessage", + "coverDialogTabCover", + "coverDialogTabApp", + "coverDialogTabColor", + "appPickDialogTitle", + "appPickDialogNone" ], "it": [ "entryActionShowGeoTiffOnMap", "entryActionConvertMotionPhotoToStillImage", - "convertMotionPhotoToStillImageWarningDialogMessage" + "setCoverDialogAuto", + "convertMotionPhotoToStillImageWarningDialogMessage", + "coverDialogTabCover", + "coverDialogTabApp", + "coverDialogTabColor", + "appPickDialogTitle", + "appPickDialogNone" ], "ja": [ "entryActionShowGeoTiffOnMap", "entryActionConvertMotionPhotoToStillImage", - "convertMotionPhotoToStillImageWarningDialogMessage" + "setCoverDialogAuto", + "convertMotionPhotoToStillImageWarningDialogMessage", + "coverDialogTabCover", + "coverDialogTabApp", + "coverDialogTabColor", + "appPickDialogTitle", + "appPickDialogNone" ], "ko": [ "entryActionShowGeoTiffOnMap", "entryActionConvertMotionPhotoToStillImage", - "convertMotionPhotoToStillImageWarningDialogMessage" + "setCoverDialogAuto", + "convertMotionPhotoToStillImageWarningDialogMessage", + "coverDialogTabCover", + "coverDialogTabApp", + "coverDialogTabColor", + "appPickDialogTitle", + "appPickDialogNone" ], "pt": [ "entryActionShowGeoTiffOnMap", "entryActionConvertMotionPhotoToStillImage", - "convertMotionPhotoToStillImageWarningDialogMessage" + "setCoverDialogAuto", + "convertMotionPhotoToStillImageWarningDialogMessage", + "coverDialogTabCover", + "coverDialogTabApp", + "coverDialogTabColor", + "appPickDialogTitle", + "appPickDialogNone" ], "ru": [ @@ -57,6 +105,7 @@ "themeBrightnessBlack", "moveUndatedConfirmationDialogMessage", "moveUndatedConfirmationDialogSetDate", + "setCoverDialogAuto", "renameEntrySetPageTitle", "renameEntrySetPagePatternFieldLabel", "renameEntrySetPageInsertTooltip", @@ -65,6 +114,11 @@ "renameProcessorName", "editEntryDateDialogCopyItem", "convertMotionPhotoToStillImageWarningDialogMessage", + "coverDialogTabCover", + "coverDialogTabApp", + "coverDialogTabColor", + "appPickDialogTitle", + "appPickDialogNone", "collectionRenameFailureFeedback", "collectionRenameSuccessFeedback", "settingsConfirmationDialogMoveUndatedItems", @@ -77,6 +131,12 @@ "zh": [ "entryActionConvertMotionPhotoToStillImage", - "convertMotionPhotoToStillImageWarningDialogMessage" + "setCoverDialogAuto", + "convertMotionPhotoToStillImageWarningDialogMessage", + "coverDialogTabCover", + "coverDialogTabApp", + "coverDialogTabColor", + "appPickDialogTitle", + "appPickDialogNone" ] }