diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 1c0fe4a5f..d3a3c3550 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -4,6 +4,7 @@ import 'package:aves/geo/countries.dart'; import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata.dart'; +import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/video/metadata.dart'; import 'package:aves/ref/mime_types.dart'; @@ -39,6 +40,8 @@ class AvesEntry { CatalogMetadata? _catalogMetadata; AddressDetails? _addressDetails; + List? burstEntries; + final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); // TODO TLAD make it dynamic if it depends on OS/lib versions @@ -64,6 +67,7 @@ class AvesEntry { required int? dateModifiedSecs, required this.sourceDateTakenMillis, required int? durationMillis, + this.burstEntries, }) { this.path = path; this.sourceTitle = sourceTitle; @@ -80,6 +84,7 @@ class AvesEntry { String? path, int? contentId, int? dateModifiedSecs, + List? burstEntries, }) { final copyContentId = contentId ?? this.contentId; final copied = AvesEntry( @@ -96,6 +101,7 @@ class AvesEntry { dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs, sourceDateTakenMillis: sourceDateTakenMillis, durationMillis: durationMillis, + burstEntries: burstEntries ?? this.burstEntries, ) ..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId) ..addressDetails = _addressDetails?.copyWith(contentId: copyContentId); @@ -228,10 +234,6 @@ class AvesEntry { bool get is360 => _catalogMetadata?.is360 ?? false; - bool get isMultiPage => _catalogMetadata?.isMultiPage ?? false; - - bool get isMotionPhoto => isMultiPage && mimeType == MimeTypes.jpeg; - bool get canEdit => path != null; bool get canRotateAndFlip => canEdit && canEditExif; @@ -652,6 +654,51 @@ class AvesEntry { } } + // multipage + + static final _burstFilenamePattern = RegExp(r'^(\d{8}_\d{6})_(\d+)$'); + + bool get isMultiPage => (_catalogMetadata?.isMultiPage ?? false) || isBurst; + + bool get isBurst => burstEntries?.isNotEmpty == true; + + bool get isMotionPhoto => isMultiPage && !isBurst && mimeType == MimeTypes.jpeg; + + String? get burstKey { + if (filenameWithoutExtension != null) { + final match = _burstFilenamePattern.firstMatch(filenameWithoutExtension!); + if (match != null) { + return '$directory/${match.group(1)}'; + } + } + return null; + } + + Future getMultiPageInfo() async { + if (isBurst) { + return MultiPageInfo( + mainEntry: this, + pages: burstEntries! + .mapIndexed((index, entry) => SinglePageInfo( + index: index, + pageId: entry.contentId!, + isDefault: index == 0, + uri: entry.uri, + mimeType: entry.mimeType, + width: entry.width, + height: entry.height, + rotationDegrees: entry.rotationDegrees, + durationMillis: entry.durationMillis, + )) + .toList(), + ); + } else { + return await metadataService.getMultiPageInfo(this); + } + } + + // sort + // compare by: // 1) title ascending // 2) extension ascending diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart index c2478077d..50d60d1a0 100644 --- a/lib/model/multipage.dart +++ b/lib/model/multipage.dart @@ -22,6 +22,14 @@ class MultiPageInfo { final firstPage = _pages.removeAt(0); _pages.insert(0, firstPage.copyWith(isDefault: true)); } + + final burstEntries = mainEntry.burstEntries; + if (burstEntries != null) { + _pageEntries.addEntries(pages.map((pageInfo) { + final pageEntry = burstEntries.firstWhere((entry) => entry.uri == pageInfo.uri); + return MapEntry(pageInfo, pageEntry); + })); + } } } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 40504a6e0..7fea6d05b 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -212,9 +212,9 @@ class Settings extends ChangeNotifier { // collection - EntryGroupFactor get collectionGroupFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values); + EntryGroupFactor get collectionSectionFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values); - set collectionGroupFactor(EntryGroupFactor newValue) => setAndNotify(collectionGroupFactorKey, newValue.toString()); + set collectionSectionFactor(EntryGroupFactor newValue) => setAndNotify(collectionGroupFactorKey, newValue.toString()); EntrySortFactor get collectionSortFactor => getEnumOrDefault(collectionSortFactorKey, EntrySortFactor.date, EntrySortFactor.values); diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index b18292a5e..7ca8abac4 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -13,6 +13,7 @@ import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/tag.dart'; import 'package:aves/utils/change_notifier.dart'; +import 'package:aves/utils/collection_utils.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -21,9 +22,9 @@ import 'enums.dart'; class CollectionLens with ChangeNotifier { final CollectionSource source; final Set filters; - EntryGroupFactor groupFactor; + EntryGroupFactor sectionFactor; EntrySortFactor sortFactor; - final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortGroupChangeNotifier = AChangeNotifier(); + final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier(); final List _subscriptions = []; int? id; bool listenToSource; @@ -38,7 +39,7 @@ class CollectionLens with ChangeNotifier { this.id, this.listenToSource = true, }) : filters = (filters ?? {}).whereNotNull().toSet(), - groupFactor = settings.collectionGroupFactor, + sectionFactor = settings.collectionSectionFactor, sortFactor = settings.collectionSortFactor { id ??= hashCode; if (listenToSource) { @@ -73,7 +74,7 @@ class CollectionLens with ChangeNotifier { int get entryCount => _filteredSortedEntries.length; - // sorted as displayed to the user, i.e. sorted then grouped, not an absolute order on all entries + // sorted as displayed to the user, i.e. sorted then sectioned, not an absolute order on all entries List? _sortedEntries; List get sortedEntries { @@ -84,9 +85,9 @@ class CollectionLens with ChangeNotifier { bool get showHeaders { if (sortFactor == EntrySortFactor.size) return false; - if (sortFactor == EntrySortFactor.date && groupFactor == EntryGroupFactor.none) return false; + if (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.none) return false; - final albumSections = sortFactor == EntrySortFactor.name || (sortFactor == EntrySortFactor.date && groupFactor == EntryGroupFactor.album); + final albumSections = sortFactor == EntrySortFactor.name || (sortFactor == EntrySortFactor.date && sectionFactor == EntryGroupFactor.album); final filterByAlbum = filters.any((f) => f is AlbumFilter); if (albumSections && filterByAlbum) return false; @@ -113,9 +114,33 @@ class CollectionLens with ChangeNotifier { filterChangeNotifier.notifyListeners(); } + final bool groupBursts = true; + void _applyFilters() { final entries = source.visibleEntries; _filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry)))); + + if (groupBursts) { + _groupBursts(); + } + } + + void _groupBursts() { + final byBurstKey = groupBy(_filteredSortedEntries, (entry) => entry.burstKey).whereNotNullKey(); + byBurstKey.forEach((burstKey, entries) { + if (entries.length > 1) { + entries.sort(AvesEntry.compareByName); + final mainEntry = entries.first; + final burstEntry = mainEntry.copyWith(burstEntries: entries); + + entries.skip(1).toList().forEach((subEntry) { + _filteredSortedEntries.remove(subEntry); + }); + final index = _filteredSortedEntries.indexOf(mainEntry); + _filteredSortedEntries.removeAt(index); + _filteredSortedEntries.insert(index, burstEntry); + } + }); } void _applySort() { @@ -132,10 +157,10 @@ class CollectionLens with ChangeNotifier { } } - void _applyGroup() { + void _applySection() { switch (sortFactor) { case EntrySortFactor.date: - switch (groupFactor) { + switch (sectionFactor) { case EntryGroupFactor.album: sections = groupBy(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); break; @@ -168,11 +193,11 @@ class CollectionLens with ChangeNotifier { } // metadata change should also trigger a full refresh - // as dates impact sorting and grouping + // as dates impact sorting and sectioning void _refresh() { _applyFilters(); _applySort(); - _applyGroup(); + _applySection(); } void _onFavouritesChanged() { @@ -183,21 +208,21 @@ class CollectionLens with ChangeNotifier { void _onSettingsChanged() { final newSortFactor = settings.collectionSortFactor; - final newGroupFactor = settings.collectionGroupFactor; + final newSectionFactor = settings.collectionSectionFactor; final needSort = sortFactor != newSortFactor; - final needGroup = needSort || groupFactor != newGroupFactor; + final needSection = needSort || sectionFactor != newSectionFactor; if (needSort) { sortFactor = newSortFactor; _applySort(); } - if (needGroup) { - groupFactor = newGroupFactor; - _applyGroup(); + if (needSection) { + sectionFactor = newSectionFactor; + _applySection(); } - if (needSort || needGroup) { - sortGroupChangeNotifier.notifyListeners(); + if (needSort || needSection) { + sortSectionChangeNotifier.notifyListeners(); } } @@ -206,8 +231,27 @@ class CollectionLens with ChangeNotifier { } void onEntryRemoved(Set entries) { + if (groupBursts) { + // find impacted burst groups + final obsoleteBurstEntries = {}; + final burstKeys = entries.map((entry) => entry.burstKey).whereNotNull().toSet(); + if (burstKeys.isNotEmpty) { + _filteredSortedEntries.where((entry) => entry.isBurst && burstKeys.contains(entry.burstKey)).forEach((mainEntry) { + final subEntries = mainEntry.burstEntries!; + // remove the deleted sub-entries + subEntries.removeWhere(entries.contains); + if (subEntries.isEmpty) { + // remove the burst entry itself + obsoleteBurstEntries.add(mainEntry); + } + // TODO TLAD [burst] recreate the burst main entry if the first sub-entry got deleted + }); + entries.addAll(obsoleteBurstEntries); + } + } + // we should remove obsolete entries and sections - // but do not apply sort/group + // but do not apply sort/section // as section order change would surprise the user while browsing _filteredSortedEntries.removeWhere(entries.contains); _sortedEntries?.removeWhere(entries.contains); diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index bb77482dc..e42fdbf64 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -105,7 +105,7 @@ class PlatformMetadataService implements MetadataService { 'uri': entry.uri, 'sizeBytes': entry.sizeBytes, }); - final pageMaps = (result as List).cast(); + final pageMaps = ((result as List?) ?? []).cast(); if (entry.isMotionPhoto && pageMaps.isNotEmpty) { final imagePage = pageMaps[0]; imagePage['width'] = entry.width; diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index c55c0f7e8..3caadebec 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -302,7 +302,7 @@ class _CollectionAppBarState extends State with SingleTickerPr final value = await showDialog( context: context, builder: (context) => AvesSelectionDialog( - initialValue: settings.collectionGroupFactor, + initialValue: settings.collectionSectionFactor, options: { EntryGroupFactor.album: context.l10n.collectionGroupAlbum, EntryGroupFactor.month: context.l10n.collectionGroupMonth, @@ -315,7 +315,7 @@ class _CollectionAppBarState extends State with SingleTickerPr // wait for the dialog to hide as applying the change may block the UI await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); if (value != null) { - settings.collectionGroupFactor = value; + settings.collectionSectionFactor = value; } break; case CollectionAction.sort: diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 2131ef70f..8a66526f6 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -267,13 +267,13 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> { void _registerWidget(_CollectionScrollView widget) { widget.collection.filterChangeNotifier.addListener(_scrollToTop); - widget.collection.sortGroupChangeNotifier.addListener(_scrollToTop); + widget.collection.sortSectionChangeNotifier.addListener(_scrollToTop); widget.scrollController.addListener(_onScrollChange); } void _unregisterWidget(_CollectionScrollView widget) { widget.collection.filterChangeNotifier.removeListener(_scrollToTop); - widget.collection.sortGroupChangeNotifier.removeListener(_scrollToTop); + widget.collection.sortSectionChangeNotifier.removeListener(_scrollToTop); widget.scrollController.removeListener(_onScrollChange); } diff --git a/lib/widgets/collection/draggable_thumb_label.dart b/lib/widgets/collection/draggable_thumb_label.dart index 370cc4d01..089ddf604 100644 --- a/lib/widgets/collection/draggable_thumb_label.dart +++ b/lib/widgets/collection/draggable_thumb_label.dart @@ -25,7 +25,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget { lineBuilder: (context, entry) { switch (collection.sortFactor) { case EntrySortFactor.date: - switch (collection.groupFactor) { + switch (collection.sectionFactor) { case EntryGroupFactor.album: return [ DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate), diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index eb6ab71e3..de3a99bb1 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -8,6 +8,7 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/services.dart'; @@ -33,10 +34,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware _showDeleteDialog(context); break; case EntryAction.share: - final selection = context.read>().selection; - AndroidAppService.shareEntries(selection).then((success) { - if (!success) showNoMatchingAppDialog(context); - }); + _share(context); break; default: break; @@ -59,18 +57,31 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware } } - void _refreshMetadata(BuildContext context) { - final collection = context.read(); + Set _getExpandedSelectedItems(Selection selection) { + return selection.selection.expand((entry) => entry.burstEntries ?? {entry}).toSet(); + } + + void _share(BuildContext context) { final selection = context.read>(); - collection.source.refreshMetadata(selection.selection); + final selectedItems = _getExpandedSelectedItems(selection); + AndroidAppService.shareEntries(selectedItems).then((success) { + if (!success) showNoMatchingAppDialog(context); + }); + } + + void _refreshMetadata(BuildContext context) { + final source = context.read(); + final selection = context.read>(); + final selectedItems = _getExpandedSelectedItems(selection); + + source.refreshMetadata(selectedItems); selection.browse(); } Future _moveSelection(BuildContext context, {required MoveType moveType}) async { - final collection = context.read(); - final source = collection.source; + final source = context.read(); final selection = context.read>(); - final selectedItems = selection.selection; + final selectedItems = _getExpandedSelectedItems(selection); final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); if (moveType == MoveType.move) { @@ -144,6 +155,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware label: context.l10n.showButtonLabel, onPressed: () async { final highlightInfo = context.read(); + final collection = context.read(); var targetCollection = collection; if (collection.filters.any((f) => f is AlbumFilter)) { final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum)); @@ -179,10 +191,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware } Future _showDeleteDialog(BuildContext context) async { - final collection = context.read(); - final source = collection.source; + final source = context.read(); final selection = context.read>(); - final selectedItems = selection.selection; + final selectedItems = _getExpandedSelectedItems(selection); final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); final todoCount = selectedItems.length; diff --git a/lib/widgets/collection/grid/headers/any.dart b/lib/widgets/collection/grid/headers/any.dart index 0253c4244..d2654a8db 100644 --- a/lib/widgets/collection/grid/headers/any.dart +++ b/lib/widgets/collection/grid/headers/any.dart @@ -36,7 +36,7 @@ class CollectionSectionHeader extends StatelessWidget { Widget? _buildHeader(BuildContext context) { switch (collection.sortFactor) { case EntrySortFactor.date: - switch (collection.groupFactor) { + switch (collection.sectionFactor) { case EntryGroupFactor.album: return _buildAlbumHeader(context); case EntryGroupFactor.month: diff --git a/lib/widgets/collection/grid/thumbnail.dart b/lib/widgets/collection/grid/thumbnail.dart index e9345cf59..28b24b3f9 100644 --- a/lib/widgets/collection/grid/thumbnail.dart +++ b/lib/widgets/collection/grid/thumbnail.dart @@ -76,7 +76,7 @@ class InteractiveThumbnail extends StatelessWidget { id: collection.id, listenToSource: false, ); - assert(viewerCollection.sortedEntries.contains(entry)); + assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId)); return EntryViewerPage( collection: viewerCollection, initialEntry: entry, diff --git a/lib/widgets/collection/thumbnail/decorated.dart b/lib/widgets/collection/thumbnail/decorated.dart index 65e40e4a5..af7da2574 100644 --- a/lib/widgets/collection/thumbnail/decorated.dart +++ b/lib/widgets/collection/thumbnail/decorated.dart @@ -11,7 +11,7 @@ class DecoratedThumbnail extends StatelessWidget { final double tileExtent; final CollectionLens? collection; final ValueNotifier? cancellableNotifier; - final bool selectable, highlightable; + final bool selectable, highlightable, hero; static final Color borderColor = Colors.grey.shade700; static final double borderWidth = AvesBorder.borderWidth; @@ -24,6 +24,7 @@ class DecoratedThumbnail extends StatelessWidget { this.cancellableNotifier, this.selectable = true, this.highlightable = true, + this.hero = true, }) : super(key: key); @override @@ -33,7 +34,7 @@ class DecoratedThumbnail extends StatelessWidget { // hero tag should include a collection identifier, so that it animates // between different views of the entry in the same collection (e.g. thumbnails <-> viewer) // but not between different collection instances, even with the same attributes (e.g. reloading collection page via drawer) - final heroTag = hashValues(collection?.id, entry); + final heroTag = hero ? hashValues(collection?.id, entry.uri) : null; final isSvg = entry.isSvg; Widget child = ThumbnailImage( entry: entry, diff --git a/lib/widgets/collection/thumbnail/overlay.dart b/lib/widgets/collection/thumbnail/overlay.dart index 662b24f76..ee302191f 100644 --- a/lib/widgets/collection/thumbnail/overlay.dart +++ b/lib/widgets/collection/thumbnail/overlay.dart @@ -28,10 +28,10 @@ class ThumbnailEntryOverlay extends StatelessWidget { const AnimatedImageIcon() else ...[ if (entry.isRaw && context.select((t) => t.showRaw)) const RawIcon(), - if (entry.isMultiPage) MultiPageIcon(entry: entry), if (entry.isGeotiff) const GeotiffIcon(), if (entry.is360) const SphericalImageIcon(), - ] + ], + if (entry.isMultiPage) MultiPageIcon(entry: entry), ]; if (children.isEmpty) return const SizedBox.shrink(); if (children.length == 1) return children.first; diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index fea89f1ee..b05dcaf87 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -112,10 +112,21 @@ class MultiPageIcon extends StatelessWidget { @override Widget build(BuildContext context) { + IconData icon; + String? text; + if (entry.isMotionPhoto) { + icon = AIcons.motionPhoto; + } else { + if(entry.isBurst) { + text = '${entry.burstEntries?.length}'; + } + icon = AIcons.multiPage; + } return OverlayIcon( - icon: entry.isMotionPhoto ? AIcons.motionPhoto : AIcons.multiPage, + icon: icon, size: context.select((t) => t.iconSize), iconScale: .8, + text: text, ); } } diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index 024d3129c..b48570386 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -28,10 +28,9 @@ class StatsPage extends StatelessWidget { final CollectionSource source; final CollectionLens? parentCollection; + late final Set entries; final Map entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {}; - Set get entries => parentCollection?.sortedEntries.toSet() ?? source.visibleEntries; - static const mimeDonutMinWidth = 124.0; StatsPage({ @@ -39,6 +38,7 @@ class StatsPage extends StatelessWidget { required this.source, this.parentCollection, }) : super(key: key) { + entries = parentCollection?.sortedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet() ?? source.visibleEntries; entries.forEach((entry) { if (entry.hasAddress) { final address = entry.addressDetails!; diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index 36f034436..c981424a3 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -182,7 +182,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix final selection = {}; if (entry.isMultiPage) { - final multiPageInfo = await metadataService.getMultiPageInfo(entry); + final multiPageInfo = await entry.getMultiPageInfo(); if (multiPageInfo != null) { if (entry.isMotionPhoto) { await multiPageInfo.extractMotionPhotoVideo(); diff --git a/lib/widgets/viewer/entry_horizontal_pager.dart b/lib/widgets/viewer/entry_horizontal_pager.dart index 52a5c9be1..9bb81bd86 100644 --- a/lib/widgets/viewer/entry_horizontal_pager.dart +++ b/lib/widgets/viewer/entry_horizontal_pager.dart @@ -1,9 +1,9 @@ import 'package:aves/model/entry.dart'; -import 'package:aves/model/multipage.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart'; import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; +import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:aves/widgets/viewer/visual/entry_page_view.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -12,7 +12,7 @@ class MultiEntryScroller extends StatefulWidget { final CollectionLens collection; final PageController pageController; final ValueChanged onPageChanged; - final void Function(String uri) onViewDisposed; + final void Function(AvesEntry mainEntry, AvesEntry? pageEntry) onViewDisposed; const MultiEntryScroller({ Key? key, @@ -44,27 +44,14 @@ class _MultiEntryScrollerState extends State with AutomaticK physics: const MagnifierScrollerPhysics(parent: BouncingScrollPhysics()), onPageChanged: widget.onPageChanged, itemBuilder: (context, index) { - final entry = entries[index]; + final mainEntry = entries[index]; - Widget? child; - if (entry.isMultiPage) { - final multiPageController = context.read().getController(entry); - if (multiPageController != null) { - child = StreamBuilder( - stream: multiPageController.infoStream, - builder: (context, snapshot) { - final multiPageInfo = multiPageController.info; - return ValueListenableBuilder( - valueListenable: multiPageController.pageNotifier, - builder: (context, page, child) { - return _buildViewer(entry, pageEntry: multiPageInfo?.getPageEntryByIndex(page)); - }, - ); - }, - ); - } - } - child ??= _buildViewer(entry); + var child = mainEntry.isMultiPage + ? PageEntryBuilder( + multiPageController: context.read().getController(mainEntry), + builder: (pageEntry) => _buildViewer(mainEntry, pageEntry: pageEntry), + ) + : _buildViewer(mainEntry); child = AnimatedBuilder( animation: pageController, @@ -93,17 +80,11 @@ class _MultiEntryScrollerState extends State with AutomaticK } Widget _buildViewer(AvesEntry mainEntry, {AvesEntry? pageEntry}) { - return Selector( - selector: (c, mq) => mq.size, - builder: (c, mqSize, child) { - return EntryPageView( - key: const Key('imageview'), - mainEntry: mainEntry, - pageEntry: pageEntry ?? mainEntry, - viewportSize: mqSize, - onDisposed: () => widget.onViewDisposed(mainEntry.uri), - ); - }, + return EntryPageView( + key: const Key('imageview'), + mainEntry: mainEntry, + pageEntry: pageEntry ?? mainEntry, + onDisposed: () => widget.onViewDisposed(mainEntry, pageEntry), ); } @@ -130,25 +111,12 @@ class _SingleEntryScrollerState extends State with Automati Widget build(BuildContext context) { super.build(context); - Widget? child; - if (mainEntry.isMultiPage) { - final multiPageController = context.read().getController(mainEntry); - if (multiPageController != null) { - child = StreamBuilder( - stream: multiPageController.infoStream, - builder: (context, snapshot) { - final multiPageInfo = multiPageController.info; - return ValueListenableBuilder( - valueListenable: multiPageController.pageNotifier, - builder: (context, page, child) { - return _buildViewer(pageEntry: multiPageInfo?.getPageEntryByIndex(page)); - }, - ); - }, - ); - } - } - child ??= _buildViewer(); + var child = mainEntry.isMultiPage + ? PageEntryBuilder( + multiPageController: context.read().getController(mainEntry), + builder: (pageEntry) => _buildViewer(pageEntry: pageEntry), + ) + : _buildViewer(); return MagnifierGestureDetectorScope( axis: const [Axis.vertical], @@ -157,15 +125,9 @@ class _SingleEntryScrollerState extends State with Automati } Widget _buildViewer({AvesEntry? pageEntry}) { - return Selector( - selector: (c, mq) => mq.size, - builder: (c, mqSize, child) { - return EntryPageView( - mainEntry: mainEntry, - pageEntry: pageEntry ?? mainEntry, - viewportSize: mqSize, - ); - }, + return EntryPageView( + mainEntry: mainEntry, + pageEntry: pageEntry ?? mainEntry, ); } diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index c278ad88a..438e503de 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -18,7 +18,7 @@ class ViewerVerticalPageView extends StatefulWidget { final PageController horizontalPager, verticalPager; final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; final VoidCallback onImagePageRequested; - final void Function(String uri) onViewDisposed; + final void Function(AvesEntry mainEntry, AvesEntry? pageEntry) onViewDisposed; const ViewerVerticalPageView({ Key? key, diff --git a/lib/widgets/viewer/entry_viewer_page.dart b/lib/widgets/viewer/entry_viewer_page.dart index 4c37a6012..523cfec0b 100644 --- a/lib/widgets/viewer/entry_viewer_page.dart +++ b/lib/widgets/viewer/entry_viewer_page.dart @@ -4,6 +4,7 @@ import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/viewer/entry_viewer_stack.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/video/conductor.dart'; +import 'package:aves/widgets/viewer/visual/conductor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -23,15 +24,13 @@ class EntryViewerPage extends StatelessWidget { Widget build(BuildContext context) { return MediaQueryDataProvider( child: Scaffold( - body: Provider( - create: (context) => VideoConductor(), - dispose: (context, value) => value.dispose(), - child: Provider( - create: (context) => MultiPageConductor(), - dispose: (context, value) => value.dispose(), - child: EntryViewerStack( - collection: collection, - initialEntry: initialEntry, + body: ViewStateConductorProvider( + child: VideoConductorProvider( + child: MultiPageConductorProvider( + child: EntryViewerStack( + collection: collection, + initialEntry: initialEntry, + ), ), ), ), @@ -41,3 +40,61 @@ class EntryViewerPage extends StatelessWidget { ); } } + +class ViewStateConductorProvider extends StatelessWidget { + final Widget? child; + + const ViewStateConductorProvider({ + Key? key, + this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ProxyProvider( + create: (context) => ViewStateConductor(), + update: (context, mq, value) { + value!.viewportSize = mq.size; + return value; + }, + dispose: (context, value) => value.dispose(), + child: child, + ); + } +} + +class VideoConductorProvider extends StatelessWidget { + final Widget? child; + + const VideoConductorProvider({ + Key? key, + this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Provider( + create: (context) => VideoConductor(), + dispose: (context, value) => value.dispose(), + child: child, + ); + } +} + +class MultiPageConductorProvider extends StatelessWidget { + final Widget? child; + + const MultiPageConductorProvider({ + Key? key, + this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Provider( + create: (context) => MultiPageConductor(), + dispose: (context, value) => value.dispose(), + child: child, + ); + } +} diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index eea7ee2ca..ece1e739d 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; -import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -24,17 +23,17 @@ import 'package:aves/widgets/viewer/overlay/bottom/panorama.dart'; import 'package:aves/widgets/viewer/overlay/bottom/video.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/overlay/top.dart'; +import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:aves/widgets/viewer/video/conductor.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:aves/widgets/viewer/video_action_delegate.dart'; -import 'package:aves/widgets/viewer/visual/state.dart'; +import 'package:aves/widgets/viewer/visual/conductor.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:tuple/tuple.dart'; class EntryViewerStack extends StatefulWidget { final CollectionLens? collection; @@ -62,7 +61,6 @@ class _EntryViewerStackState extends State with FeedbackMixin, late Animation _bottomOverlayOffset; EdgeInsets? _frozenViewInsets, _frozenViewPadding; late VideoActionDelegate _videoActionDelegate; - final List>> _viewStateNotifiers = []; final Map Function()> _multiPageControllerPageListeners = {}; final ValueNotifier _heroInfoNotifier = ValueNotifier(null); bool _isEntryTracked = true; @@ -90,7 +88,11 @@ class _EntryViewerStackState extends State with FeedbackMixin, } // make sure initial entry is actually among the filtered collection entries - final entry = entries.contains(widget.initialEntry) ? widget.initialEntry : entries.firstOrNull; + // `initialEntry` may be a dynamic burst entry from another collection lens + // so it is, strictly speaking, not contained in the lens used by the viewer, + // but it can be found by content ID + final initialEntry = widget.initialEntry; + final entry = entries.firstWhereOrNull((v) => v.contentId == initialEntry.contentId) ?? entries.firstOrNull; // opening hero, with viewer as target _heroInfoNotifier.value = HeroInfo(collection?.id, entry); _entryNotifier.value = entry; @@ -169,6 +171,7 @@ class _EntryViewerStackState extends State with FeedbackMixin, @override Widget build(BuildContext context) { + final viewStateConductor = context.read(); return WillPopScope( onWillPop: () { if (_currentVerticalPage.value == infoPage) { @@ -186,8 +189,6 @@ class _EntryViewerStackState extends State with FeedbackMixin, onNotification: (dynamic notification) { if (notification is FilterSelectedNotification) { _goToCollection(notification.filter); - } else if (notification is ViewStateNotification) { - _updateViewState(notification.uri, notification.viewState); } else if (notification is EntryDeletedNotification) { _onEntryDeleted(context, notification.entry); } @@ -208,7 +209,7 @@ class _EntryViewerStackState extends State with FeedbackMixin, onVerticalPageChanged: _onVerticalPageChanged, onHorizontalPageChanged: _onHorizontalPageChanged, onImagePageRequested: () => _goToVerticalPage(imagePage), - onViewDisposed: (uri) => _updateViewState(uri, null), + onViewDisposed: (mainEntry, pageEntry) => viewStateConductor.reset(pageEntry ?? mainEntry), ), _buildTopOverlay(), _buildBottomOverlay(), @@ -221,23 +222,14 @@ class _EntryViewerStackState extends State with FeedbackMixin, ); } - void _updateViewState(String uri, ViewState? viewState) { - final viewStateNotifier = _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == uri)?.item2; - viewStateNotifier?.value = viewState ?? ViewState.zero; - } - Widget _buildTopOverlay() { Widget child = ValueListenableBuilder( valueListenable: _entryNotifier, builder: (context, mainEntry, child) { if (mainEntry == null) return const SizedBox.shrink(); - return NotificationListener( - onNotification: (notification) { - _goToVerticalPage(infoPage); - return true; - }, - child: EmbeddedDataOpener( + Widget _buildContent({AvesEntry? pageEntry}) { + return EmbeddedDataOpener( entry: mainEntry, child: ViewerTopOverlay( mainEntry: mainEntry, @@ -245,9 +237,21 @@ class _EntryViewerStackState extends State with FeedbackMixin, canToggleFavourite: hasCollection, viewInsets: _frozenViewInsets, viewPadding: _frozenViewPadding, - viewStateNotifier: _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2, ), - ), + ); + } + + return NotificationListener( + onNotification: (notification) { + _goToVerticalPage(infoPage); + return true; + }, + child: mainEntry.isMultiPage + ? PageEntryBuilder( + multiPageController: context.read().getController(mainEntry), + builder: (pageEntry) => _buildContent(pageEntry: pageEntry), + ) + : _buildContent(), ); }, ); @@ -282,14 +286,17 @@ class _EntryViewerStackState extends State with FeedbackMixin, valueListenable: _entryNotifier, builder: (context, mainEntry, child) { if (mainEntry == null) return const SizedBox.shrink(); + final multiPageController = mainEntry.isMultiPage ? context.read().getController(mainEntry) : null; - Widget? _buildExtraBottomOverlay(AvesEntry pageEntry) { + Widget? _buildExtraBottomOverlay({AvesEntry? pageEntry}) { + final targetEntry = pageEntry ?? mainEntry; + Widget? child; // a 360 video is both a video and a panorama but only the video controls are displayed - if (pageEntry.isVideo) { - return Selector( - selector: (context, vc) => vc.getController(pageEntry), + if (targetEntry.isVideo) { + child = Selector( + selector: (context, vc) => vc.getController(targetEntry), builder: (context, videoController, child) => VideoControlOverlay( - entry: pageEntry, + entry: targetEntry, controller: videoController, scale: _bottomOverlayScale, onActionSelected: (action) { @@ -299,40 +306,31 @@ class _EntryViewerStackState extends State with FeedbackMixin, }, ), ); - } else if (pageEntry.is360) { - return PanoramaOverlay( - entry: pageEntry, + } else if (targetEntry.is360) { + child = PanoramaOverlay( + entry: targetEntry, scale: _bottomOverlayScale, ); } - return null; + return child != null + ? ExtraBottomOverlay( + viewInsets: _frozenViewInsets, + viewPadding: _frozenViewPadding, + child: child, + ) + : null; } - final multiPageController = mainEntry.isMultiPage ? context.read().getController(mainEntry) : null; - final extraBottomOverlay = multiPageController != null - ? StreamBuilder( - stream: multiPageController.infoStream, - builder: (context, snapshot) { - final multiPageInfo = multiPageController.info; - if (multiPageInfo == null) return const SizedBox.shrink(); - return ValueListenableBuilder( - valueListenable: multiPageController.pageNotifier, - builder: (context, page, child) { - final pageEntry = multiPageInfo.getPageEntryByIndex(page); - return _buildExtraBottomOverlay(pageEntry) ?? const SizedBox(); - }, - ); - }) - : _buildExtraBottomOverlay(mainEntry); + final extraBottomOverlay = mainEntry.isMultiPage + ? PageEntryBuilder( + multiPageController: multiPageController, + builder: (pageEntry) => _buildExtraBottomOverlay(pageEntry: pageEntry) ?? const SizedBox(), + ) + : _buildExtraBottomOverlay(); return Column( children: [ - if (extraBottomOverlay != null) - ExtraBottomOverlay( - viewInsets: _frozenViewInsets, - viewPadding: _frozenViewPadding, - child: extraBottomOverlay, - ), + if (extraBottomOverlay != null) extraBottomOverlay, SlideTransition( position: _bottomOverlayOffset, child: ViewerBottomOverlay( @@ -564,7 +562,6 @@ class _EntryViewerStackState extends State with FeedbackMixin, Future _initEntryControllers(AvesEntry? entry) async { if (entry == null) return; - _initViewStateController(entry); if (entry.isVideo) { await _initVideoController(entry); } @@ -581,20 +578,6 @@ class _EntryViewerStackState extends State with FeedbackMixin, } } - void _initViewStateController(AvesEntry entry) { - final uri = entry.uri; - var controller = _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == uri); - if (controller != null) { - _viewStateNotifiers.remove(controller); - } else { - controller = Tuple2(uri, ValueNotifier(ViewState.zero)); - } - _viewStateNotifiers.insert(0, controller); - while (_viewStateNotifiers.length > 3) { - _viewStateNotifiers.removeLast().item2.dispose(); - } - } - Future _initVideoController(AvesEntry entry) async { final controller = context.read().getOrCreateController(entry); setState(() {}); diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 0e6eda22f..28142bd06 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -10,6 +10,8 @@ import 'package:aves/widgets/viewer/info/info_app_bar.dart'; import 'package:aves/widgets/viewer/info/location_section.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:aves/widgets/viewer/info/notifications.dart'; +import 'package:aves/widgets/viewer/multipage/conductor.dart'; +import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -33,9 +35,7 @@ class _InfoPageState extends State { final ScrollController _scrollController = ScrollController(); bool _scrollStartFromTop = false; - CollectionLens? get collection => widget.collection; - - AvesEntry? get entry => widget.entryNotifier.value; + static const splitScreenWidthThreshold = 600; @override Widget build(BuildContext context) { @@ -51,20 +51,31 @@ class _InfoPageState extends State { builder: (c, mqWidth, child) { return ValueListenableBuilder( valueListenable: widget.entryNotifier, - builder: (context, entry, child) { - return entry != null - ? EmbeddedDataOpener( - entry: entry, - child: _InfoPageContent( - collection: collection, - entry: entry, - isScrollingNotifier: widget.isScrollingNotifier, - scrollController: _scrollController, - split: mqWidth > 600, - goToViewer: _goToViewer, - ), - ) - : const SizedBox.shrink(); + builder: (context, mainEntry, child) { + if (mainEntry != null) { + Widget _buildContent({AvesEntry? pageEntry}) { + final targetEntry = pageEntry ?? mainEntry; + return EmbeddedDataOpener( + entry: targetEntry, + child: _InfoPageContent( + collection: widget.collection, + entry: targetEntry, + isScrollingNotifier: widget.isScrollingNotifier, + scrollController: _scrollController, + split: mqWidth > splitScreenWidthThreshold, + goToViewer: _goToViewer, + ), + ); + } + + return mainEntry.isBurst + ? PageEntryBuilder( + multiPageController: context.read().getController(mainEntry), + builder: (pageEntry) => _buildContent(pageEntry: pageEntry), + ) + : _buildContent(); + } + return const SizedBox(); }, ); }, diff --git a/lib/widgets/viewer/multipage/controller.dart b/lib/widgets/viewer/multipage/controller.dart index 66c8705fb..97bfdfd05 100644 --- a/lib/widgets/viewer/multipage/controller.dart +++ b/lib/widgets/viewer/multipage/controller.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; import 'package:aves/model/multipage.dart'; -import 'package:aves/services/services.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -24,9 +23,9 @@ class MultiPageController { set page(int? page) => pageNotifier.value = page; MultiPageController(this.entry) { - metadataService.getMultiPageInfo(entry).then((value) { + entry.getMultiPageInfo().then((value) { if (value == null || _disposed) return; - pageNotifier.value = value.defaultPage!.index; + pageNotifier.value = value.defaultPage?.index ?? 0; _info = value; _infoStreamController.add(_info); }); diff --git a/lib/widgets/viewer/overlay/bottom/common.dart b/lib/widgets/viewer/overlay/bottom/common.dart index 460f1d58c..60c2343fd 100644 --- a/lib/widgets/viewer/overlay/bottom/common.dart +++ b/lib/widgets/viewer/overlay/bottom/common.dart @@ -14,6 +14,7 @@ import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart'; import 'package:aves/widgets/viewer/overlay/bottom/multipage.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; +import 'package:aves/widgets/viewer/page_entry_builder.dart'; import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -107,30 +108,23 @@ class _ViewerBottomOverlayState extends State { _lastEntry = entry; } if (_lastEntry == null) return const SizedBox.shrink(); + final mainEntry = _lastEntry!; - Widget _buildContent({MultiPageInfo? multiPageInfo, int? page}) => _BottomOverlayContent( - mainEntry: _lastEntry!, - pageEntry: multiPageInfo?.getPageEntryByIndex(page) ?? _lastEntry!, + Widget _buildContent({AvesEntry? pageEntry}) => _BottomOverlayContent( + mainEntry: mainEntry, + pageEntry: pageEntry ?? mainEntry, details: _lastDetails, position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null, availableWidth: availableWidth, multiPageController: multiPageController, ); - if (multiPageController == null) return _buildContent(); - - return StreamBuilder( - stream: multiPageController!.infoStream, - builder: (context, snapshot) { - final multiPageInfo = multiPageController!.info; - return ValueListenableBuilder( - valueListenable: multiPageController!.pageNotifier, - builder: (context, page, child) { - return _buildContent(multiPageInfo: multiPageInfo, page: page); - }, - ); - }, - ); + return multiPageController != null + ? PageEntryBuilder( + multiPageController: multiPageController!, + builder: (pageEntry) => _buildContent(pageEntry: pageEntry), + ) + : _buildContent(); }, ), ); @@ -370,7 +364,7 @@ class _PositionTitleRow extends StatelessWidget { // but fail to get information about these pages final pageCount = multiPageInfo.pageCount; if (pageCount > 0) { - final page = multiPageInfo.getById(entry.pageId) ?? multiPageInfo.defaultPage; + final page = multiPageInfo.getById(entry.pageId ?? entry.contentId) ?? multiPageInfo.defaultPage; pagePosition = '${(page?.index ?? 0) + 1}/$pageCount'; } } diff --git a/lib/widgets/viewer/overlay/bottom/multipage.dart b/lib/widgets/viewer/overlay/bottom/multipage.dart index 28163c161..e599f9a61 100644 --- a/lib/widgets/viewer/overlay/bottom/multipage.dart +++ b/lib/widgets/viewer/overlay/bottom/multipage.dart @@ -5,8 +5,10 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/viewer/multipage/controller.dart'; +import 'package:aves/widgets/viewer/visual/conductor.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class MultiPageOverlay extends StatefulWidget { final MultiPageController controller; @@ -126,6 +128,7 @@ class _MultiPageOverlayState extends State { cancellableNotifier: _cancellableNotifier, selectable: false, highlightable: false, + hero: false, ), ), IgnorePointer( @@ -148,9 +151,18 @@ class _MultiPageOverlayState extends State { ); } + void _setPage(int newPage) { + final oldPage = controller.page; + if (oldPage == newPage) return; + + final oldPageEntry = controller.info!.getPageEntryByIndex(oldPage); + controller.page = newPage; + context.read().reset(oldPageEntry); + } + Future _goToPage(int page) async { _syncScroll = false; - controller.page = page; + _setPage(page); await _scrollController.animateTo( pageToScrollOffset(page), duration: Durations.viewerOverlayPageScrollAnimation, @@ -161,7 +173,7 @@ class _MultiPageOverlayState extends State { void _onScrollChange() { if (_syncScroll) { - controller.page = scrollOffsetToPage(_scrollController.offset); + _setPage(scrollOffsetToPage(_scrollController.offset)); } } diff --git a/lib/widgets/viewer/overlay/top.dart b/lib/widgets/viewer/overlay/top.dart index 1c4e9941d..725998922 100644 --- a/lib/widgets/viewer/overlay/top.dart +++ b/lib/widgets/viewer/overlay/top.dart @@ -1,7 +1,6 @@ import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; -import 'package:aves/model/multipage.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; @@ -12,7 +11,8 @@ import 'package:aves/widgets/viewer/entry_action_delegate.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/minimap.dart'; -import 'package:aves/widgets/viewer/visual/state.dart'; +import 'package:aves/widgets/viewer/page_entry_builder.dart'; +import 'package:aves/widgets/viewer/visual/conductor.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -23,7 +23,6 @@ class ViewerTopOverlay extends StatelessWidget { final Animation scale; final EdgeInsets? viewInsets, viewPadding; final bool canToggleFavourite; - final ValueNotifier? viewStateNotifier; static const double outerPadding = 8; static const double innerPadding = 8; @@ -35,7 +34,6 @@ class ViewerTopOverlay extends StatelessWidget { required this.canToggleFavourite, required this.viewInsets, required this.viewPadding, - required this.viewStateNotifier, }) : super(key: key); @override @@ -50,33 +48,19 @@ class ViewerTopOverlay extends StatelessWidget { final buttonWidth = OverlayButton.getSize(context); final availableCount = ((mqWidth - outerPadding * 2 - buttonWidth) / (buttonWidth + innerPadding)).floor(); - Widget? child; - if (mainEntry.isMultiPage) { - final multiPageController = context.read().getController(mainEntry); - if (multiPageController != null) { - child = StreamBuilder( - stream: multiPageController.infoStream, - builder: (context, snapshot) { - final multiPageInfo = multiPageController.info; - return ValueListenableBuilder( - valueListenable: multiPageController.pageNotifier, - builder: (context, page, child) { - return _buildOverlay(availableCount, mainEntry, pageEntry: multiPageInfo?.getPageEntryByIndex(page)); - }, - ); - }, - ); - } - } - - return child ??= _buildOverlay(availableCount, mainEntry); + return mainEntry.isMultiPage + ? PageEntryBuilder( + multiPageController: context.read().getController(mainEntry), + builder: (pageEntry) => _buildOverlay(context, availableCount, mainEntry, pageEntry: pageEntry), + ) + : _buildOverlay(context, availableCount, mainEntry); }, ), ), ); } - Widget _buildOverlay(int availableCount, AvesEntry mainEntry, {AvesEntry? pageEntry}) { + Widget _buildOverlay(BuildContext context, int availableCount, AvesEntry mainEntry, {AvesEntry? pageEntry}) { pageEntry ??= mainEntry; bool _canDo(EntryAction action) { @@ -130,22 +114,25 @@ class ViewerTopOverlay extends StatelessWidget { }, ); - return settings.showOverlayMinimap && viewStateNotifier != null - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - buttonRow, - const SizedBox(height: 8), - FadeTransition( - opacity: scale, - child: Minimap( - entry: pageEntry, - viewStateNotifier: viewStateNotifier!, - ), - ) - ], + if (settings.showOverlayMinimap) { + final viewStateConductor = context.read(); + final viewStateNotifier = viewStateConductor.getOrCreateController(pageEntry); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buttonRow, + const SizedBox(height: 8), + FadeTransition( + opacity: scale, + child: Minimap( + entry: pageEntry, + viewStateNotifier: viewStateNotifier, + ), ) - : buttonRow; + ], + ); + } + return buttonRow; } } @@ -154,6 +141,8 @@ class _TopOverlayRow extends StatelessWidget { final Animation scale; final AvesEntry mainEntry, pageEntry; + AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry; + const _TopOverlayRow({ Key? key, required this.quickActions, @@ -204,7 +193,7 @@ class _TopOverlayRow extends StatelessWidget { switch (action) { case EntryAction.toggleFavourite: child = _FavouriteToggler( - entry: mainEntry, + entry: favouriteTargetEntry, onPressed: onPressed, ); break; @@ -250,7 +239,7 @@ class _TopOverlayRow extends StatelessWidget { // in app actions case EntryAction.toggleFavourite: child = _FavouriteToggler( - entry: mainEntry, + entry: favouriteTargetEntry, isMenuItem: true, ); break; @@ -300,7 +289,7 @@ class _TopOverlayRow extends StatelessWidget { void _onActionSelected(BuildContext context, EntryAction action) { var targetEntry = mainEntry; - if (mainEntry.isMultiPage && EntryActions.pageActions.contains(action)) { + if (mainEntry.isMultiPage && (mainEntry.isBurst || EntryActions.pageActions.contains(action))) { final multiPageController = context.read().getController(mainEntry); if (multiPageController != null) { final multiPageInfo = multiPageController.info; diff --git a/lib/widgets/viewer/page_entry_builder.dart b/lib/widgets/viewer/page_entry_builder.dart new file mode 100644 index 000000000..23050602f --- /dev/null +++ b/lib/widgets/viewer/page_entry_builder.dart @@ -0,0 +1,35 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/model/multipage.dart'; +import 'package:aves/widgets/viewer/multipage/controller.dart'; +import 'package:flutter/widgets.dart'; + +class PageEntryBuilder extends StatelessWidget { + final MultiPageController? multiPageController; + final Widget Function(AvesEntry? pageEntry) builder; + + const PageEntryBuilder({ + Key? key, + required this.multiPageController, + required this.builder, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final controller = multiPageController; + return controller != null + ? StreamBuilder( + stream: controller.infoStream, + builder: (context, snapshot) { + final multiPageInfo = controller.info; + return ValueListenableBuilder( + valueListenable: controller.pageNotifier, + builder: (context, page, child) { + final pageEntry = multiPageInfo?.getPageEntryByIndex(page); + return builder(pageEntry); + }, + ); + }, + ) + : builder(null); + } +} diff --git a/lib/widgets/viewer/printer.dart b/lib/widgets/viewer/printer.dart index e27f5bab2..72e3ed4e4 100644 --- a/lib/widgets/viewer/printer.dart +++ b/lib/widgets/viewer/printer.dart @@ -48,7 +48,7 @@ class EntryPrinter with FeedbackMixin { } if (entry.isMultiPage && !entry.isMotionPhoto) { - final multiPageInfo = await metadataService.getMultiPageInfo(entry); + final multiPageInfo = await entry.getMultiPageInfo(); if (multiPageInfo != null) { final pageCount = multiPageInfo.pageCount; if (pageCount > 1) { diff --git a/lib/widgets/viewer/visual/conductor.dart b/lib/widgets/viewer/visual/conductor.dart new file mode 100644 index 000000000..043bc62cd --- /dev/null +++ b/lib/widgets/viewer/visual/conductor.dart @@ -0,0 +1,59 @@ +import 'package:aves/model/entry.dart'; +import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart'; +import 'package:aves/widgets/common/magnifier/scale/scale_level.dart'; +import 'package:aves/widgets/viewer/visual/state.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:tuple/tuple.dart'; + +class ViewStateConductor { + final List>> _controllers = []; + Size _viewportSize = Size.zero; + + static const maxControllerCount = 3; + + Future dispose() async { + _controllers.clear(); + } + + set viewportSize(Size size) => _viewportSize = size; + + ValueNotifier getOrCreateController(AvesEntry entry) { + var controller = _controllers.firstOrNull; + if (controller == null || controller.item1 != entry.uri) { + controller = _controllers.firstWhereOrNull((kv) => kv.item1 == entry.uri); + if (controller != null) { + _controllers.remove(controller); + } else { + // try to initialize the view state to match magnifier initial state + const initialScale = ScaleLevel(ref: ScaleReference.contained); + final initialValue = ViewState( + Offset.zero, + ScaleBoundaries( + minScale: initialScale, + maxScale: initialScale, + initialScale: initialScale, + viewportSize: _viewportSize, + childSize: entry.displaySize, + ).initialScale, + _viewportSize, + ); + controller = Tuple2(entry.uri, ValueNotifier(initialValue)); + } + _controllers.insert(0, controller); + while (_controllers.length > maxControllerCount) { + _controllers.removeLast().item2.dispose(); + } + } + return controller.item2; + } + + void reset(AvesEntry entry) { + final uris = { + entry, + ...?entry.burstEntries, + }.map((v) => v.uri).toSet(); + _controllers.removeWhere((kv) => uris.contains(kv.item1)); + } +} diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index 3f21a6510..a85e663fe 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -14,6 +14,7 @@ import 'package:aves/widgets/viewer/hero.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/video/conductor.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; +import 'package:aves/widgets/viewer/visual/conductor.dart'; import 'package:aves/widgets/viewer/visual/error.dart'; import 'package:aves/widgets/viewer/visual/raster.dart'; import 'package:aves/widgets/viewer/visual/state.dart'; @@ -26,7 +27,6 @@ import 'package:provider/provider.dart'; class EntryPageView extends StatefulWidget { final AvesEntry mainEntry, pageEntry; - final Size viewportSize; final VoidCallback? onDisposed; static const decorationCheckSize = 20.0; @@ -35,7 +35,6 @@ class EntryPageView extends StatefulWidget { Key? key, required this.mainEntry, required this.pageEntry, - required this.viewportSize, this.onDisposed, }) : super(key: key); @@ -44,16 +43,14 @@ class EntryPageView extends StatefulWidget { } class _EntryPageViewState extends State { + late ValueNotifier _viewStateNotifier; late MagnifierController _magnifierController; - final ValueNotifier _viewStateNotifier = ValueNotifier(ViewState.zero); final List _subscriptions = []; AvesEntry get mainEntry => widget.mainEntry; AvesEntry get entry => widget.pageEntry; - Size get viewportSize => widget.viewportSize; - static const initialScale = ScaleLevel(ref: ScaleReference.contained); static const minScale = ScaleLevel(ref: ScaleReference.contained); static const maxScale = ScaleLevel(factor: 2.0); @@ -68,9 +65,7 @@ class _EntryPageViewState extends State { void didUpdateWidget(covariant EntryPageView oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.pageEntry.uri != widget.pageEntry.uri || oldWidget.pageEntry.displaySize != widget.pageEntry.displaySize) { - // do not reset the magnifier view state unless main entry or page entry dimensions change, - // in effect locking the zoom & position when browsing entry pages of the same size + if (oldWidget.pageEntry != widget.pageEntry) { _unregisterWidget(); _registerWidget(); } @@ -84,19 +79,7 @@ class _EntryPageViewState extends State { } void _registerWidget() { - // try to initialize the view state to match magnifier initial state - _viewStateNotifier.value = ViewState( - Offset.zero, - ScaleBoundaries( - minScale: minScale, - maxScale: maxScale, - initialScale: initialScale, - viewportSize: viewportSize, - childSize: entry.displaySize, - ).initialScale, - viewportSize, - ); - + _viewStateNotifier = context.read().getOrCreateController(entry); _magnifierController = MagnifierController(); _subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged)); _subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged)); @@ -134,7 +117,7 @@ class _EntryPageViewState extends State { return Consumer( builder: (context, info, child) => Hero( - tag: info != null && info.entry == mainEntry ? hashValues(info.collectionId, mainEntry) : hashCode, + tag: info != null && info.entry == mainEntry ? hashValues(info.collectionId, mainEntry.uri) : hashCode, transitionOnUserGestures: true, child: child!, ), @@ -241,7 +224,7 @@ class _EntryPageViewState extends State { }) { return Magnifier( // key includes modified date to refresh when the image is modified by metadata (e.g. rotated) - key: ValueKey('${entry.pageId}_${entry.dateModifiedSecs}'), + key: ValueKey('${entry.uri}_${entry.pageId}_${entry.dateModifiedSecs}'), controller: _magnifierController, childSize: displaySize ?? entry.displaySize, minScale: minScale, @@ -260,14 +243,12 @@ class _EntryPageViewState extends State { final current = _viewStateNotifier.value; final viewState = ViewState(v.position, v.scale, current.viewportSize); _viewStateNotifier.value = viewState; - ViewStateNotification(entry.uri, viewState).dispatch(context); } void _onViewScaleBoundariesChanged(ScaleBoundaries v) { final current = _viewStateNotifier.value; final viewState = ViewState(current.position, current.scale, v.viewportSize); _viewStateNotifier.value = viewState; - ViewStateNotification(entry.uri, viewState).dispatch(context); } static ScaleState _vectorScaleStateCycle(ScaleState actual) { diff --git a/lib/widgets/viewer/visual/state.dart b/lib/widgets/viewer/visual/state.dart index 2bcc3d3a4..efae82e5e 100644 --- a/lib/widgets/viewer/visual/state.dart +++ b/lib/widgets/viewer/visual/state.dart @@ -13,13 +13,3 @@ class ViewState { @override String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, viewportSize=$viewportSize}'; } - -class ViewStateNotification extends Notification { - final String uri; - final ViewState viewState; - - const ViewStateNotification(this.uri, this.viewState); - - @override - String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}'; -}