#6 burst support (samsung camera naming pattern)

This commit is contained in:
Thibault Deckers 2021-07-22 16:55:16 +09:00
parent 4d7b9e9065
commit 6c2dc791d5
30 changed files with 504 additions and 310 deletions

View file

@ -4,6 +4,7 @@ import 'package:aves/geo/countries.dart';
import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/video/metadata.dart'; import 'package:aves/model/video/metadata.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
@ -39,6 +40,8 @@ class AvesEntry {
CatalogMetadata? _catalogMetadata; CatalogMetadata? _catalogMetadata;
AddressDetails? _addressDetails; AddressDetails? _addressDetails;
List<AvesEntry>? burstEntries;
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
// TODO TLAD make it dynamic if it depends on OS/lib versions // TODO TLAD make it dynamic if it depends on OS/lib versions
@ -64,6 +67,7 @@ class AvesEntry {
required int? dateModifiedSecs, required int? dateModifiedSecs,
required this.sourceDateTakenMillis, required this.sourceDateTakenMillis,
required int? durationMillis, required int? durationMillis,
this.burstEntries,
}) { }) {
this.path = path; this.path = path;
this.sourceTitle = sourceTitle; this.sourceTitle = sourceTitle;
@ -80,6 +84,7 @@ class AvesEntry {
String? path, String? path,
int? contentId, int? contentId,
int? dateModifiedSecs, int? dateModifiedSecs,
List<AvesEntry>? burstEntries,
}) { }) {
final copyContentId = contentId ?? this.contentId; final copyContentId = contentId ?? this.contentId;
final copied = AvesEntry( final copied = AvesEntry(
@ -96,6 +101,7 @@ class AvesEntry {
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs, dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
sourceDateTakenMillis: sourceDateTakenMillis, sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: durationMillis, durationMillis: durationMillis,
burstEntries: burstEntries ?? this.burstEntries,
) )
..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId) ..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
..addressDetails = _addressDetails?.copyWith(contentId: copyContentId); ..addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
@ -228,10 +234,6 @@ class AvesEntry {
bool get is360 => _catalogMetadata?.is360 ?? false; 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 canEdit => path != null;
bool get canRotateAndFlip => canEdit && canEditExif; 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<MultiPageInfo?> 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: // compare by:
// 1) title ascending // 1) title ascending
// 2) extension ascending // 2) extension ascending

View file

@ -22,6 +22,14 @@ class MultiPageInfo {
final firstPage = _pages.removeAt(0); final firstPage = _pages.removeAt(0);
_pages.insert(0, firstPage.copyWith(isDefault: true)); _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);
}));
}
} }
} }

View file

@ -212,9 +212,9 @@ class Settings extends ChangeNotifier {
// collection // 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); EntrySortFactor get collectionSortFactor => getEnumOrDefault(collectionSortFactorKey, EntrySortFactor.date, EntrySortFactor.values);

View file

@ -13,6 +13,7 @@ import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/section_keys.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/collection_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -21,9 +22,9 @@ import 'enums.dart';
class CollectionLens with ChangeNotifier { class CollectionLens with ChangeNotifier {
final CollectionSource source; final CollectionSource source;
final Set<CollectionFilter> filters; final Set<CollectionFilter> filters;
EntryGroupFactor groupFactor; EntryGroupFactor sectionFactor;
EntrySortFactor sortFactor; EntrySortFactor sortFactor;
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortGroupChangeNotifier = AChangeNotifier(); final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier();
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
int? id; int? id;
bool listenToSource; bool listenToSource;
@ -38,7 +39,7 @@ class CollectionLens with ChangeNotifier {
this.id, this.id,
this.listenToSource = true, this.listenToSource = true,
}) : filters = (filters ?? {}).whereNotNull().toSet(), }) : filters = (filters ?? {}).whereNotNull().toSet(),
groupFactor = settings.collectionGroupFactor, sectionFactor = settings.collectionSectionFactor,
sortFactor = settings.collectionSortFactor { sortFactor = settings.collectionSortFactor {
id ??= hashCode; id ??= hashCode;
if (listenToSource) { if (listenToSource) {
@ -73,7 +74,7 @@ class CollectionLens with ChangeNotifier {
int get entryCount => _filteredSortedEntries.length; 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<AvesEntry>? _sortedEntries; List<AvesEntry>? _sortedEntries;
List<AvesEntry> get sortedEntries { List<AvesEntry> get sortedEntries {
@ -84,9 +85,9 @@ class CollectionLens with ChangeNotifier {
bool get showHeaders { bool get showHeaders {
if (sortFactor == EntrySortFactor.size) return false; 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); final filterByAlbum = filters.any((f) => f is AlbumFilter);
if (albumSections && filterByAlbum) return false; if (albumSections && filterByAlbum) return false;
@ -113,9 +114,33 @@ class CollectionLens with ChangeNotifier {
filterChangeNotifier.notifyListeners(); filterChangeNotifier.notifyListeners();
} }
final bool groupBursts = true;
void _applyFilters() { void _applyFilters() {
final entries = source.visibleEntries; final entries = source.visibleEntries;
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry)))); _filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry))));
if (groupBursts) {
_groupBursts();
}
}
void _groupBursts() {
final byBurstKey = groupBy<AvesEntry, String?>(_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() { void _applySort() {
@ -132,10 +157,10 @@ class CollectionLens with ChangeNotifier {
} }
} }
void _applyGroup() { void _applySection() {
switch (sortFactor) { switch (sortFactor) {
case EntrySortFactor.date: case EntrySortFactor.date:
switch (groupFactor) { switch (sectionFactor) {
case EntryGroupFactor.album: case EntryGroupFactor.album:
sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory)); sections = groupBy<AvesEntry, EntryAlbumSectionKey>(_filteredSortedEntries, (entry) => EntryAlbumSectionKey(entry.directory));
break; break;
@ -168,11 +193,11 @@ class CollectionLens with ChangeNotifier {
} }
// metadata change should also trigger a full refresh // metadata change should also trigger a full refresh
// as dates impact sorting and grouping // as dates impact sorting and sectioning
void _refresh() { void _refresh() {
_applyFilters(); _applyFilters();
_applySort(); _applySort();
_applyGroup(); _applySection();
} }
void _onFavouritesChanged() { void _onFavouritesChanged() {
@ -183,21 +208,21 @@ class CollectionLens with ChangeNotifier {
void _onSettingsChanged() { void _onSettingsChanged() {
final newSortFactor = settings.collectionSortFactor; final newSortFactor = settings.collectionSortFactor;
final newGroupFactor = settings.collectionGroupFactor; final newSectionFactor = settings.collectionSectionFactor;
final needSort = sortFactor != newSortFactor; final needSort = sortFactor != newSortFactor;
final needGroup = needSort || groupFactor != newGroupFactor; final needSection = needSort || sectionFactor != newSectionFactor;
if (needSort) { if (needSort) {
sortFactor = newSortFactor; sortFactor = newSortFactor;
_applySort(); _applySort();
} }
if (needGroup) { if (needSection) {
groupFactor = newGroupFactor; sectionFactor = newSectionFactor;
_applyGroup(); _applySection();
} }
if (needSort || needGroup) { if (needSort || needSection) {
sortGroupChangeNotifier.notifyListeners(); sortSectionChangeNotifier.notifyListeners();
} }
} }
@ -206,8 +231,27 @@ class CollectionLens with ChangeNotifier {
} }
void onEntryRemoved(Set<AvesEntry> entries) { void onEntryRemoved(Set<AvesEntry> entries) {
if (groupBursts) {
// find impacted burst groups
final obsoleteBurstEntries = <AvesEntry>{};
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 // 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 // as section order change would surprise the user while browsing
_filteredSortedEntries.removeWhere(entries.contains); _filteredSortedEntries.removeWhere(entries.contains);
_sortedEntries?.removeWhere(entries.contains); _sortedEntries?.removeWhere(entries.contains);

View file

@ -105,7 +105,7 @@ class PlatformMetadataService implements MetadataService {
'uri': entry.uri, 'uri': entry.uri,
'sizeBytes': entry.sizeBytes, 'sizeBytes': entry.sizeBytes,
}); });
final pageMaps = (result as List).cast<Map>(); final pageMaps = ((result as List?) ?? []).cast<Map>();
if (entry.isMotionPhoto && pageMaps.isNotEmpty) { if (entry.isMotionPhoto && pageMaps.isNotEmpty) {
final imagePage = pageMaps[0]; final imagePage = pageMaps[0];
imagePage['width'] = entry.width; imagePage['width'] = entry.width;

View file

@ -302,7 +302,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final value = await showDialog<EntryGroupFactor>( final value = await showDialog<EntryGroupFactor>(
context: context, context: context,
builder: (context) => AvesSelectionDialog<EntryGroupFactor>( builder: (context) => AvesSelectionDialog<EntryGroupFactor>(
initialValue: settings.collectionGroupFactor, initialValue: settings.collectionSectionFactor,
options: { options: {
EntryGroupFactor.album: context.l10n.collectionGroupAlbum, EntryGroupFactor.album: context.l10n.collectionGroupAlbum,
EntryGroupFactor.month: context.l10n.collectionGroupMonth, EntryGroupFactor.month: context.l10n.collectionGroupMonth,
@ -315,7 +315,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
// wait for the dialog to hide as applying the change may block the UI // wait for the dialog to hide as applying the change may block the UI
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
if (value != null) { if (value != null) {
settings.collectionGroupFactor = value; settings.collectionSectionFactor = value;
} }
break; break;
case CollectionAction.sort: case CollectionAction.sort:

View file

@ -267,13 +267,13 @@ class _CollectionScrollViewState extends State<_CollectionScrollView> {
void _registerWidget(_CollectionScrollView widget) { void _registerWidget(_CollectionScrollView widget) {
widget.collection.filterChangeNotifier.addListener(_scrollToTop); widget.collection.filterChangeNotifier.addListener(_scrollToTop);
widget.collection.sortGroupChangeNotifier.addListener(_scrollToTop); widget.collection.sortSectionChangeNotifier.addListener(_scrollToTop);
widget.scrollController.addListener(_onScrollChange); widget.scrollController.addListener(_onScrollChange);
} }
void _unregisterWidget(_CollectionScrollView widget) { void _unregisterWidget(_CollectionScrollView widget) {
widget.collection.filterChangeNotifier.removeListener(_scrollToTop); widget.collection.filterChangeNotifier.removeListener(_scrollToTop);
widget.collection.sortGroupChangeNotifier.removeListener(_scrollToTop); widget.collection.sortSectionChangeNotifier.removeListener(_scrollToTop);
widget.scrollController.removeListener(_onScrollChange); widget.scrollController.removeListener(_onScrollChange);
} }

View file

@ -25,7 +25,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
lineBuilder: (context, entry) { lineBuilder: (context, entry) {
switch (collection.sortFactor) { switch (collection.sortFactor) {
case EntrySortFactor.date: case EntrySortFactor.date:
switch (collection.groupFactor) { switch (collection.sectionFactor) {
case EntryGroupFactor.album: case EntryGroupFactor.album:
return [ return [
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate), DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate),

View file

@ -8,6 +8,7 @@ import 'package:aves/model/filters/album.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/model/selection.dart'; import 'package:aves/model/selection.dart';
import 'package:aves/model/source/collection_lens.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/android_app_service.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
@ -33,10 +34,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
_showDeleteDialog(context); _showDeleteDialog(context);
break; break;
case EntryAction.share: case EntryAction.share:
final selection = context.read<Selection<AvesEntry>>().selection; _share(context);
AndroidAppService.shareEntries(selection).then((success) {
if (!success) showNoMatchingAppDialog(context);
});
break; break;
default: default:
break; break;
@ -59,18 +57,31 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
} }
} }
void _refreshMetadata(BuildContext context) { Set<AvesEntry> _getExpandedSelectedItems(Selection<AvesEntry> selection) {
final collection = context.read<CollectionLens>(); return selection.selection.expand((entry) => entry.burstEntries ?? {entry}).toSet();
}
void _share(BuildContext context) {
final selection = context.read<Selection<AvesEntry>>(); final selection = context.read<Selection<AvesEntry>>();
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<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
source.refreshMetadata(selectedItems);
selection.browse(); selection.browse();
} }
Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async { Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
final collection = context.read<CollectionLens>(); final source = context.read<CollectionSource>();
final source = collection.source;
final selection = context.read<Selection<AvesEntry>>(); final selection = context.read<Selection<AvesEntry>>();
final selectedItems = selection.selection; final selectedItems = _getExpandedSelectedItems(selection);
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
if (moveType == MoveType.move) { if (moveType == MoveType.move) {
@ -144,6 +155,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
label: context.l10n.showButtonLabel, label: context.l10n.showButtonLabel,
onPressed: () async { onPressed: () async {
final highlightInfo = context.read<HighlightInfo>(); final highlightInfo = context.read<HighlightInfo>();
final collection = context.read<CollectionLens>();
var targetCollection = collection; var targetCollection = collection;
if (collection.filters.any((f) => f is AlbumFilter)) { if (collection.filters.any((f) => f is AlbumFilter)) {
final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum)); final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum));
@ -179,10 +191,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
} }
Future<void> _showDeleteDialog(BuildContext context) async { Future<void> _showDeleteDialog(BuildContext context) async {
final collection = context.read<CollectionLens>(); final source = context.read<CollectionSource>();
final source = collection.source;
final selection = context.read<Selection<AvesEntry>>(); final selection = context.read<Selection<AvesEntry>>();
final selectedItems = selection.selection; final selectedItems = _getExpandedSelectedItems(selection);
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
final todoCount = selectedItems.length; final todoCount = selectedItems.length;

View file

@ -36,7 +36,7 @@ class CollectionSectionHeader extends StatelessWidget {
Widget? _buildHeader(BuildContext context) { Widget? _buildHeader(BuildContext context) {
switch (collection.sortFactor) { switch (collection.sortFactor) {
case EntrySortFactor.date: case EntrySortFactor.date:
switch (collection.groupFactor) { switch (collection.sectionFactor) {
case EntryGroupFactor.album: case EntryGroupFactor.album:
return _buildAlbumHeader(context); return _buildAlbumHeader(context);
case EntryGroupFactor.month: case EntryGroupFactor.month:

View file

@ -76,7 +76,7 @@ class InteractiveThumbnail extends StatelessWidget {
id: collection.id, id: collection.id,
listenToSource: false, listenToSource: false,
); );
assert(viewerCollection.sortedEntries.contains(entry)); assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId));
return EntryViewerPage( return EntryViewerPage(
collection: viewerCollection, collection: viewerCollection,
initialEntry: entry, initialEntry: entry,

View file

@ -11,7 +11,7 @@ class DecoratedThumbnail extends StatelessWidget {
final double tileExtent; final double tileExtent;
final CollectionLens? collection; final CollectionLens? collection;
final ValueNotifier<bool>? cancellableNotifier; final ValueNotifier<bool>? cancellableNotifier;
final bool selectable, highlightable; final bool selectable, highlightable, hero;
static final Color borderColor = Colors.grey.shade700; static final Color borderColor = Colors.grey.shade700;
static final double borderWidth = AvesBorder.borderWidth; static final double borderWidth = AvesBorder.borderWidth;
@ -24,6 +24,7 @@ class DecoratedThumbnail extends StatelessWidget {
this.cancellableNotifier, this.cancellableNotifier,
this.selectable = true, this.selectable = true,
this.highlightable = true, this.highlightable = true,
this.hero = true,
}) : super(key: key); }) : super(key: key);
@override @override
@ -33,7 +34,7 @@ class DecoratedThumbnail extends StatelessWidget {
// hero tag should include a collection identifier, so that it animates // 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) // 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) // 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; final isSvg = entry.isSvg;
Widget child = ThumbnailImage( Widget child = ThumbnailImage(
entry: entry, entry: entry,

View file

@ -28,10 +28,10 @@ class ThumbnailEntryOverlay extends StatelessWidget {
const AnimatedImageIcon() const AnimatedImageIcon()
else ...[ else ...[
if (entry.isRaw && context.select<GridThemeData, bool>((t) => t.showRaw)) const RawIcon(), if (entry.isRaw && context.select<GridThemeData, bool>((t) => t.showRaw)) const RawIcon(),
if (entry.isMultiPage) MultiPageIcon(entry: entry),
if (entry.isGeotiff) const GeotiffIcon(), if (entry.isGeotiff) const GeotiffIcon(),
if (entry.is360) const SphericalImageIcon(), if (entry.is360) const SphericalImageIcon(),
] ],
if (entry.isMultiPage) MultiPageIcon(entry: entry),
]; ];
if (children.isEmpty) return const SizedBox.shrink(); if (children.isEmpty) return const SizedBox.shrink();
if (children.length == 1) return children.first; if (children.length == 1) return children.first;

View file

@ -112,10 +112,21 @@ class MultiPageIcon extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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( return OverlayIcon(
icon: entry.isMotionPhoto ? AIcons.motionPhoto : AIcons.multiPage, icon: icon,
size: context.select<GridThemeData, double>((t) => t.iconSize), size: context.select<GridThemeData, double>((t) => t.iconSize),
iconScale: .8, iconScale: .8,
text: text,
); );
} }
} }

View file

@ -28,10 +28,9 @@ class StatsPage extends StatelessWidget {
final CollectionSource source; final CollectionSource source;
final CollectionLens? parentCollection; final CollectionLens? parentCollection;
late final Set<AvesEntry> entries;
final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {}; final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {};
Set<AvesEntry> get entries => parentCollection?.sortedEntries.toSet() ?? source.visibleEntries;
static const mimeDonutMinWidth = 124.0; static const mimeDonutMinWidth = 124.0;
StatsPage({ StatsPage({
@ -39,6 +38,7 @@ class StatsPage extends StatelessWidget {
required this.source, required this.source,
this.parentCollection, this.parentCollection,
}) : super(key: key) { }) : super(key: key) {
entries = parentCollection?.sortedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet() ?? source.visibleEntries;
entries.forEach((entry) { entries.forEach((entry) {
if (entry.hasAddress) { if (entry.hasAddress) {
final address = entry.addressDetails!; final address = entry.addressDetails!;

View file

@ -182,7 +182,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final selection = <AvesEntry>{}; final selection = <AvesEntry>{};
if (entry.isMultiPage) { if (entry.isMultiPage) {
final multiPageInfo = await metadataService.getMultiPageInfo(entry); final multiPageInfo = await entry.getMultiPageInfo();
if (multiPageInfo != null) { if (multiPageInfo != null) {
if (entry.isMotionPhoto) { if (entry.isMotionPhoto) {
await multiPageInfo.extractMotionPhotoVideo(); await multiPageInfo.extractMotionPhotoVideo();

View file

@ -1,9 +1,9 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/source/collection_lens.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/gesture_detector_scope.dart';
import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart';
import 'package:aves/widgets/viewer/multipage/conductor.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:aves/widgets/viewer/visual/entry_page_view.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -12,7 +12,7 @@ class MultiEntryScroller extends StatefulWidget {
final CollectionLens collection; final CollectionLens collection;
final PageController pageController; final PageController pageController;
final ValueChanged<int> onPageChanged; final ValueChanged<int> onPageChanged;
final void Function(String uri) onViewDisposed; final void Function(AvesEntry mainEntry, AvesEntry? pageEntry) onViewDisposed;
const MultiEntryScroller({ const MultiEntryScroller({
Key? key, Key? key,
@ -44,27 +44,14 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
physics: const MagnifierScrollerPhysics(parent: BouncingScrollPhysics()), physics: const MagnifierScrollerPhysics(parent: BouncingScrollPhysics()),
onPageChanged: widget.onPageChanged, onPageChanged: widget.onPageChanged,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final entry = entries[index]; final mainEntry = entries[index];
Widget? child; var child = mainEntry.isMultiPage
if (entry.isMultiPage) { ? PageEntryBuilder(
final multiPageController = context.read<MultiPageConductor>().getController(entry); multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
if (multiPageController != null) { builder: (pageEntry) => _buildViewer(mainEntry, pageEntry: pageEntry),
child = StreamBuilder<MultiPageInfo?>( )
stream: multiPageController.infoStream, : _buildViewer(mainEntry);
builder: (context, snapshot) {
final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int?>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildViewer(entry, pageEntry: multiPageInfo?.getPageEntryByIndex(page));
},
);
},
);
}
}
child ??= _buildViewer(entry);
child = AnimatedBuilder( child = AnimatedBuilder(
animation: pageController, animation: pageController,
@ -93,17 +80,11 @@ class _MultiEntryScrollerState extends State<MultiEntryScroller> with AutomaticK
} }
Widget _buildViewer(AvesEntry mainEntry, {AvesEntry? pageEntry}) { Widget _buildViewer(AvesEntry mainEntry, {AvesEntry? pageEntry}) {
return Selector<MediaQueryData, Size>( return EntryPageView(
selector: (c, mq) => mq.size, key: const Key('imageview'),
builder: (c, mqSize, child) { mainEntry: mainEntry,
return EntryPageView( pageEntry: pageEntry ?? mainEntry,
key: const Key('imageview'), onDisposed: () => widget.onViewDisposed(mainEntry, pageEntry),
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
viewportSize: mqSize,
onDisposed: () => widget.onViewDisposed(mainEntry.uri),
);
},
); );
} }
@ -130,25 +111,12 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
Widget? child; var child = mainEntry.isMultiPage
if (mainEntry.isMultiPage) { ? PageEntryBuilder(
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry); multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
if (multiPageController != null) { builder: (pageEntry) => _buildViewer(pageEntry: pageEntry),
child = StreamBuilder<MultiPageInfo?>( )
stream: multiPageController.infoStream, : _buildViewer();
builder: (context, snapshot) {
final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int?>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildViewer(pageEntry: multiPageInfo?.getPageEntryByIndex(page));
},
);
},
);
}
}
child ??= _buildViewer();
return MagnifierGestureDetectorScope( return MagnifierGestureDetectorScope(
axis: const [Axis.vertical], axis: const [Axis.vertical],
@ -157,15 +125,9 @@ class _SingleEntryScrollerState extends State<SingleEntryScroller> with Automati
} }
Widget _buildViewer({AvesEntry? pageEntry}) { Widget _buildViewer({AvesEntry? pageEntry}) {
return Selector<MediaQueryData, Size>( return EntryPageView(
selector: (c, mq) => mq.size, mainEntry: mainEntry,
builder: (c, mqSize, child) { pageEntry: pageEntry ?? mainEntry,
return EntryPageView(
mainEntry: mainEntry,
pageEntry: pageEntry ?? mainEntry,
viewportSize: mqSize,
);
},
); );
} }

View file

@ -18,7 +18,7 @@ class ViewerVerticalPageView extends StatefulWidget {
final PageController horizontalPager, verticalPager; final PageController horizontalPager, verticalPager;
final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged; final void Function(int page) onVerticalPageChanged, onHorizontalPageChanged;
final VoidCallback onImagePageRequested; final VoidCallback onImagePageRequested;
final void Function(String uri) onViewDisposed; final void Function(AvesEntry mainEntry, AvesEntry? pageEntry) onViewDisposed;
const ViewerVerticalPageView({ const ViewerVerticalPageView({
Key? key, Key? key,

View file

@ -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/entry_viewer_stack.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart';
import 'package:aves/widgets/viewer/video/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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -23,15 +24,13 @@ class EntryViewerPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: Provider<VideoConductor>( body: ViewStateConductorProvider(
create: (context) => VideoConductor(), child: VideoConductorProvider(
dispose: (context, value) => value.dispose(), child: MultiPageConductorProvider(
child: Provider<MultiPageConductor>( child: EntryViewerStack(
create: (context) => MultiPageConductor(), collection: collection,
dispose: (context, value) => value.dispose(), initialEntry: initialEntry,
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<MediaQueryData, ViewStateConductor>(
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<VideoConductor>(
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<MultiPageConductor>(
create: (context) => MultiPageConductor(),
dispose: (context, value) => value.dispose(),
child: child,
);
}
}

View file

@ -3,7 +3,6 @@ import 'dart:math';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/highlight.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/enums.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.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/bottom/video.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/overlay/top.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/conductor.dart';
import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:aves/widgets/viewer/video/controller.dart';
import 'package:aves/widgets/viewer/video_action_delegate.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:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class EntryViewerStack extends StatefulWidget { class EntryViewerStack extends StatefulWidget {
final CollectionLens? collection; final CollectionLens? collection;
@ -62,7 +61,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
late Animation<Offset> _bottomOverlayOffset; late Animation<Offset> _bottomOverlayOffset;
EdgeInsets? _frozenViewInsets, _frozenViewPadding; EdgeInsets? _frozenViewInsets, _frozenViewPadding;
late VideoActionDelegate _videoActionDelegate; late VideoActionDelegate _videoActionDelegate;
final List<Tuple2<String, ValueNotifier<ViewState>>> _viewStateNotifiers = [];
final Map<MultiPageController, Future<void> Function()> _multiPageControllerPageListeners = {}; final Map<MultiPageController, Future<void> Function()> _multiPageControllerPageListeners = {};
final ValueNotifier<HeroInfo?> _heroInfoNotifier = ValueNotifier(null); final ValueNotifier<HeroInfo?> _heroInfoNotifier = ValueNotifier(null);
bool _isEntryTracked = true; bool _isEntryTracked = true;
@ -90,7 +88,11 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
} }
// make sure initial entry is actually among the filtered collection entries // 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 // opening hero, with viewer as target
_heroInfoNotifier.value = HeroInfo(collection?.id, entry); _heroInfoNotifier.value = HeroInfo(collection?.id, entry);
_entryNotifier.value = entry; _entryNotifier.value = entry;
@ -169,6 +171,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final viewStateConductor = context.read<ViewStateConductor>();
return WillPopScope( return WillPopScope(
onWillPop: () { onWillPop: () {
if (_currentVerticalPage.value == infoPage) { if (_currentVerticalPage.value == infoPage) {
@ -186,8 +189,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
onNotification: (dynamic notification) { onNotification: (dynamic notification) {
if (notification is FilterSelectedNotification) { if (notification is FilterSelectedNotification) {
_goToCollection(notification.filter); _goToCollection(notification.filter);
} else if (notification is ViewStateNotification) {
_updateViewState(notification.uri, notification.viewState);
} else if (notification is EntryDeletedNotification) { } else if (notification is EntryDeletedNotification) {
_onEntryDeleted(context, notification.entry); _onEntryDeleted(context, notification.entry);
} }
@ -208,7 +209,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
onVerticalPageChanged: _onVerticalPageChanged, onVerticalPageChanged: _onVerticalPageChanged,
onHorizontalPageChanged: _onHorizontalPageChanged, onHorizontalPageChanged: _onHorizontalPageChanged,
onImagePageRequested: () => _goToVerticalPage(imagePage), onImagePageRequested: () => _goToVerticalPage(imagePage),
onViewDisposed: (uri) => _updateViewState(uri, null), onViewDisposed: (mainEntry, pageEntry) => viewStateConductor.reset(pageEntry ?? mainEntry),
), ),
_buildTopOverlay(), _buildTopOverlay(),
_buildBottomOverlay(), _buildBottomOverlay(),
@ -221,23 +222,14 @@ class _EntryViewerStackState extends State<EntryViewerStack> 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 _buildTopOverlay() {
Widget child = ValueListenableBuilder<AvesEntry?>( Widget child = ValueListenableBuilder<AvesEntry?>(
valueListenable: _entryNotifier, valueListenable: _entryNotifier,
builder: (context, mainEntry, child) { builder: (context, mainEntry, child) {
if (mainEntry == null) return const SizedBox.shrink(); if (mainEntry == null) return const SizedBox.shrink();
return NotificationListener<ShowInfoNotification>( Widget _buildContent({AvesEntry? pageEntry}) {
onNotification: (notification) { return EmbeddedDataOpener(
_goToVerticalPage(infoPage);
return true;
},
child: EmbeddedDataOpener(
entry: mainEntry, entry: mainEntry,
child: ViewerTopOverlay( child: ViewerTopOverlay(
mainEntry: mainEntry, mainEntry: mainEntry,
@ -245,9 +237,21 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
canToggleFavourite: hasCollection, canToggleFavourite: hasCollection,
viewInsets: _frozenViewInsets, viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding, viewPadding: _frozenViewPadding,
viewStateNotifier: _viewStateNotifiers.firstWhereOrNull((kv) => kv.item1 == mainEntry.uri)?.item2,
), ),
), );
}
return NotificationListener<ShowInfoNotification>(
onNotification: (notification) {
_goToVerticalPage(infoPage);
return true;
},
child: mainEntry.isMultiPage
? PageEntryBuilder(
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
builder: (pageEntry) => _buildContent(pageEntry: pageEntry),
)
: _buildContent(),
); );
}, },
); );
@ -282,14 +286,17 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
valueListenable: _entryNotifier, valueListenable: _entryNotifier,
builder: (context, mainEntry, child) { builder: (context, mainEntry, child) {
if (mainEntry == null) return const SizedBox.shrink(); if (mainEntry == null) return const SizedBox.shrink();
final multiPageController = mainEntry.isMultiPage ? context.read<MultiPageConductor>().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 // a 360 video is both a video and a panorama but only the video controls are displayed
if (pageEntry.isVideo) { if (targetEntry.isVideo) {
return Selector<VideoConductor, AvesVideoController?>( child = Selector<VideoConductor, AvesVideoController?>(
selector: (context, vc) => vc.getController(pageEntry), selector: (context, vc) => vc.getController(targetEntry),
builder: (context, videoController, child) => VideoControlOverlay( builder: (context, videoController, child) => VideoControlOverlay(
entry: pageEntry, entry: targetEntry,
controller: videoController, controller: videoController,
scale: _bottomOverlayScale, scale: _bottomOverlayScale,
onActionSelected: (action) { onActionSelected: (action) {
@ -299,40 +306,31 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
}, },
), ),
); );
} else if (pageEntry.is360) { } else if (targetEntry.is360) {
return PanoramaOverlay( child = PanoramaOverlay(
entry: pageEntry, entry: targetEntry,
scale: _bottomOverlayScale, scale: _bottomOverlayScale,
); );
} }
return null; return child != null
? ExtraBottomOverlay(
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
child: child,
)
: null;
} }
final multiPageController = mainEntry.isMultiPage ? context.read<MultiPageConductor>().getController(mainEntry) : null; final extraBottomOverlay = mainEntry.isMultiPage
final extraBottomOverlay = multiPageController != null ? PageEntryBuilder(
? StreamBuilder<MultiPageInfo?>( multiPageController: multiPageController,
stream: multiPageController.infoStream, builder: (pageEntry) => _buildExtraBottomOverlay(pageEntry: pageEntry) ?? const SizedBox(),
builder: (context, snapshot) { )
final multiPageInfo = multiPageController.info; : _buildExtraBottomOverlay();
if (multiPageInfo == null) return const SizedBox.shrink();
return ValueListenableBuilder<int?>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
final pageEntry = multiPageInfo.getPageEntryByIndex(page);
return _buildExtraBottomOverlay(pageEntry) ?? const SizedBox();
},
);
})
: _buildExtraBottomOverlay(mainEntry);
return Column( return Column(
children: [ children: [
if (extraBottomOverlay != null) if (extraBottomOverlay != null) extraBottomOverlay,
ExtraBottomOverlay(
viewInsets: _frozenViewInsets,
viewPadding: _frozenViewPadding,
child: extraBottomOverlay,
),
SlideTransition( SlideTransition(
position: _bottomOverlayOffset, position: _bottomOverlayOffset,
child: ViewerBottomOverlay( child: ViewerBottomOverlay(
@ -564,7 +562,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
Future<void> _initEntryControllers(AvesEntry? entry) async { Future<void> _initEntryControllers(AvesEntry? entry) async {
if (entry == null) return; if (entry == null) return;
_initViewStateController(entry);
if (entry.isVideo) { if (entry.isVideo) {
await _initVideoController(entry); await _initVideoController(entry);
} }
@ -581,20 +578,6 @@ class _EntryViewerStackState extends State<EntryViewerStack> 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>(ViewState.zero));
}
_viewStateNotifiers.insert(0, controller);
while (_viewStateNotifiers.length > 3) {
_viewStateNotifiers.removeLast().item2.dispose();
}
}
Future<void> _initVideoController(AvesEntry entry) async { Future<void> _initVideoController(AvesEntry entry) async {
final controller = context.read<VideoConductor>().getOrCreateController(entry); final controller = context.read<VideoConductor>().getOrCreateController(entry);
setState(() {}); setState(() {});

View file

@ -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/location_section.dart';
import 'package:aves/widgets/viewer/info/metadata/metadata_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/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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -33,9 +35,7 @@ class _InfoPageState extends State<InfoPage> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
bool _scrollStartFromTop = false; bool _scrollStartFromTop = false;
CollectionLens? get collection => widget.collection; static const splitScreenWidthThreshold = 600;
AvesEntry? get entry => widget.entryNotifier.value;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -51,20 +51,31 @@ class _InfoPageState extends State<InfoPage> {
builder: (c, mqWidth, child) { builder: (c, mqWidth, child) {
return ValueListenableBuilder<AvesEntry?>( return ValueListenableBuilder<AvesEntry?>(
valueListenable: widget.entryNotifier, valueListenable: widget.entryNotifier,
builder: (context, entry, child) { builder: (context, mainEntry, child) {
return entry != null if (mainEntry != null) {
? EmbeddedDataOpener( Widget _buildContent({AvesEntry? pageEntry}) {
entry: entry, final targetEntry = pageEntry ?? mainEntry;
child: _InfoPageContent( return EmbeddedDataOpener(
collection: collection, entry: targetEntry,
entry: entry, child: _InfoPageContent(
isScrollingNotifier: widget.isScrollingNotifier, collection: widget.collection,
scrollController: _scrollController, entry: targetEntry,
split: mqWidth > 600, isScrollingNotifier: widget.isScrollingNotifier,
goToViewer: _goToViewer, scrollController: _scrollController,
), split: mqWidth > splitScreenWidthThreshold,
) goToViewer: _goToViewer,
: const SizedBox.shrink(); ),
);
}
return mainEntry.isBurst
? PageEntryBuilder(
multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
builder: (pageEntry) => _buildContent(pageEntry: pageEntry),
)
: _buildContent();
}
return const SizedBox();
}, },
); );
}, },

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/services/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -24,9 +23,9 @@ class MultiPageController {
set page(int? page) => pageNotifier.value = page; set page(int? page) => pageNotifier.value = page;
MultiPageController(this.entry) { MultiPageController(this.entry) {
metadataService.getMultiPageInfo(entry).then((value) { entry.getMultiPageInfo().then((value) {
if (value == null || _disposed) return; if (value == null || _disposed) return;
pageNotifier.value = value.defaultPage!.index; pageNotifier.value = value.defaultPage?.index ?? 0;
_info = value; _info = value;
_infoStreamController.add(_info); _infoStreamController.add(_info);
}); });

View file

@ -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/multipage/controller.dart';
import 'package:aves/widgets/viewer/overlay/bottom/multipage.dart'; import 'package:aves/widgets/viewer/overlay/bottom/multipage.dart';
import 'package:aves/widgets/viewer/overlay/common.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:decorated_icon/decorated_icon.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -107,30 +108,23 @@ class _ViewerBottomOverlayState extends State<ViewerBottomOverlay> {
_lastEntry = entry; _lastEntry = entry;
} }
if (_lastEntry == null) return const SizedBox.shrink(); if (_lastEntry == null) return const SizedBox.shrink();
final mainEntry = _lastEntry!;
Widget _buildContent({MultiPageInfo? multiPageInfo, int? page}) => _BottomOverlayContent( Widget _buildContent({AvesEntry? pageEntry}) => _BottomOverlayContent(
mainEntry: _lastEntry!, mainEntry: mainEntry,
pageEntry: multiPageInfo?.getPageEntryByIndex(page) ?? _lastEntry!, pageEntry: pageEntry ?? mainEntry,
details: _lastDetails, details: _lastDetails,
position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null, position: widget.showPosition ? '${widget.index + 1}/${widget.entries.length}' : null,
availableWidth: availableWidth, availableWidth: availableWidth,
multiPageController: multiPageController, multiPageController: multiPageController,
); );
if (multiPageController == null) return _buildContent(); return multiPageController != null
? PageEntryBuilder(
return StreamBuilder<MultiPageInfo?>( multiPageController: multiPageController!,
stream: multiPageController!.infoStream, builder: (pageEntry) => _buildContent(pageEntry: pageEntry),
builder: (context, snapshot) { )
final multiPageInfo = multiPageController!.info; : _buildContent();
return ValueListenableBuilder<int?>(
valueListenable: multiPageController!.pageNotifier,
builder: (context, page, child) {
return _buildContent(multiPageInfo: multiPageInfo, page: page);
},
);
},
);
}, },
), ),
); );
@ -370,7 +364,7 @@ class _PositionTitleRow extends StatelessWidget {
// but fail to get information about these pages // but fail to get information about these pages
final pageCount = multiPageInfo.pageCount; final pageCount = multiPageInfo.pageCount;
if (pageCount > 0) { 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'; pagePosition = '${(page?.index ?? 0) + 1}/$pageCount';
} }
} }

View file

@ -5,8 +5,10 @@ import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart';
import 'package:aves/widgets/common/grid/theme.dart'; import 'package:aves/widgets/common/grid/theme.dart';
import 'package:aves/widgets/viewer/multipage/controller.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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class MultiPageOverlay extends StatefulWidget { class MultiPageOverlay extends StatefulWidget {
final MultiPageController controller; final MultiPageController controller;
@ -126,6 +128,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
cancellableNotifier: _cancellableNotifier, cancellableNotifier: _cancellableNotifier,
selectable: false, selectable: false,
highlightable: false, highlightable: false,
hero: false,
), ),
), ),
IgnorePointer( IgnorePointer(
@ -148,9 +151,18 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
); );
} }
void _setPage(int newPage) {
final oldPage = controller.page;
if (oldPage == newPage) return;
final oldPageEntry = controller.info!.getPageEntryByIndex(oldPage);
controller.page = newPage;
context.read<ViewStateConductor>().reset(oldPageEntry);
}
Future<void> _goToPage(int page) async { Future<void> _goToPage(int page) async {
_syncScroll = false; _syncScroll = false;
controller.page = page; _setPage(page);
await _scrollController.animateTo( await _scrollController.animateTo(
pageToScrollOffset(page), pageToScrollOffset(page),
duration: Durations.viewerOverlayPageScrollAnimation, duration: Durations.viewerOverlayPageScrollAnimation,
@ -161,7 +173,7 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
void _onScrollChange() { void _onScrollChange() {
if (_syncScroll) { if (_syncScroll) {
controller.page = scrollOffsetToPage(_scrollController.offset); _setPage(scrollOffsetToPage(_scrollController.offset));
} }
} }

View file

@ -1,7 +1,6 @@
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.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/multipage/conductor.dart';
import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:aves/widgets/viewer/overlay/minimap.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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
@ -23,7 +23,6 @@ class ViewerTopOverlay extends StatelessWidget {
final Animation<double> scale; final Animation<double> scale;
final EdgeInsets? viewInsets, viewPadding; final EdgeInsets? viewInsets, viewPadding;
final bool canToggleFavourite; final bool canToggleFavourite;
final ValueNotifier<ViewState>? viewStateNotifier;
static const double outerPadding = 8; static const double outerPadding = 8;
static const double innerPadding = 8; static const double innerPadding = 8;
@ -35,7 +34,6 @@ class ViewerTopOverlay extends StatelessWidget {
required this.canToggleFavourite, required this.canToggleFavourite,
required this.viewInsets, required this.viewInsets,
required this.viewPadding, required this.viewPadding,
required this.viewStateNotifier,
}) : super(key: key); }) : super(key: key);
@override @override
@ -50,33 +48,19 @@ class ViewerTopOverlay extends StatelessWidget {
final buttonWidth = OverlayButton.getSize(context); final buttonWidth = OverlayButton.getSize(context);
final availableCount = ((mqWidth - outerPadding * 2 - buttonWidth) / (buttonWidth + innerPadding)).floor(); final availableCount = ((mqWidth - outerPadding * 2 - buttonWidth) / (buttonWidth + innerPadding)).floor();
Widget? child; return mainEntry.isMultiPage
if (mainEntry.isMultiPage) { ? PageEntryBuilder(
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry); multiPageController: context.read<MultiPageConductor>().getController(mainEntry),
if (multiPageController != null) { builder: (pageEntry) => _buildOverlay(context, availableCount, mainEntry, pageEntry: pageEntry),
child = StreamBuilder<MultiPageInfo?>( )
stream: multiPageController.infoStream, : _buildOverlay(context, availableCount, mainEntry);
builder: (context, snapshot) {
final multiPageInfo = multiPageController.info;
return ValueListenableBuilder<int?>(
valueListenable: multiPageController.pageNotifier,
builder: (context, page, child) {
return _buildOverlay(availableCount, mainEntry, pageEntry: multiPageInfo?.getPageEntryByIndex(page));
},
);
},
);
}
}
return child ??= _buildOverlay(availableCount, mainEntry);
}, },
), ),
), ),
); );
} }
Widget _buildOverlay(int availableCount, AvesEntry mainEntry, {AvesEntry? pageEntry}) { Widget _buildOverlay(BuildContext context, int availableCount, AvesEntry mainEntry, {AvesEntry? pageEntry}) {
pageEntry ??= mainEntry; pageEntry ??= mainEntry;
bool _canDo(EntryAction action) { bool _canDo(EntryAction action) {
@ -130,22 +114,25 @@ class ViewerTopOverlay extends StatelessWidget {
}, },
); );
return settings.showOverlayMinimap && viewStateNotifier != null if (settings.showOverlayMinimap) {
? Column( final viewStateConductor = context.read<ViewStateConductor>();
crossAxisAlignment: CrossAxisAlignment.start, final viewStateNotifier = viewStateConductor.getOrCreateController(pageEntry);
children: [ return Column(
buttonRow, crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: 8), children: [
FadeTransition( buttonRow,
opacity: scale, const SizedBox(height: 8),
child: Minimap( FadeTransition(
entry: pageEntry, opacity: scale,
viewStateNotifier: viewStateNotifier!, child: Minimap(
), entry: pageEntry,
) viewStateNotifier: viewStateNotifier,
], ),
) )
: buttonRow; ],
);
}
return buttonRow;
} }
} }
@ -154,6 +141,8 @@ class _TopOverlayRow extends StatelessWidget {
final Animation<double> scale; final Animation<double> scale;
final AvesEntry mainEntry, pageEntry; final AvesEntry mainEntry, pageEntry;
AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry;
const _TopOverlayRow({ const _TopOverlayRow({
Key? key, Key? key,
required this.quickActions, required this.quickActions,
@ -204,7 +193,7 @@ class _TopOverlayRow extends StatelessWidget {
switch (action) { switch (action) {
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
child = _FavouriteToggler( child = _FavouriteToggler(
entry: mainEntry, entry: favouriteTargetEntry,
onPressed: onPressed, onPressed: onPressed,
); );
break; break;
@ -250,7 +239,7 @@ class _TopOverlayRow extends StatelessWidget {
// in app actions // in app actions
case EntryAction.toggleFavourite: case EntryAction.toggleFavourite:
child = _FavouriteToggler( child = _FavouriteToggler(
entry: mainEntry, entry: favouriteTargetEntry,
isMenuItem: true, isMenuItem: true,
); );
break; break;
@ -300,7 +289,7 @@ class _TopOverlayRow extends StatelessWidget {
void _onActionSelected(BuildContext context, EntryAction action) { void _onActionSelected(BuildContext context, EntryAction action) {
var targetEntry = mainEntry; var targetEntry = mainEntry;
if (mainEntry.isMultiPage && EntryActions.pageActions.contains(action)) { if (mainEntry.isMultiPage && (mainEntry.isBurst || EntryActions.pageActions.contains(action))) {
final multiPageController = context.read<MultiPageConductor>().getController(mainEntry); final multiPageController = context.read<MultiPageConductor>().getController(mainEntry);
if (multiPageController != null) { if (multiPageController != null) {
final multiPageInfo = multiPageController.info; final multiPageInfo = multiPageController.info;

View file

@ -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<MultiPageInfo?>(
stream: controller.infoStream,
builder: (context, snapshot) {
final multiPageInfo = controller.info;
return ValueListenableBuilder<int?>(
valueListenable: controller.pageNotifier,
builder: (context, page, child) {
final pageEntry = multiPageInfo?.getPageEntryByIndex(page);
return builder(pageEntry);
},
);
},
)
: builder(null);
}
}

View file

@ -48,7 +48,7 @@ class EntryPrinter with FeedbackMixin {
} }
if (entry.isMultiPage && !entry.isMotionPhoto) { if (entry.isMultiPage && !entry.isMotionPhoto) {
final multiPageInfo = await metadataService.getMultiPageInfo(entry); final multiPageInfo = await entry.getMultiPageInfo();
if (multiPageInfo != null) { if (multiPageInfo != null) {
final pageCount = multiPageInfo.pageCount; final pageCount = multiPageInfo.pageCount;
if (pageCount > 1) { if (pageCount > 1) {

View file

@ -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<Tuple2<String, ValueNotifier<ViewState>>> _controllers = [];
Size _viewportSize = Size.zero;
static const maxControllerCount = 3;
Future<void> dispose() async {
_controllers.clear();
}
set viewportSize(Size size) => _viewportSize = size;
ValueNotifier<ViewState> 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<ViewState>(initialValue));
}
_controllers.insert(0, controller);
while (_controllers.length > maxControllerCount) {
_controllers.removeLast().item2.dispose();
}
}
return controller.item2;
}
void reset(AvesEntry entry) {
final uris = <AvesEntry>{
entry,
...?entry.burstEntries,
}.map((v) => v.uri).toSet();
_controllers.removeWhere((kv) => uris.contains(kv.item1));
}
}

View file

@ -14,6 +14,7 @@ import 'package:aves/widgets/viewer/hero.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart'; import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/video/conductor.dart'; import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves/widgets/viewer/video/controller.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/error.dart';
import 'package:aves/widgets/viewer/visual/raster.dart'; import 'package:aves/widgets/viewer/visual/raster.dart';
import 'package:aves/widgets/viewer/visual/state.dart'; import 'package:aves/widgets/viewer/visual/state.dart';
@ -26,7 +27,6 @@ import 'package:provider/provider.dart';
class EntryPageView extends StatefulWidget { class EntryPageView extends StatefulWidget {
final AvesEntry mainEntry, pageEntry; final AvesEntry mainEntry, pageEntry;
final Size viewportSize;
final VoidCallback? onDisposed; final VoidCallback? onDisposed;
static const decorationCheckSize = 20.0; static const decorationCheckSize = 20.0;
@ -35,7 +35,6 @@ class EntryPageView extends StatefulWidget {
Key? key, Key? key,
required this.mainEntry, required this.mainEntry,
required this.pageEntry, required this.pageEntry,
required this.viewportSize,
this.onDisposed, this.onDisposed,
}) : super(key: key); }) : super(key: key);
@ -44,16 +43,14 @@ class EntryPageView extends StatefulWidget {
} }
class _EntryPageViewState extends State<EntryPageView> { class _EntryPageViewState extends State<EntryPageView> {
late ValueNotifier<ViewState> _viewStateNotifier;
late MagnifierController _magnifierController; late MagnifierController _magnifierController;
final ValueNotifier<ViewState> _viewStateNotifier = ValueNotifier(ViewState.zero);
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
AvesEntry get mainEntry => widget.mainEntry; AvesEntry get mainEntry => widget.mainEntry;
AvesEntry get entry => widget.pageEntry; AvesEntry get entry => widget.pageEntry;
Size get viewportSize => widget.viewportSize;
static const initialScale = ScaleLevel(ref: ScaleReference.contained); static const initialScale = ScaleLevel(ref: ScaleReference.contained);
static const minScale = ScaleLevel(ref: ScaleReference.contained); static const minScale = ScaleLevel(ref: ScaleReference.contained);
static const maxScale = ScaleLevel(factor: 2.0); static const maxScale = ScaleLevel(factor: 2.0);
@ -68,9 +65,7 @@ class _EntryPageViewState extends State<EntryPageView> {
void didUpdateWidget(covariant EntryPageView oldWidget) { void didUpdateWidget(covariant EntryPageView oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.pageEntry.uri != widget.pageEntry.uri || oldWidget.pageEntry.displaySize != widget.pageEntry.displaySize) { if (oldWidget.pageEntry != widget.pageEntry) {
// 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
_unregisterWidget(); _unregisterWidget();
_registerWidget(); _registerWidget();
} }
@ -84,19 +79,7 @@ class _EntryPageViewState extends State<EntryPageView> {
} }
void _registerWidget() { void _registerWidget() {
// try to initialize the view state to match magnifier initial state _viewStateNotifier = context.read<ViewStateConductor>().getOrCreateController(entry);
_viewStateNotifier.value = ViewState(
Offset.zero,
ScaleBoundaries(
minScale: minScale,
maxScale: maxScale,
initialScale: initialScale,
viewportSize: viewportSize,
childSize: entry.displaySize,
).initialScale,
viewportSize,
);
_magnifierController = MagnifierController(); _magnifierController = MagnifierController();
_subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged)); _subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged));
_subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged)); _subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged));
@ -134,7 +117,7 @@ class _EntryPageViewState extends State<EntryPageView> {
return Consumer<HeroInfo?>( return Consumer<HeroInfo?>(
builder: (context, info, child) => Hero( 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, transitionOnUserGestures: true,
child: child!, child: child!,
), ),
@ -241,7 +224,7 @@ class _EntryPageViewState extends State<EntryPageView> {
}) { }) {
return Magnifier( return Magnifier(
// key includes modified date to refresh when the image is modified by metadata (e.g. rotated) // 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, controller: _magnifierController,
childSize: displaySize ?? entry.displaySize, childSize: displaySize ?? entry.displaySize,
minScale: minScale, minScale: minScale,
@ -260,14 +243,12 @@ class _EntryPageViewState extends State<EntryPageView> {
final current = _viewStateNotifier.value; final current = _viewStateNotifier.value;
final viewState = ViewState(v.position, v.scale, current.viewportSize); final viewState = ViewState(v.position, v.scale, current.viewportSize);
_viewStateNotifier.value = viewState; _viewStateNotifier.value = viewState;
ViewStateNotification(entry.uri, viewState).dispatch(context);
} }
void _onViewScaleBoundariesChanged(ScaleBoundaries v) { void _onViewScaleBoundariesChanged(ScaleBoundaries v) {
final current = _viewStateNotifier.value; final current = _viewStateNotifier.value;
final viewState = ViewState(current.position, current.scale, v.viewportSize); final viewState = ViewState(current.position, current.scale, v.viewportSize);
_viewStateNotifier.value = viewState; _viewStateNotifier.value = viewState;
ViewStateNotification(entry.uri, viewState).dispatch(context);
} }
static ScaleState _vectorScaleStateCycle(ScaleState actual) { static ScaleState _vectorScaleStateCycle(ScaleState actual) {

View file

@ -13,13 +13,3 @@ class ViewState {
@override @override
String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, viewportSize=$viewportSize}'; 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}';
}