#179 allow custom app / color along cover item

This commit is contained in:
Thibault Deckers 2022-04-15 12:24:17 +09:00
parent 7b24e25d71
commit e980fae768
38 changed files with 976 additions and 255 deletions

View file

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

View file

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

View file

@ -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<Set<CollectionFilter>?> _entryChangeStreamController = StreamController.broadcast();
final StreamController<Set<CollectionFilter>?> _packageChangeStreamController = StreamController.broadcast();
final StreamController<Set<CollectionFilter>?> _colorChangeStreamController = StreamController.broadcast();
Stream<Set<CollectionFilter>?> get entryChangeStream => _entryChangeStreamController.stream;
Stream<Set<CollectionFilter>?> get packageChangeStream => _packageChangeStreamController.stream;
Stream<Set<CollectionFilter>?> get colorChangeStream => _colorChangeStreamController.stream;
Set<CoverRow> _rows = {};
Covers._private();
@ -23,58 +37,88 @@ class Covers with ChangeNotifier {
Set<CoverRow> get all => Set.unmodifiable(_rows);
int? coverEntryId(CollectionFilter filter) => _rows.firstWhereOrNull((row) => row.filter == filter)?.entryId;
Tuple3<int?, String?, Color?>? of(CollectionFilter filter) {
final row = _rows.firstWhereOrNull((row) => row.filter == filter);
return row != null ? Tuple3(row.entryId, row.packageName, row.color) : null;
}
Future<void> set(CollectionFilter filter, int? entryId) async {
Future<void> 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<void> moveEntry(AvesEntry entry, {required bool persist}) async {
Future<void> _removeEntryFromRows(Set<CoverRow> rows) {
return Future.forEach<CoverRow>(
rows,
(row) => set(
filter: row.filter,
entryId: null,
packageName: row.packageName,
color: row.color,
));
}
Future<void> 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<void> removeEntries(Set<AvesEntry> entries) => removeIds(entries.map((entry) => entry.id).toSet());
Future<void> removeIds(Set<int> 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<void> 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<Object?> get props => [filter, entryId];
List<Object?> 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<String, dynamic> toMap() => {
'filter': filter.toJson(),
'entryId': entryId,
'packageName': packageName,
'color': color?.value,
};
}

View file

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

View file

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

View file

@ -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> color(BuildContext context) {
final colors = context.watch<AvesColorsData>();
// 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<AvesColorsData>();
// 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;

View file

@ -35,7 +35,7 @@ class FavouriteFilter extends CollectionFilter {
@override
Future<Color> color(BuildContext context) {
final colors = context.watch<AvesColorsData>();
final colors = context.read<AvesColorsData>();
return SynchronousFuture(colors.favourite);
}

View file

@ -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<Collecti
Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => null;
Future<Color> color(BuildContext context) {
final colors = context.watch<AvesColorsData>();
final colors = context.read<AvesColorsData>();
return SynchronousFuture(colors.fromString(getLabel(context)));
}
@ -114,6 +115,20 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
}
}
@immutable
abstract class CoveredCollectionFilter extends CollectionFilter {
const CoveredCollectionFilter({bool not = false}) : super(not: not);
@override
Future<Color> color(BuildContext context) {
final customColor = covers.of(this)?.item3;
if (customColor != null) {
return SynchronousFuture(customColor);
}
return super.color(context);
}
}
@immutable
class FilterGridItem<T extends CollectionFilter> with EquatableMixin {
final T filter;

View file

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

View file

@ -77,7 +77,7 @@ class MimeFilter extends CollectionFilter {
@override
Future<Color> color(BuildContext context) {
final colors = context.watch<AvesColorsData>();
final colors = context.read<AvesColorsData>();
switch (mime) {
case MimeTypes.anyImage:
return SynchronousFuture(colors.image);

View file

@ -73,7 +73,7 @@ class QueryFilter extends CollectionFilter {
return super.color(context);
}
final colors = context.watch<AvesColorsData>();
final colors = context.read<AvesColorsData>();
return SynchronousFuture(colors.neutral);
}

View file

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

View file

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

View file

@ -101,7 +101,7 @@ class TypeFilter extends CollectionFilter {
@override
Future<Color> color(BuildContext context) {
final colors = context.watch<AvesColorsData>();
final colors = context.read<AvesColorsData>();
switch (itemType) {
case _animated:
return SynchronousFuture(colors.animated);

View file

@ -28,7 +28,7 @@ class Query extends ChangeNotifier {
void toggle() => enabled = !enabled;
final StreamController<bool> _enabledStreamController = StreamController<bool>.broadcast();
final StreamController<bool> _enabledStreamController = StreamController.broadcast();
Stream<bool> get enabledStream => _enabledStreamController.stream;

View file

@ -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<SettingsChangedEvent> _updateStreamController = StreamController<SettingsChangedEvent>.broadcast();
final StreamController<SettingsChangedEvent> _updateStreamController = StreamController.broadcast();
Stream<SettingsChangedEvent> get updateStream => _updateStreamController.stream;

View file

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

View file

@ -8,7 +8,7 @@ import 'package:tuple/tuple.dart';
final ServicePolicy servicePolicy = ServicePolicy._private();
class ServicePolicy {
final StreamController<QueueState> _queueStreamController = StreamController<QueueState>.broadcast();
final StreamController<QueueState> _queueStreamController = StreamController.broadcast();
final Map<Object, Tuple2<int, _Task>> _paused = {};
final SplayTreeMap<int, LinkedHashMap<Object, _Task>> _queues = SplayTreeMap();
final LinkedHashMap<Object, _Task> _runningQueue = LinkedHashMap();

View file

@ -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<Color>? 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);

View file

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

View file

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

View file

@ -9,7 +9,7 @@ class ColorListTile extends StatelessWidget {
final Color value;
final ValueSetter<Color> onChanged;
static const radius = 16.0;
static const double radius = 16.0;
const ColorListTile({
Key? key,

View file

@ -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<AvesFilterChip> {
final List<StreamSubscription> _subscriptions = [];
late Future<Color> _colorFuture;
late Color _outlineColor;
late bool _tapped;
@ -131,6 +135,14 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
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<AvesFilterChip> {
}
}
@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<AvesFilterChip> {
_outlineColor = context.read<AvesColorsData>().neutral;
}
void _onCoverColorChange(Set<CollectionFilter>? event) {
if (event == null || event.contains(filter)) {
_initColorLoader();
setState(() {});
}
}
@override
Widget build(BuildContext context) {
final decoration = widget.decoration;

View file

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

View file

@ -39,7 +39,7 @@ class _DebugAndroidAppSectionState extends State<DebugAndroidAppSection> 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));

View file

@ -38,7 +38,7 @@ class _AddShortcutDialogState extends State<AddShortcutDialog> {
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<AddShortcutDialog> {
final _collection = widget.collection;
if (_collection == null) return;
final entry = await Navigator.push(
final entry = await Navigator.push<AvesEntry>(
context,
MaterialPageRoute(
settings: const RouteSettings(name: ItemPickDialog.routeName),

View file

@ -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<AppPickDialog> createState() => _AppPickDialogState();
}
class _AppPickDialogState extends State<AppPickDialog> {
late String? _selectedValue;
late Future<Set<Package>> _loader;
final ValueNotifier<String> _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<Set<Package>>(
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<String>(
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<String?>(
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<String?>(
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;
}

View file

@ -376,7 +376,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
final _collection = widget.collection;
if (_collection == null) return;
final entry = await Navigator.push(
final entry = await Navigator.push<AvesEntry>(
context,
MaterialPageRoute(
settings: const RouteSettings(name: ItemPickDialog.routeName),

View file

@ -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<CoverSelectionDialog> {
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<bool>(
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<bool, AvesEntry?>(_isCustom, _customEntry)),
child: Text(l10n.applyButtonLabel),
),
],
final l10n = context.l10n;
final tabs = <Tuple2<Tab, Widget>>[
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<AvesEntry?, String?, Color?>(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<Widget> _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<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();
}
List<Widget> _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<bool>(
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<Widget> _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<bool>(
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<void> _pickEntry() async {
final entry = await Navigator.push(
final entry = await Navigator.push<AvesEntry>(
context,
MaterialPageRoute(
settings: const RouteSettings(name: ItemPickDialog.routeName),
@ -117,8 +363,104 @@ class _CoverSelectionDialogState extends State<CoverSelectionDialog> {
);
if (entry != null) {
_customEntry = entry;
_isCustom = true;
_isCustomEntry = true;
setState(() {});
}
}
Future<void> _pickPackage() async {
final package = await Navigator.push<String>(
context,
MaterialPageRoute(
settings: const RouteSettings(name: AppPickDialog.routeName),
builder: (context) => AppPickDialog(
initialValue: _customPackage,
),
fullscreenDialog: true,
),
);
if (package != null) {
_customPackage = package;
_isCustomPackage = true;
setState(() {});
}
}
Future<void> _pickColor() async {
final color = await showDialog<Color>(
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);
}

View file

@ -40,6 +40,8 @@ class _TileViewDialogState<S, G, L> extends State<TileViewDialog<S, G, L>> with
Map<L, String> 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<S, G, L> extends State<TileViewDialog<S, G, L>> 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<S, G, L> extends State<TileViewDialog<S, G, L>> with
);
}
Widget _buildRadioListTile<T>(T value, String title, T? Function() get, void Function(T value) set) {
return RadioListTile<T>(
// 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<S, G, L> extends State<TileViewDialog<S, G, L>> 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<S, G, L> extends State<TileViewDialog<S, G, L>> with
layoutOptions,
].map((v) => v.length).fold(0, max) *
singleOptionTileHeight(context);
Widget _buildRadioListTile<T>(T value, String title, T? Function() get, void Function(T value) set) {
return RadioListTile<T>(
// 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,
),
);
}
}

View file

@ -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<AlbumsChangedEvent>(),
builder: (context, snapshot) {
final gridItems = getAlbumGridItems(context, source);
return FilterNavigationPage<AlbumFilter>(
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<Set<CollectionFilter>?>(
// to update sections by tier
stream: covers.packageChangeStream,
builder: (context, snapshot) => FilterNavigationPage<AlbumFilter>(
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<FilterGridItem<AlbumFilter>, ChipSectionKey>(unpinnedMapEntries, (kv) {
switch (androidFileUtils.getAlbumType(kv.filter.album)) {
switch (covers.effectiveAlbumType(kv.filter.album)) {
case AlbumType.regular:
return regularKey;
case AlbumType.app:

View file

@ -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<T extends CollectionFilter> with FeedbackMi
}
void _setCover(BuildContext context, T filter) async {
final entryId = covers.coverEntryId(filter);
final customEntry = context.read<CollectionSource>().visibleEntries.firstWhereOrNull((entry) => entry.id == entryId);
final coverSelection = await showDialog<Tuple2<bool, AvesEntry?>>(
final existingCover = covers.of(filter);
final entryId = existingCover?.item1;
final customEntry = entryId != null ? context.read<CollectionSource>().visibleEntries.firstWhereOrNull((entry) => entry.id == entryId) : null;
final selectedCover = await showDialog<Tuple3<AvesEntry?, String?, Color?>>(
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<AvesColorsData>().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);
}

View file

@ -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<T extends CollectionFilter> extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<CollectionSource>(
builder: (context, source, child) {
switch (T) {
case AlbumFilter:
{
final album = (filter as AlbumFilter).album;
return StreamBuilder<AlbumSummaryInvalidatedEvent>(
stream: source.eventBus.on<AlbumSummaryInvalidatedEvent>().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<CountrySummaryInvalidatedEvent>(
stream: source.eventBus.on<CountrySummaryInvalidatedEvent>().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<TagSummaryInvalidatedEvent>(
stream: source.eventBus.on<TagSummaryInvalidatedEvent>().where((event) => event.tags == null || event.tags!.contains(tag)),
builder: (context, snapshot) => _buildChip(context, source),
);
}
default:
return const SizedBox();
}
},
return StreamBuilder<Set<CollectionFilter>?>(
stream: covers.entryChangeStream.where((event) => event == null || event.contains(filter)),
builder: (context, snapshot) => Consumer<CollectionSource>(
builder: (context, source, child) {
switch (T) {
case AlbumFilter:
{
final album = (filter as AlbumFilter).album;
return StreamBuilder<AlbumSummaryInvalidatedEvent>(
stream: source.eventBus.on<AlbumSummaryInvalidatedEvent>().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<CountrySummaryInvalidatedEvent>(
stream: source.eventBus.on<CountrySummaryInvalidatedEvent>().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<TagSummaryInvalidatedEvent>(
stream: source.eventBus.on<TagSummaryInvalidatedEvent>().where((event) => event.tags == null || event.tags!.contains(tag)),
builder: (context, snapshot) => _buildChip(context, source),
);
}
default:
return const SizedBox();
}
},
),
);
}

View file

@ -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<T extends CollectionFilter> extends StatelessWidget {
child: GestureAreaProtectorStack(
child: SafeArea(
bottom: false,
child: AnimatedBuilder(
animation: covers,
builder: (context, child) => FilterGrid<T>(
// 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<T>(
// 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,
),
),
),

View file

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

View file

@ -24,7 +24,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
final AvesEntry entry;
final CollectionLens? collection;
final StreamController<ActionEvent<EntryInfoAction>> _eventStreamController = StreamController<ActionEvent<EntryInfoAction>>.broadcast();
final StreamController<ActionEvent<EntryInfoAction>> _eventStreamController = StreamController.broadcast();
Stream<ActionEvent<EntryInfoAction>> get eventStream => _eventStreamController.stream;

View file

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

View file

@ -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"
]
}