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