From d890d9d9aee04381d29f2663d3d5f3b6a1507976 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 21 Jun 2024 23:26:00 +0200 Subject: [PATCH] #1045 stack RAW and JPEG with same file names --- CHANGELOG.md | 4 + lib/model/entry/entry.dart | 9 +- lib/model/entry/extensions/multipage.dart | 8 +- lib/model/multipage.dart | 6 +- lib/model/source/collection_lens.dart | 108 ++++++++++++++---- lib/utils/collection_utils.dart | 10 ++ lib/widgets/collection/app_bar.dart | 2 +- .../collection/entry_set_action_delegate.dart | 2 +- lib/widgets/collection/grid/list_details.dart | 2 +- lib/widgets/common/identity/aves_icons.dart | 4 +- lib/widgets/home_page.dart | 2 +- .../viewer/action/entry_action_delegate.dart | 2 +- lib/widgets/viewer/entry_viewer_stack.dart | 4 +- lib/widgets/viewer/info/info_page.dart | 2 +- .../viewer/overlay/viewer_buttons.dart | 6 +- lib/widgets/viewer/view/conductor.dart | 2 +- 16 files changed, 125 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb3f7929..6ab91cd80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Collection: stack RAW and JPEG with same file names + ## [v1.11.3] - 2024-06-17 ### Added diff --git a/lib/model/entry/entry.dart b/lib/model/entry/entry.dart index bdf810294..d7b91a58c 100644 --- a/lib/model/entry/entry.dart +++ b/lib/model/entry/entry.dart @@ -44,7 +44,8 @@ class AvesEntry with AvesEntryBase { AddressDetails? _addressDetails; TrashDetails? trashDetails; - List? burstEntries; + // synthetic stack of related entries, e.g. burst shots or raw/developed pairs + List? stackedEntries; @override final AChangeNotifier visualChangeNotifier = AChangeNotifier(); @@ -69,7 +70,7 @@ class AvesEntry with AvesEntryBase { required int? durationMillis, required this.trashed, required this.origin, - this.burstEntries, + this.stackedEntries, }) : id = id ?? 0 { if (kFlutterMemoryAllocationsEnabled) { FlutterMemoryAllocations.instance.dispatchObjectCreated( @@ -93,7 +94,7 @@ class AvesEntry with AvesEntryBase { int? dateAddedSecs, int? dateModifiedSecs, int? origin, - List? burstEntries, + List? stackedEntries, }) { final copyEntryId = id ?? this.id; final copied = AvesEntry( @@ -114,7 +115,7 @@ class AvesEntry with AvesEntryBase { durationMillis: durationMillis, trashed: trashed, origin: origin ?? this.origin, - burstEntries: burstEntries ?? this.burstEntries, + stackedEntries: stackedEntries ?? this.stackedEntries, ) ..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId) ..addressDetails = _addressDetails?.copyWith(id: copyEntryId) diff --git a/lib/model/entry/extensions/multipage.dart b/lib/model/entry/extensions/multipage.dart index a978ef0f6..6b32a8e8b 100644 --- a/lib/model/entry/extensions/multipage.dart +++ b/lib/model/entry/extensions/multipage.dart @@ -7,9 +7,9 @@ import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; 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; @@ -19,10 +19,10 @@ extension ExtraAvesEntryMultipage on AvesEntry { } Future getMultiPageInfo() async { - if (isBurst) { + if (isStack) { return MultiPageInfo( mainEntry: this, - pages: burstEntries! + pages: stackedEntries! .mapIndexed((index, entry) => SinglePageInfo( index: index, pageId: entry.id, diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart index c987965b9..b8dcbb0aa 100644 --- a/lib/model/multipage.dart +++ b/lib/model/multipage.dart @@ -32,10 +32,10 @@ class MultiPageInfo { _pages.insert(0, firstPage.copyWith(isDefault: true)); } - final burstEntries = mainEntry.burstEntries; - if (burstEntries != null) { + final stackedEntries = mainEntry.stackedEntries; + if (stackedEntries != null) { _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); })); } diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 747e3bd1e..5e2e7222b 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -9,15 +9,18 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/rating.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/source/collection_source.dart'; import 'package:aves/model/source/events.dart'; import 'package:aves/model/source/location/location.dart'; import 'package:aves/model/source/section_keys.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_model/aves_model.dart'; import 'package:aves_utils/aves_utils.dart'; @@ -34,7 +37,7 @@ class CollectionLens with ChangeNotifier { final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier(); final List _subscriptions = []; int? id; - bool listenToSource, groupBursts, fixedSort; + bool listenToSource, stackBursts, stackDevelopedRaws, fixedSort; List? fixedSelection; final Set _syntheticEntries = {}; @@ -47,7 +50,8 @@ class CollectionLens with ChangeNotifier { Set? filters, this.id, this.listenToSource = true, - this.groupBursts = true, + this.stackBursts = true, + this.stackDevelopedRaws = true, this.fixedSort = false, this.fixedSelection, }) : filters = (filters ?? {}).whereNotNull().toSet(), @@ -192,30 +196,59 @@ class CollectionLens with ChangeNotifier { _disposeSyntheticEntries(); _filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry)))); - if (groupBursts) { - _groupBursts(); + if (stackBursts) { + _stackBursts(); + } + if (stackDevelopedRaws) { + _stackDevelopedRaws(); } } - void _groupBursts() { + void _stackBursts() { final byBurstKey = groupBy(_filteredSortedEntries, (entry) => entry.getBurstKey(burstPatterns)).whereNotNullKey(); byBurstKey.forEach((burstKey, entries) { if (entries.length > 1) { entries.sort(AvesEntrySort.compareByName); final mainEntry = entries.first; - final burstEntry = mainEntry.copyWith(burstEntries: entries); - _syntheticEntries.add(burstEntry); + final stackEntry = mainEntry.copyWith(stackedEntries: entries); + _syntheticEntries.add(stackEntry); - entries.skip(1).toList().forEach((subEntry) { + entries.skip(1).forEach((subEntry) { _filteredSortedEntries.remove(subEntry); }); final index = _filteredSortedEntries.indexOf(mainEntry); _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(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() { if (fixedSort) return; @@ -322,23 +355,52 @@ class CollectionLens with ChangeNotifier { } void _onEntryRemoved(Set entries) { - if (groupBursts) { - // find impacted burst groups - final obsoleteBurstEntries = {}; - final burstKeys = entries.map((entry) => entry.getBurstKey(burstPatterns)).whereNotNull().toSet(); - if (burstKeys.isNotEmpty) { - _filteredSortedEntries.where((entry) => entry.isBurst && burstKeys.contains(entry.getBurstKey(burstPatterns))).forEach((mainEntry) { - final subEntries = mainEntry.burstEntries!; + if (_syntheticEntries.isNotEmpty) { + // find impacted stacks + final obsoleteStacks = {}; + + void _replaceStack(AvesEntry stackEntry, AvesEntry entry) { + obsoleteStacks.add(stackEntry); + 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 subEntries.removeWhere(entries.contains); - if (subEntries.isEmpty) { - // remove the burst entry itself - obsoleteBurstEntries.add(mainEntry); + + switch (subEntries.length) { + 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 diff --git a/lib/utils/collection_utils.dart b/lib/utils/collection_utils.dart index d464e1429..88c7b2ca1 100644 --- a/lib/utils/collection_utils.dart +++ b/lib/utils/collection_utils.dart @@ -1,5 +1,15 @@ import 'package:collection/collection.dart'; +extension ExtraList on List { + bool replace(E old, E newItem) { + final index = indexOf(old); + if (index == -1) return false; + + this[index] = newItem; + return true; + } +} + extension ExtraMapNullableKey on Map { Map whereNotNullKey() => {for (var v in keys.whereNotNull()) v: this[v] as V}; } diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 30b19b9d4..123db2277 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -444,7 +444,7 @@ class _CollectionAppBarState extends State with SingleTickerPr } Set _getExpandedSelectedItems(Selection 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') diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 23f444808..fe8e6e022 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -237,7 +237,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware Set _getTargetItems(BuildContext context) { final selection = context.read>(); final groupedEntries = (selection.isSelecting ? selection.selectedItems : context.read().sortedEntries); - return groupedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet(); + return groupedEntries.expand((entry) => entry.stackedEntries ?? {entry}).toSet(); } Future _share(BuildContext context) async { diff --git a/lib/widgets/collection/grid/list_details.dart b/lib/widgets/collection/grid/list_details.dart index 898cc9285..0c30a73e3 100644 --- a/lib/widgets/collection/grid/list_details.dart +++ b/lib/widgets/collection/grid/list_details.dart @@ -80,7 +80,7 @@ class EntryListDetails extends StatelessWidget { final date = entry.bestDate; 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; return Wrap( diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index 3b3584be7..b17ddb640 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -181,8 +181,8 @@ class MultiPageIcon extends StatelessWidget { @override Widget build(BuildContext context) { String? text; - if (entry.isBurst) { - text = '${entry.burstEntries?.length}'; + if (entry.isStack) { + text = '${entry.stackedEntries?.length}'; } final child = OverlayIcon( icon: AIcons.multiPage, diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 969aca967..a96fe888b 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -300,7 +300,7 @@ class _HomePageState extends State { // if we group bursts, opening a burst sub-entry should: // - identify and select the containing main entry, // - select the sub-entry in the Viewer page. - groupBursts: false, + stackBursts: false, ); final viewerEntryPath = viewerEntry.path; final collectionEntry = collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath); diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index b2f7d5a4f..ad9553df3 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -163,7 +163,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } 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().getController(mainEntry); if (multiPageController != null) { final multiPageInfo = multiPageController.info; diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 16188cf7d..a6bdfd4db 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -795,11 +795,11 @@ class _EntryViewerStackState extends State with EntryViewContr if (collectionEntries.remove(removedEntry)) return; // 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) { final multiPageController = context.read().getController(mainEntry); if (multiPageController != null) { - mainEntry.burstEntries!.remove(removedEntry); + mainEntry.stackedEntries!.remove(removedEntry); multiPageController.reset(); } } diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 85cb274c7..ca342a284 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -85,7 +85,7 @@ class _InfoPageState extends State { ); } - return mainEntry.isBurst + return mainEntry.isStack ? PageEntryBuilder( multiPageController: context.read().getController(mainEntry), builder: (pageEntry) => _buildContent(pageEntry: pageEntry), diff --git a/lib/widgets/viewer/overlay/viewer_buttons.dart b/lib/widgets/viewer/overlay/viewer_buttons.dart index 6b2e3a237..bcf6287d3 100644 --- a/lib/widgets/viewer/overlay/viewer_buttons.dart +++ b/lib/widgets/viewer/overlay/viewer_buttons.dart @@ -181,7 +181,7 @@ class _TvButtonRowContent extends StatelessWidget { }) { switch (action) { case EntryAction.toggleFavourite: - final favouriteTargetEntry = mainEntry.isBurst ? pageEntry : mainEntry; + final favouriteTargetEntry = mainEntry.isStack ? pageEntry : mainEntry; return FavouriteTogglerCaption( entries: {favouriteTargetEntry}, enabled: enabled, @@ -236,7 +236,7 @@ class _ViewerButtonRowContentState extends State { 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; @@ -487,7 +487,7 @@ class _ViewerButtonRowContentState extends State { onPressed: onPressed, ); case EntryAction.toggleFavourite: - final favouriteTargetEntry = mainEntry.isBurst ? pageEntry : mainEntry; + final favouriteTargetEntry = mainEntry.isStack ? pageEntry : mainEntry; child = FavouriteToggler( entries: {favouriteTargetEntry}, focusNode: focusNode, diff --git a/lib/widgets/viewer/view/conductor.dart b/lib/widgets/viewer/view/conductor.dart index 644c0d6c9..cae0f16cd 100644 --- a/lib/widgets/viewer/view/conductor.dart +++ b/lib/widgets/viewer/view/conductor.dart @@ -71,7 +71,7 @@ class ViewStateConductor { void reset(AvesEntry entry) { final uris = { entry, - ...?entry.burstEntries, + ...?entry.stackedEntries, }.map((v) => v.uri).toSet(); final entryControllers = _controllers.where((v) => uris.contains(v.entry.uri)).toSet(); entryControllers.forEach((controller) {