#1045 stack RAW and JPEG with same file names
This commit is contained in:
parent
44eecd2e55
commit
d890d9d9ae
16 changed files with 125 additions and 48 deletions
|
@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## <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
|
||||
|
||||
### Added
|
||||
|
|
|
@ -44,7 +44,8 @@ class AvesEntry with AvesEntryBase {
|
|||
AddressDetails? _addressDetails;
|
||||
TrashDetails? trashDetails;
|
||||
|
||||
List<AvesEntry>? burstEntries;
|
||||
// synthetic stack of related entries, e.g. burst shots or raw/developed pairs
|
||||
List<AvesEntry>? 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<AvesEntry>? burstEntries,
|
||||
List<AvesEntry>? 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)
|
||||
|
|
|
@ -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<MultiPageInfo?> getMultiPageInfo() async {
|
||||
if (isBurst) {
|
||||
if (isStack) {
|
||||
return MultiPageInfo(
|
||||
mainEntry: this,
|
||||
pages: burstEntries!
|
||||
pages: stackedEntries!
|
||||
.mapIndexed((index, entry) => SinglePageInfo(
|
||||
index: index,
|
||||
pageId: entry.id,
|
||||
|
|
|
@ -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);
|
||||
}));
|
||||
}
|
||||
|
|
|
@ -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<StreamSubscription> _subscriptions = [];
|
||||
int? id;
|
||||
bool listenToSource, groupBursts, fixedSort;
|
||||
bool listenToSource, stackBursts, stackDevelopedRaws, fixedSort;
|
||||
List<AvesEntry>? fixedSelection;
|
||||
|
||||
final Set<AvesEntry> _syntheticEntries = {};
|
||||
|
@ -47,7 +50,8 @@ class CollectionLens with ChangeNotifier {
|
|||
Set<CollectionFilter?>? 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<AvesEntry, String?>(_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<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() {
|
||||
if (fixedSort) return;
|
||||
|
||||
|
@ -322,23 +355,52 @@ class CollectionLens with ChangeNotifier {
|
|||
}
|
||||
|
||||
void _onEntryRemoved(Set<AvesEntry> entries) {
|
||||
if (groupBursts) {
|
||||
// find impacted burst groups
|
||||
final obsoleteBurstEntries = <AvesEntry>{};
|
||||
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 = <AvesEntry>{};
|
||||
|
||||
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
|
||||
|
|
|
@ -1,5 +1,15 @@
|
|||
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> {
|
||||
Map<K, V> whereNotNullKey() => <K, V>{for (var v in keys.whereNotNull()) v: this[v] as V};
|
||||
}
|
||||
|
|
|
@ -444,7 +444,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
}
|
||||
|
||||
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')
|
||||
|
|
|
@ -237,7 +237,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
Set<AvesEntry> _getTargetItems(BuildContext context) {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
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 {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -300,7 +300,7 @@ class _HomePageState extends State<HomePage> {
|
|||
// 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);
|
||||
|
|
|
@ -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<MultiPageConductor>().getController(mainEntry);
|
||||
if (multiPageController != null) {
|
||||
final multiPageInfo = multiPageController.info;
|
||||
|
|
|
@ -795,11 +795,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> 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<MultiPageConductor>().getController(mainEntry);
|
||||
if (multiPageController != null) {
|
||||
mainEntry.burstEntries!.remove(removedEntry);
|
||||
mainEntry.stackedEntries!.remove(removedEntry);
|
||||
multiPageController.reset();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -85,7 +85,7 @@ class _InfoPageState extends State<InfoPage> {
|
|||
);
|
||||
}
|
||||
|
||||
return mainEntry.isBurst
|
||||
return mainEntry.isStack
|
||||
? PageEntryBuilder(
|
||||
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
|
||||
builder: (pageEntry) => _buildContent(pageEntry: pageEntry),
|
||||
|
|
|
@ -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<ViewerButtonRowContent> {
|
|||
|
||||
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<ViewerButtonRowContent> {
|
|||
onPressed: onPressed,
|
||||
);
|
||||
case EntryAction.toggleFavourite:
|
||||
final favouriteTargetEntry = mainEntry.isBurst ? pageEntry : mainEntry;
|
||||
final favouriteTargetEntry = mainEntry.isStack ? pageEntry : mainEntry;
|
||||
child = FavouriteToggler(
|
||||
entries: {favouriteTargetEntry},
|
||||
focusNode: focusNode,
|
||||
|
|
|
@ -71,7 +71,7 @@ class ViewStateConductor {
|
|||
void reset(AvesEntry entry) {
|
||||
final uris = <AvesEntry>{
|
||||
entry,
|
||||
...?entry.burstEntries,
|
||||
...?entry.stackedEntries,
|
||||
}.map((v) => v.uri).toSet();
|
||||
final entryControllers = _controllers.where((v) => uris.contains(v.entry.uri)).toSet();
|
||||
entryControllers.forEach((controller) {
|
||||
|
|
Loading…
Reference in a new issue