#6 burst support (samsung camera naming pattern)
This commit is contained in:
parent
4d7b9e9065
commit
6c2dc791d5
30 changed files with 504 additions and 310 deletions
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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!;
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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(() {});
|
||||||
|
|
|
@ -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();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
35
lib/widgets/viewer/page_entry_builder.dart
Normal file
35
lib/widgets/viewer/page_entry_builder.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
59
lib/widgets/viewer/visual/conductor.dart
Normal file
59
lib/widgets/viewer/visual/conductor.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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}';
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in a new issue