#1045 stack RAW and JPEG with same file names

This commit is contained in:
Thibault Deckers 2024-06-21 23:26:00 +02:00
parent 44eecd2e55
commit d890d9d9ae
16 changed files with 125 additions and 48 deletions

View file

@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased] ## <a id="unreleased"></a>[Unreleased]
### Added
- Collection: stack RAW and JPEG with same file names
## <a id="v1.11.3"></a>[v1.11.3] - 2024-06-17 ## <a id="v1.11.3"></a>[v1.11.3] - 2024-06-17
### Added ### Added

View file

@ -44,7 +44,8 @@ class AvesEntry with AvesEntryBase {
AddressDetails? _addressDetails; AddressDetails? _addressDetails;
TrashDetails? trashDetails; TrashDetails? trashDetails;
List<AvesEntry>? burstEntries; // synthetic stack of related entries, e.g. burst shots or raw/developed pairs
List<AvesEntry>? stackedEntries;
@override @override
final AChangeNotifier visualChangeNotifier = AChangeNotifier(); final AChangeNotifier visualChangeNotifier = AChangeNotifier();
@ -69,7 +70,7 @@ class AvesEntry with AvesEntryBase {
required int? durationMillis, required int? durationMillis,
required this.trashed, required this.trashed,
required this.origin, required this.origin,
this.burstEntries, this.stackedEntries,
}) : id = id ?? 0 { }) : id = id ?? 0 {
if (kFlutterMemoryAllocationsEnabled) { if (kFlutterMemoryAllocationsEnabled) {
FlutterMemoryAllocations.instance.dispatchObjectCreated( FlutterMemoryAllocations.instance.dispatchObjectCreated(
@ -93,7 +94,7 @@ class AvesEntry with AvesEntryBase {
int? dateAddedSecs, int? dateAddedSecs,
int? dateModifiedSecs, int? dateModifiedSecs,
int? origin, int? origin,
List<AvesEntry>? burstEntries, List<AvesEntry>? stackedEntries,
}) { }) {
final copyEntryId = id ?? this.id; final copyEntryId = id ?? this.id;
final copied = AvesEntry( final copied = AvesEntry(
@ -114,7 +115,7 @@ class AvesEntry with AvesEntryBase {
durationMillis: durationMillis, durationMillis: durationMillis,
trashed: trashed, trashed: trashed,
origin: origin ?? this.origin, origin: origin ?? this.origin,
burstEntries: burstEntries ?? this.burstEntries, stackedEntries: stackedEntries ?? this.stackedEntries,
) )
..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId) ..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId)
..addressDetails = _addressDetails?.copyWith(id: copyEntryId) ..addressDetails = _addressDetails?.copyWith(id: copyEntryId)

View file

@ -7,9 +7,9 @@ import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
extension ExtraAvesEntryMultipage on AvesEntry { extension ExtraAvesEntryMultipage on AvesEntry {
bool get isMultiPage => isBurst || ((catalogMetadata?.isMultiPage ?? false) && (isMotionPhoto || !isHdr)); bool get isMultiPage => isStack || ((catalogMetadata?.isMultiPage ?? false) && (isMotionPhoto || !isHdr));
bool get isBurst => burstEntries?.isNotEmpty == true; bool get isStack => stackedEntries?.isNotEmpty == true;
bool get isMotionPhoto => catalogMetadata?.isMotionPhoto ?? false; bool get isMotionPhoto => catalogMetadata?.isMotionPhoto ?? false;
@ -19,10 +19,10 @@ extension ExtraAvesEntryMultipage on AvesEntry {
} }
Future<MultiPageInfo?> getMultiPageInfo() async { Future<MultiPageInfo?> getMultiPageInfo() async {
if (isBurst) { if (isStack) {
return MultiPageInfo( return MultiPageInfo(
mainEntry: this, mainEntry: this,
pages: burstEntries! pages: stackedEntries!
.mapIndexed((index, entry) => SinglePageInfo( .mapIndexed((index, entry) => SinglePageInfo(
index: index, index: index,
pageId: entry.id, pageId: entry.id,

View file

@ -32,10 +32,10 @@ class MultiPageInfo {
_pages.insert(0, firstPage.copyWith(isDefault: true)); _pages.insert(0, firstPage.copyWith(isDefault: true));
} }
final burstEntries = mainEntry.burstEntries; final stackedEntries = mainEntry.stackedEntries;
if (burstEntries != null) { if (stackedEntries != null) {
_pageEntries.addEntries(pages.map((pageInfo) { _pageEntries.addEntries(pages.map((pageInfo) {
final pageEntry = burstEntries.firstWhere((entry) => entry.uri == pageInfo.uri); final pageEntry = stackedEntries.firstWhere((entry) => entry.uri == pageInfo.uri);
return MapEntry(pageInfo, pageEntry); return MapEntry(pageInfo, pageEntry);
})); }));
} }

View file

@ -9,15 +9,18 @@ import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/filters/trash.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/events.dart'; import 'package:aves/model/source/events.dart';
import 'package:aves/model/source/location/location.dart'; import 'package:aves/model/source/location/location.dart';
import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/utils/collection_utils.dart'; import 'package:aves/utils/collection_utils.dart';
import 'package:aves_model/aves_model.dart'; import 'package:aves_model/aves_model.dart';
import 'package:aves_utils/aves_utils.dart'; import 'package:aves_utils/aves_utils.dart';
@ -34,7 +37,7 @@ class CollectionLens with ChangeNotifier {
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier(); final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier();
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
int? id; int? id;
bool listenToSource, groupBursts, fixedSort; bool listenToSource, stackBursts, stackDevelopedRaws, fixedSort;
List<AvesEntry>? fixedSelection; List<AvesEntry>? fixedSelection;
final Set<AvesEntry> _syntheticEntries = {}; final Set<AvesEntry> _syntheticEntries = {};
@ -47,7 +50,8 @@ class CollectionLens with ChangeNotifier {
Set<CollectionFilter?>? filters, Set<CollectionFilter?>? filters,
this.id, this.id,
this.listenToSource = true, this.listenToSource = true,
this.groupBursts = true, this.stackBursts = true,
this.stackDevelopedRaws = true,
this.fixedSort = false, this.fixedSort = false,
this.fixedSelection, this.fixedSelection,
}) : filters = (filters ?? {}).whereNotNull().toSet(), }) : filters = (filters ?? {}).whereNotNull().toSet(),
@ -192,30 +196,59 @@ class CollectionLens with ChangeNotifier {
_disposeSyntheticEntries(); _disposeSyntheticEntries();
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry)))); _filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry))));
if (groupBursts) { if (stackBursts) {
_groupBursts(); _stackBursts();
}
if (stackDevelopedRaws) {
_stackDevelopedRaws();
} }
} }
void _groupBursts() { void _stackBursts() {
final byBurstKey = groupBy<AvesEntry, String?>(_filteredSortedEntries, (entry) => entry.getBurstKey(burstPatterns)).whereNotNullKey(); final byBurstKey = groupBy<AvesEntry, String?>(_filteredSortedEntries, (entry) => entry.getBurstKey(burstPatterns)).whereNotNullKey();
byBurstKey.forEach((burstKey, entries) { byBurstKey.forEach((burstKey, entries) {
if (entries.length > 1) { if (entries.length > 1) {
entries.sort(AvesEntrySort.compareByName); entries.sort(AvesEntrySort.compareByName);
final mainEntry = entries.first; final mainEntry = entries.first;
final burstEntry = mainEntry.copyWith(burstEntries: entries); final stackEntry = mainEntry.copyWith(stackedEntries: entries);
_syntheticEntries.add(burstEntry); _syntheticEntries.add(stackEntry);
entries.skip(1).toList().forEach((subEntry) { entries.skip(1).forEach((subEntry) {
_filteredSortedEntries.remove(subEntry); _filteredSortedEntries.remove(subEntry);
}); });
final index = _filteredSortedEntries.indexOf(mainEntry); final index = _filteredSortedEntries.indexOf(mainEntry);
_filteredSortedEntries.removeAt(index); _filteredSortedEntries.removeAt(index);
_filteredSortedEntries.insert(index, burstEntry); _filteredSortedEntries.insert(index, stackEntry);
} }
}); });
} }
void _stackDevelopedRaws() {
final allRawEntries = _filteredSortedEntries.where(TypeFilter.raw.test).toSet();
if (allRawEntries.isNotEmpty) {
final allDevelopedEntries = _filteredSortedEntries.where(MimeFilter(MimeTypes.jpeg).test).toSet();
final rawEntriesByDir = groupBy<AvesEntry, String?>(allRawEntries, (entry) => entry.directory);
rawEntriesByDir.forEach((dir, dirRawEntries) {
if (dir != null) {
final dirDevelopedEntries = allDevelopedEntries.where((entry) => entry.directory == dir).toSet();
for (final rawEntry in dirRawEntries) {
final rawFilename = rawEntry.filenameWithoutExtension;
final developedEntry = dirDevelopedEntries.firstWhereOrNull((entry) => entry.filenameWithoutExtension == rawFilename);
if (developedEntry != null) {
final stackEntry = rawEntry.copyWith(stackedEntries: [rawEntry, developedEntry]);
_syntheticEntries.add(stackEntry);
_filteredSortedEntries.remove(developedEntry);
final index = _filteredSortedEntries.indexOf(rawEntry);
_filteredSortedEntries.removeAt(index);
_filteredSortedEntries.insert(0, stackEntry);
}
}
}
});
}
}
void _applySort() { void _applySort() {
if (fixedSort) return; if (fixedSort) return;
@ -322,23 +355,52 @@ class CollectionLens with ChangeNotifier {
} }
void _onEntryRemoved(Set<AvesEntry> entries) { void _onEntryRemoved(Set<AvesEntry> entries) {
if (groupBursts) { if (_syntheticEntries.isNotEmpty) {
// find impacted burst groups // find impacted stacks
final obsoleteBurstEntries = <AvesEntry>{}; final obsoleteStacks = <AvesEntry>{};
final burstKeys = entries.map((entry) => entry.getBurstKey(burstPatterns)).whereNotNull().toSet();
if (burstKeys.isNotEmpty) { void _replaceStack(AvesEntry stackEntry, AvesEntry entry) {
_filteredSortedEntries.where((entry) => entry.isBurst && burstKeys.contains(entry.getBurstKey(burstPatterns))).forEach((mainEntry) { obsoleteStacks.add(stackEntry);
final subEntries = mainEntry.burstEntries!; fixedSelection?.replace(stackEntry, entry);
_filteredSortedEntries.replace(stackEntry, entry);
_sortedEntries?.replace(stackEntry, entry);
sections.forEach((key, sectionEntries) => sectionEntries.replace(stackEntry, entry));
}
final stacks = _filteredSortedEntries.where((entry) => entry.isStack).toSet();
stacks.forEach((stackEntry) {
final subEntries = stackEntry.stackedEntries!;
if (subEntries.any(entries.contains)) {
final mainEntry = subEntries.first;
// remove the deleted sub-entries // remove the deleted sub-entries
subEntries.removeWhere(entries.contains); subEntries.removeWhere(entries.contains);
if (subEntries.isEmpty) {
// remove the burst entry itself switch (subEntries.length) {
obsoleteBurstEntries.add(mainEntry); case 0:
// remove the stack itself
obsoleteStacks.add(stackEntry);
break;
case 1:
// replace the stack by the last remaining sub-entry
_replaceStack(stackEntry, subEntries.first);
break;
default:
// keep the stack with the remaining sub-entries
if (!subEntries.contains(mainEntry)) {
// recreate the stack with the correct main entry
_replaceStack(stackEntry, subEntries.first.copyWith(stackedEntries: subEntries));
}
break;
} }
// TODO TLAD [burst] recreate the burst main entry if the first sub-entry got deleted }
}); });
entries.addAll(obsoleteBurstEntries);
} obsoleteStacks.forEach((stackEntry) {
_syntheticEntries.remove(stackEntry);
stackEntry.dispose();
});
entries.addAll(obsoleteStacks);
} }
// we should remove obsolete entries and sections // we should remove obsolete entries and sections

View file

@ -1,5 +1,15 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
extension ExtraList<E> on List<E> {
bool replace(E old, E newItem) {
final index = indexOf(old);
if (index == -1) return false;
this[index] = newItem;
return true;
}
}
extension ExtraMapNullableKey<K extends Object, V> on Map<K?, V> { extension ExtraMapNullableKey<K extends Object, V> on Map<K?, V> {
Map<K, V> whereNotNullKey() => <K, V>{for (var v in keys.whereNotNull()) v: this[v] as V}; Map<K, V> whereNotNullKey() => <K, V>{for (var v in keys.whereNotNull()) v: this[v] as V};
} }

View file

@ -444,7 +444,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
} }
Set<AvesEntry> _getExpandedSelectedItems(Selection<AvesEntry> selection) { Set<AvesEntry> _getExpandedSelectedItems(Selection<AvesEntry> selection) {
return selection.selectedItems.expand((entry) => entry.burstEntries ?? {entry}).toSet(); return selection.selectedItems.expand((entry) => entry.stackedEntries ?? {entry}).toSet();
} }
// key is expected by test driver (e.g. 'menu-configureView', 'menu-map') // key is expected by test driver (e.g. 'menu-configureView', 'menu-map')

View file

@ -237,7 +237,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
Set<AvesEntry> _getTargetItems(BuildContext context) { Set<AvesEntry> _getTargetItems(BuildContext context) {
final selection = context.read<Selection<AvesEntry>>(); final selection = context.read<Selection<AvesEntry>>();
final groupedEntries = (selection.isSelecting ? selection.selectedItems : context.read<CollectionLens>().sortedEntries); final groupedEntries = (selection.isSelecting ? selection.selectedItems : context.read<CollectionLens>().sortedEntries);
return groupedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet(); return groupedEntries.expand((entry) => entry.stackedEntries ?? {entry}).toSet();
} }
Future<void> _share(BuildContext context) async { Future<void> _share(BuildContext context) async {

View file

@ -80,7 +80,7 @@ class EntryListDetails extends StatelessWidget {
final date = entry.bestDate; final date = entry.bestDate;
final dateText = date != null ? formatDateTime(date, locale, use24hour) : AText.valueNotAvailable; final dateText = date != null ? formatDateTime(date, locale, use24hour) : AText.valueNotAvailable;
final size = entry.burstEntries?.map((v) => v.sizeBytes).sum ?? entry.sizeBytes; final size = entry.stackedEntries?.map((v) => v.sizeBytes).sum ?? entry.sizeBytes;
final sizeText = size != null ? formatFileSize(locale, size) : AText.valueNotAvailable; final sizeText = size != null ? formatFileSize(locale, size) : AText.valueNotAvailable;
return Wrap( return Wrap(

View file

@ -181,8 +181,8 @@ class MultiPageIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
String? text; String? text;
if (entry.isBurst) { if (entry.isStack) {
text = '${entry.burstEntries?.length}'; text = '${entry.stackedEntries?.length}';
} }
final child = OverlayIcon( final child = OverlayIcon(
icon: AIcons.multiPage, icon: AIcons.multiPage,

View file

@ -300,7 +300,7 @@ class _HomePageState extends State<HomePage> {
// if we group bursts, opening a burst sub-entry should: // if we group bursts, opening a burst sub-entry should:
// - identify and select the containing main entry, // - identify and select the containing main entry,
// - select the sub-entry in the Viewer page. // - select the sub-entry in the Viewer page.
groupBursts: false, stackBursts: false,
); );
final viewerEntryPath = viewerEntry.path; final viewerEntryPath = viewerEntry.path;
final collectionEntry = collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath); final collectionEntry = collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath);

View file

@ -163,7 +163,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
} }
AvesEntry _getTargetEntry(BuildContext context, EntryAction action) { AvesEntry _getTargetEntry(BuildContext context, EntryAction action) {
if (mainEntry.isMultiPage && (mainEntry.isBurst || EntryActions.pageActions.contains(action))) { if (mainEntry.isMultiPage && (mainEntry.isStack || EntryActions.pageActions.contains(action))) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry); final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) { if (multiPageController != null) {
final multiPageInfo = multiPageController.info; final multiPageInfo = multiPageController.info;

View file

@ -795,11 +795,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
if (collectionEntries.remove(removedEntry)) return; if (collectionEntries.remove(removedEntry)) return;
// remove from burst // remove from burst
final mainEntry = collectionEntries.firstWhereOrNull((entry) => entry.burstEntries?.contains(removedEntry) == true); final mainEntry = collectionEntries.firstWhereOrNull((entry) => entry.stackedEntries?.contains(removedEntry) == true);
if (mainEntry != null) { if (mainEntry != null) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry); final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) { if (multiPageController != null) {
mainEntry.burstEntries!.remove(removedEntry); mainEntry.stackedEntries!.remove(removedEntry);
multiPageController.reset(); multiPageController.reset();
} }
} }

View file

@ -85,7 +85,7 @@ class _InfoPageState extends State<InfoPage> {
); );
} }
return mainEntry.isBurst return mainEntry.isStack
? PageEntryBuilder( ? PageEntryBuilder(
multiPageController: context.read<MultiPageConductor>().getController(mainEntry), multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
builder: (pageEntry) => _buildContent(pageEntry: pageEntry), builder: (pageEntry) => _buildContent(pageEntry: pageEntry),

View file

@ -181,7 +181,7 @@ class _TvButtonRowContent extends StatelessWidget {
}) { }) {
switch (action) { switch (action) {
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
final favouriteTargetEntry = mainEntry.isBurst ? pageEntry : mainEntry; final favouriteTargetEntry = mainEntry.isStack ? pageEntry : mainEntry;
return FavouriteTogglerCaption( return FavouriteTogglerCaption(
entries: {favouriteTargetEntry}, entries: {favouriteTargetEntry},
enabled: enabled, enabled: enabled,
@ -236,7 +236,7 @@ class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
AvesEntry get pageEntry => widget.pageEntry; AvesEntry get pageEntry => widget.pageEntry;
AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry; AvesEntry get favouriteTargetEntry => mainEntry.isStack ? pageEntry : mainEntry;
static const double padding = ViewerButtonRowContent.padding; static const double padding = ViewerButtonRowContent.padding;
@ -487,7 +487,7 @@ class _ViewerButtonRowContentState extends State<ViewerButtonRowContent> {
onPressed: onPressed, onPressed: onPressed,
); );
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
final favouriteTargetEntry = mainEntry.isBurst ? pageEntry : mainEntry; final favouriteTargetEntry = mainEntry.isStack ? pageEntry : mainEntry;
child = FavouriteToggler( child = FavouriteToggler(
entries: {favouriteTargetEntry}, entries: {favouriteTargetEntry},
focusNode: focusNode, focusNode: focusNode,

View file

@ -71,7 +71,7 @@ class ViewStateConductor {
void reset(AvesEntry entry) { void reset(AvesEntry entry) {
final uris = <AvesEntry>{ final uris = <AvesEntry>{
entry, entry,
...?entry.burstEntries, ...?entry.stackedEntries,
}.map((v) => v.uri).toSet(); }.map((v) => v.uri).toSet();
final entryControllers = _controllers.where((v) => uris.contains(v.entry.uri)).toSet(); final entryControllers = _controllers.where((v) => uris.contains(v.entry.uri)).toSet();
entryControllers.forEach((controller) { entryControllers.forEach((controller) {