diff --git a/lib/model/entry/entry.dart.old b/lib/model/entry/entry.dart.old deleted file mode 100644 index 4a0f9239..00000000 --- a/lib/model/entry/entry.dart.old +++ /dev/null @@ -1,505 +0,0 @@ -import 'dart:async'; -import 'dart:ui'; - -import 'package:aves/model/entry/cache.dart'; -import 'package:aves/model/entry/dirs.dart'; -import 'package:aves/model/entry/extensions/keys.dart'; -import 'package:aves/model/metadata/address.dart'; -import 'package:aves/model/metadata/catalog.dart'; -import 'package:aves/model/metadata/trash.dart'; -import 'package:aves/ref/mime_types.dart'; -import 'package:aves/services/common/services.dart'; -import 'package:aves/theme/format.dart'; -import 'package:aves/utils/time_utils.dart'; -import 'package:aves_model/aves_model.dart'; -import 'package:aves_utils/aves_utils.dart'; -import 'package:flutter/foundation.dart'; -import 'package:leak_tracker/leak_tracker.dart'; - -enum EntryDataType { basic, aspectRatio, catalog, address, references } - -class AvesEntry with AvesEntryBase { - @override - int id; - - @override - String uri; - - @override - int? pageId; - - @override - int? sizeBytes; - - String? _path, _filename, _extension, _sourceTitle; - EntryDir? _directory; - int? contentId; - final String sourceMimeType; - int width, height, sourceRotationDegrees; - int? dateAddedSecs, _dateModifiedMillis, sourceDateTakenMillis, _durationMillis; - bool trashed; - int origin; - - int? _catalogDateMillis; - CatalogMetadata? _catalogMetadata; - AddressDetails? _addressDetails; - TrashDetails? trashDetails; - - // synthetic stack of related entries, e.g. burst shots or raw/developed pairs - List? stackedEntries; - - @override - final AChangeNotifier visualChangeNotifier = AChangeNotifier(); - - final AChangeNotifier metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier(); - - AvesEntry({ - required int? id, - required this.uri, - required String? path, - required this.contentId, - required this.pageId, - required this.sourceMimeType, - required this.width, - required this.height, - required this.sourceRotationDegrees, - required this.sizeBytes, - required String? sourceTitle, - required this.dateAddedSecs, - required int? dateModifiedMillis, - required this.sourceDateTakenMillis, - required int? durationMillis, - required this.trashed, - required this.origin, - this.stackedEntries, - }) : id = id ?? 0 { - if (kFlutterMemoryAllocationsEnabled) { - LeakTracking.dispatchObjectCreated( - library: 'aves', - className: '$AvesEntry', - object: this, - ); - } - this.path = path; - this.sourceTitle = sourceTitle; - this.dateModifiedMillis = dateModifiedMillis; - this.durationMillis = durationMillis; - } - - AvesEntry copyWith({ - int? id, - String? uri, - String? path, - int? contentId, - String? title, - int? dateAddedSecs, - int? dateModifiedMillis, - int? origin, - List? stackedEntries, - }) { - final copyEntryId = id ?? this.id; - final copied = - AvesEntry( - id: copyEntryId, - uri: uri ?? this.uri, - path: path ?? this.path, - contentId: contentId ?? this.contentId, - pageId: null, - sourceMimeType: sourceMimeType, - width: width, - height: height, - sourceRotationDegrees: sourceRotationDegrees, - sizeBytes: sizeBytes, - sourceTitle: title ?? sourceTitle, - dateAddedSecs: dateAddedSecs ?? this.dateAddedSecs, - dateModifiedMillis: dateModifiedMillis ?? this.dateModifiedMillis, - sourceDateTakenMillis: sourceDateTakenMillis, - durationMillis: durationMillis, - trashed: trashed, - origin: origin ?? this.origin, - stackedEntries: stackedEntries ?? this.stackedEntries, - ) - ..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId) - ..addressDetails = _addressDetails?.copyWith(id: copyEntryId) - ..trashDetails = trashDetails?.copyWith(id: copyEntryId); - - return copied; - } - - // from DB or platform source entry - factory AvesEntry.fromMap(Map map) { - return AvesEntry( - id: map[EntryFields.id] as int?, - uri: map[EntryFields.uri] as String, - path: map[EntryFields.path] as String?, - pageId: null, - contentId: map[EntryFields.contentId] as int?, - sourceMimeType: map[EntryFields.sourceMimeType] as String, - width: map[EntryFields.width] as int? ?? 0, - height: map[EntryFields.height] as int? ?? 0, - sourceRotationDegrees: map[EntryFields.sourceRotationDegrees] as int? ?? 0, - sizeBytes: map[EntryFields.sizeBytes] as int?, - sourceTitle: map[EntryFields.title] as String?, - dateAddedSecs: map[EntryFields.dateAddedSecs] as int?, - dateModifiedMillis: map[EntryFields.dateModifiedMillis] as int?, - sourceDateTakenMillis: map[EntryFields.sourceDateTakenMillis] as int?, - durationMillis: map[EntryFields.durationMillis] as int?, - trashed: (map[EntryFields.trashed] as int? ?? 0) != 0, - origin: map[EntryFields.origin] as int, - ); - } - - // for DB only - Map toDatabaseMap() { - return { - EntryFields.id: id, - EntryFields.uri: uri, - EntryFields.path: path, - EntryFields.contentId: contentId, - EntryFields.sourceMimeType: sourceMimeType, - EntryFields.width: width, - EntryFields.height: height, - EntryFields.sourceRotationDegrees: sourceRotationDegrees, - EntryFields.sizeBytes: sizeBytes, - EntryFields.title: sourceTitle, - EntryFields.dateAddedSecs: dateAddedSecs, - EntryFields.dateModifiedMillis: dateModifiedMillis, - EntryFields.sourceDateTakenMillis: sourceDateTakenMillis, - EntryFields.durationMillis: durationMillis, - EntryFields.trashed: trashed ? 1 : 0, - EntryFields.origin: origin, - }; - } - - Map toPlatformEntryMap() { - return { - EntryFields.uri: uri, - EntryFields.path: path, - EntryFields.pageId: pageId, - EntryFields.mimeType: mimeType, - EntryFields.width: width, - EntryFields.height: height, - EntryFields.rotationDegrees: rotationDegrees, - EntryFields.isFlipped: isFlipped, - EntryFields.dateModifiedMillis: dateModifiedMillis, - EntryFields.sizeBytes: sizeBytes, - EntryFields.trashed: trashed, - EntryFields.trashPath: trashDetails?.path, - EntryFields.origin: origin, - }; - } - - void dispose() { - if (kFlutterMemoryAllocationsEnabled) { - LeakTracking.dispatchObjectDisposed(object: this); - } - visualChangeNotifier.dispose(); - metadataChangeNotifier.dispose(); - addressChangeNotifier.dispose(); - } - - // do not implement [Object.==] and [Object.hashCode] using mutable attributes (e.g. `uri`) - // so that we can reliably use instances in a `Set`, which requires consistent hash codes over time - - @override - String toString() => '$runtimeType#${shortHash(this)}{id=$id, uri=$uri, path=$path, pageId=$pageId}'; - - set path(String? path) { - _path = path; - _directory = null; - _filename = null; - _extension = null; - _bestTitle = null; - } - - @override - String? get path => _path; - - // directory path, without the trailing separator - String? get directory { - _directory ??= entryDirRepo.getOrCreate(path != null ? pContext.dirname(path!) : null); - return _directory!.resolved; - } - - String? get filenameWithoutExtension { - _filename ??= path != null ? pContext.basenameWithoutExtension(path!) : null; - return _filename; - } - - // file extension, including the `.` - String? get extension { - _extension ??= path != null ? pContext.extension(path!) : null; - return _extension; - } - - // the MIME type reported by the Media Store is unreliable - // so we use the one found during cataloguing if possible - @override - String get mimeType => _catalogMetadata?.mimeType ?? sourceMimeType; - - bool get isCatalogued => _catalogMetadata != null; - - DateTime? _bestDate; - - DateTime? get bestDate { - _bestDate ??= dateTimeFromMillis(_catalogDateMillis) ?? dateTimeFromMillis(sourceDateTakenMillis) ?? dateTimeFromMillis(dateModifiedMillis ?? 0); - return _bestDate; - } - - @override - bool get isAnimated => catalogMetadata?.isAnimated ?? false; - - bool get isHdr => _catalogMetadata?.isHdr ?? false; - - int get rating => _catalogMetadata?.rating ?? 0; - - @override - int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees; - - set rotationDegrees(int rotationDegrees) { - sourceRotationDegrees = rotationDegrees; - _catalogMetadata?.rotationDegrees = rotationDegrees; - } - - bool get isFlipped => _catalogMetadata?.isFlipped ?? false; - - set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped; - - // Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata, - // so it should be registered as width=1920, height=1080, orientation=90, - // but is incorrectly registered as width=1080, height=1920, orientation=0. - // Double-checking the width/height during loading or cataloguing is the proper solution, but it would take space and time. - // Comparing width and height can help with the portrait FHD video example, - // but it fails for a portrait screenshot rotated, which is landscape with width=1080, height=1920, orientation=90 - bool get isRotated => rotationDegrees % 180 == 90; - - @override - double get displayAspectRatio { - if (width == 0 || height == 0) return 1; - return isRotated ? height / width : width / height; - } - - @override - Size get displaySize { - final w = width.toDouble(); - final h = height.toDouble(); - return isRotated ? Size(h, w) : Size(w, h); - } - - String? get sourceTitle => _sourceTitle; - - set sourceTitle(String? sourceTitle) { - _sourceTitle = sourceTitle; - _bestTitle = null; - } - - int? get dateModifiedMillis => _dateModifiedMillis; - - set dateModifiedMillis(int? dateModifiedMillis) { - _dateModifiedMillis = dateModifiedMillis; - _bestDate = null; - } - - // TODO TLAD cache _monthTaken - DateTime? get monthTaken { - final d = bestDate; - return d == null ? null : DateTime(d.year, d.month); - } - - // TODO TLAD cache _dayTaken - DateTime? get dayTaken { - final d = bestDate; - return d == null ? null : DateTime(d.year, d.month, d.day); - } - - @override - int? get durationMillis => _durationMillis; - - set durationMillis(int? durationMillis) { - _durationMillis = durationMillis; - _durationText = null; - } - - String? _durationText; - - String get durationText { - _durationText ??= formatFriendlyDuration(Duration(milliseconds: durationMillis ?? 0)); - return _durationText!; - } - - // returns whether this entry has GPS coordinates - // (0, 0) coordinates are considered invalid, as it is likely a default value - bool get hasGps => (_catalogMetadata?.latitude ?? 0) != 0 || (_catalogMetadata?.longitude ?? 0) != 0; - - bool get hasAddress => _addressDetails != null; - - // has a place, or at least the full country name - // derived from Google reverse geocoding addresses - bool get hasFineAddress => _addressDetails?.place?.isNotEmpty == true || (_addressDetails?.countryName?.length ?? 0) > 3; - - Set? _tags; - - Set get tags { - _tags ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toSet() ?? {}; - return _tags!; - } - - String? _bestTitle; - - @override - String? get bestTitle { - _bestTitle ??= _catalogMetadata?.xmpTitle?.isNotEmpty == true ? _catalogMetadata!.xmpTitle : (filenameWithoutExtension ?? sourceTitle); - return _bestTitle; - } - - int? get catalogDateMillis => _catalogDateMillis; - - set catalogDateMillis(int? dateMillis) { - _catalogDateMillis = dateMillis; - _bestDate = null; - } - - CatalogMetadata? get catalogMetadata => _catalogMetadata; - - set catalogMetadata(CatalogMetadata? newMetadata) { - final oldMimeType = mimeType; - final oldDateModifiedMillis = dateModifiedMillis; - final oldRotationDegrees = rotationDegrees; - final oldIsFlipped = isFlipped; - - catalogDateMillis = newMetadata?.dateMillis; - _catalogMetadata = newMetadata; - _bestTitle = null; - _tags = null; - metadataChangeNotifier.notify(); - - _onVisualFieldChanged(oldMimeType, oldDateModifiedMillis, oldRotationDegrees, oldIsFlipped); - } - - void clearMetadata() { - catalogMetadata = null; - addressDetails = null; - } - - AddressDetails? get addressDetails => _addressDetails; - - set addressDetails(AddressDetails? newAddress) { - _addressDetails = newAddress; - addressChangeNotifier.notify(); - } - - String get shortAddress { - // `admin area` examples: Seoul, Geneva, null - // `locality` examples: Mapo-gu, Geneva, Annecy - return { - _addressDetails?.countryName, - _addressDetails?.adminArea, - _addressDetails?.locality, - }.nonNulls.where((v) => v.isNotEmpty).join(', '); - } - - static void normalizeMimeTypeFields(Map fields) { - final mimeType = fields[EntryFields.mimeType] as String?; - if (mimeType != null) { - fields[EntryFields.mimeType] = MimeTypes.normalize(mimeType); - } - final sourceMimeType = fields[EntryFields.sourceMimeType] as String?; - if (sourceMimeType != null) { - fields[EntryFields.sourceMimeType] = MimeTypes.normalize(sourceMimeType); - } - } - - Future applyNewFields(Map newFields, {required bool persist}) async { - final oldMimeType = mimeType; - final oldDateModifiedMillis = this.dateModifiedMillis; - final oldRotationDegrees = this.rotationDegrees; - final oldIsFlipped = this.isFlipped; - - final uri = newFields[EntryFields.uri]; - if (uri is String) this.uri = uri; - final path = newFields[EntryFields.path]; - if (path is String) this.path = path; - final contentId = newFields[EntryFields.contentId]; - if (contentId is int) this.contentId = contentId; - - final sourceTitle = newFields[EntryFields.title]; - if (sourceTitle is String) this.sourceTitle = sourceTitle; - final sourceRotationDegrees = newFields[EntryFields.sourceRotationDegrees]; - if (sourceRotationDegrees is int) this.sourceRotationDegrees = sourceRotationDegrees; - final sourceDateTakenMillis = newFields[EntryFields.sourceDateTakenMillis]; - if (sourceDateTakenMillis is int) this.sourceDateTakenMillis = sourceDateTakenMillis; - - final width = newFields[EntryFields.width]; - if (width is int) this.width = width; - final height = newFields[EntryFields.height]; - if (height is int) this.height = height; - final durationMillis = newFields[EntryFields.durationMillis]; - if (durationMillis is int) this.durationMillis = durationMillis; - - final sizeBytes = newFields[EntryFields.sizeBytes]; - if (sizeBytes is int) this.sizeBytes = sizeBytes; - final dateModifiedMillis = newFields[EntryFields.dateModifiedMillis]; - if (dateModifiedMillis is int) this.dateModifiedMillis = dateModifiedMillis; - final rotationDegrees = newFields[EntryFields.rotationDegrees]; - if (rotationDegrees is int) this.rotationDegrees = rotationDegrees; - final isFlipped = newFields[EntryFields.isFlipped]; - if (isFlipped is bool) this.isFlipped = isFlipped; - - if (persist) { - await localMediaDb.updateEntry(id, this); - if (catalogMetadata != null) await localMediaDb.saveCatalogMetadata({catalogMetadata!}); - } - - await _onVisualFieldChanged(oldMimeType, oldDateModifiedMillis, oldRotationDegrees, oldIsFlipped); - metadataChangeNotifier.notify(); - } - - Future refresh({ - required bool background, - required bool persist, - required Set dataTypes, - }) async { - // clear derived fields - _bestDate = null; - _bestTitle = null; - _tags = null; - - if (persist) { - await localMediaDb.removeIds({id}, dataTypes: dataTypes); - } - - final updatedEntry = await mediaFetchService.getEntry(uri, mimeType); - if (updatedEntry != null) { - await applyNewFields(updatedEntry.toDatabaseMap(), persist: persist); - } - } - - Future delete() { - final opCompleter = Completer(); - mediaEditService - .delete(entries: {this}) - .listen( - (event) => opCompleter.complete(event.success && !event.skipped), - onError: opCompleter.completeError, - onDone: () { - if (!opCompleter.isCompleted) { - opCompleter.complete(false); - } - }, - ); - return opCompleter.future; - } - - // when the MIME type or the image itself changed (e.g. after rotation) - Future _onVisualFieldChanged( - String oldMimeType, - int? oldDateModifiedMillis, - int oldRotationDegrees, - bool oldIsFlipped, - ) async { - if ((!MimeTypes.refersToSameType(oldMimeType, mimeType) && !MimeTypes.isVideo(oldMimeType)) || oldDateModifiedMillis != dateModifiedMillis || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { - await EntryCache.evict(uri, oldMimeType, oldDateModifiedMillis, oldRotationDegrees, oldIsFlipped, isAnimated); - visualChangeNotifier.notify(); - } - } -} diff --git a/lib/widgets/viewer/view/conductor.dart b/lib/widgets/viewer/view/conductor.dart index f81dd7af..529238ec 100644 --- a/lib/widgets/viewer/view/conductor.dart +++ b/lib/widgets/viewer/view/conductor.dart @@ -40,11 +40,6 @@ class ViewStateConductor { } else { // try to initialize the view state to match magnifier initial state const initialScale = ScaleLevel(ref: ScaleReference.contained); - - final Size contentSize = (entry.isRemote && entry.remoteWidth != null && entry.remoteHeight != null) - ? Size(entry.remoteWidth!.toDouble(), entry.remoteHeight!.toDouble()) - : entry.displaySize; - final initialValue = ViewState( position: Offset.zero, scale: ScaleBoundaries( @@ -53,12 +48,11 @@ class ViewStateConductor { maxScale: initialScale, initialScale: initialScale, viewportSize: _viewportSize, - contentSize: contentSize, + contentSize: entry.displaySize, ).initialScale, viewportSize: _viewportSize, - contentSize: contentSize, + contentSize: entry.displaySize, ); - controller = ViewStateController( entry: entry, viewStateNotifier: ValueNotifier(initialValue), diff --git a/lib/widgets/viewer/view/conductor.dart.old b/lib/widgets/viewer/view/conductor.dart.new similarity index 89% rename from lib/widgets/viewer/view/conductor.dart.old rename to lib/widgets/viewer/view/conductor.dart.new index 529238ec..f81dd7af 100644 --- a/lib/widgets/viewer/view/conductor.dart.old +++ b/lib/widgets/viewer/view/conductor.dart.new @@ -40,6 +40,11 @@ class ViewStateConductor { } else { // try to initialize the view state to match magnifier initial state const initialScale = ScaleLevel(ref: ScaleReference.contained); + + final Size contentSize = (entry.isRemote && entry.remoteWidth != null && entry.remoteHeight != null) + ? Size(entry.remoteWidth!.toDouble(), entry.remoteHeight!.toDouble()) + : entry.displaySize; + final initialValue = ViewState( position: Offset.zero, scale: ScaleBoundaries( @@ -48,11 +53,12 @@ class ViewStateConductor { maxScale: initialScale, initialScale: initialScale, viewportSize: _viewportSize, - contentSize: entry.displaySize, + contentSize: contentSize, ).initialScale, viewportSize: _viewportSize, - contentSize: entry.displaySize, + contentSize: contentSize, ); + controller = ViewStateController( entry: entry, viewStateNotifier: ValueNotifier(initialValue), diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index c70b0365..1f053427 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -97,24 +97,6 @@ class _EntryPageViewState extends State with TickerProviderStateM _magnifierController = AvesMagnifierController(); _subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged)); _subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged)); - - // PATCH2 -WidgetsBinding.instance.addPostFrameCallback((_) { - final boundaries = _magnifierController.scaleBoundaries; - if (boundaries != null) { - final initial = boundaries.initialScale; - _magnifierController.update( - scale: initial, - source: ChangeSource.animation, - ); - } -}); - - - - - - if (entry.isVideo) { _subscriptions.add(mediaSessionService.mediaCommands.listen(_onMediaCommand)); } @@ -418,23 +400,6 @@ WidgetsBinding.instance.addPostFrameCallback((_) { final isWallpaperMode = context.read>().value == AppMode.setWallpaper; final minScale = isWallpaperMode ? const ScaleLevel(ref: ScaleReference.covered) : const ScaleLevel(ref: ScaleReference.contained); -// DEBUG REMOTE - final effectiveContentSize = displaySize ?? - (entry.isRemote && entry.remoteWidth != null && entry.remoteHeight != null - ? Size(entry.remoteWidth!.toDouble(), entry.remoteHeight!.toDouble()) - : entry.displaySize); - - // ignore: avoid_print - print('DEBUG REMOTE: ' - 'uri=${entry.uri} ' - 'isRemote=${entry.isRemote} ' - 'remoteWidth=${entry.remoteWidth} ' - 'remoteHeight=${entry.remoteHeight} ' - 'entry.displaySize=${entry.displaySize} ' - 'effectiveContentSize=$effectiveContentSize'); - - - return ValueListenableBuilder( valueListenable: AvesApp.canGestureToOtherApps, builder: (context, canGestureToOtherApps, child) { @@ -442,7 +407,7 @@ WidgetsBinding.instance.addPostFrameCallback((_) { // key includes modified date to refresh when the image is modified by metadata (e.g. rotated) key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedMillis}'), controller: controller ?? _magnifierController, - contentSize: effectiveContentSize, + contentSize: displaySize ?? entry.displaySize, allowOriginalScaleBeyondRange: !isWallpaperMode, allowDoubleTap: _allowDoubleTap, minScale: minScale, @@ -564,51 +529,13 @@ WidgetsBinding.instance.addPostFrameCallback((_) { ); } - - -// PATCH3 -void _onViewScaleBoundariesChanged(ScaleBoundaries v) { - _viewStateNotifier.value = _viewStateNotifier.value.copyWith( - viewportSize: v.viewportSize, - contentSize: v.contentSize, - ); - - if (v.viewportSize.width <= 0 || v.viewportSize.height <= 0) return; - - final vw = v.viewportSize.width; - final vh = v.viewportSize.height; - - // dimensioni di base - double cw = v.contentSize.width; - double ch = v.contentSize.height; - - // se l’immagine è ruotata di 90°/270°, inverti larghezza/altezza - final rotation = entry.rotationDegrees ?? 0; - if (rotation == 90 || rotation == 270) { - final tmp = cw; - cw = ch; - ch = tmp; + void _onViewScaleBoundariesChanged(ScaleBoundaries v) { + _viewStateNotifier.value = _viewStateNotifier.value.copyWith( + viewportSize: v.viewportSize, + contentSize: v.contentSize, + ); } - double scale; - - if (entry.isRemote) { - // qui puoi scegliere la politica: ad es. fit “contenuto” intelligente - // per non zoomare troppo né lasciarla minuscola - final sx = vw / cw; - final sy = vh / ch; - // ad esempio: usa il min (contained) ma con orientamento corretto - scale = sx < sy ? sx : sy; - } else { - scale = v.initialScale; - } - - _magnifierController.update( - scale: scale, - source: ChangeSource.animation, - ); -} - double? _getSideRatio() { if (!mounted) return null; final isPortrait = MediaQuery.orientationOf(context) == Orientation.portrait; diff --git a/lib/widgets/viewer/visual/entry_page_view.dart.new b/lib/widgets/viewer/visual/entry_page_view.dart.new new file mode 100644 index 00000000..c70b0365 --- /dev/null +++ b/lib/widgets/viewer/visual/entry_page_view.dart.new @@ -0,0 +1,626 @@ +import 'dart:async'; + +import 'package:aves/app_mode.dart'; +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/model/entry/extensions/props.dart'; +import 'package:aves/model/settings/enums/widget_outline.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/viewer/view_state.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/services/media/media_session_service.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/view/view.dart'; +import 'package:aves/widgets/aves_app.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/basic/insets.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/home_widget.dart'; +import 'package:aves/widgets/viewer/controls/controller.dart'; +import 'package:aves/widgets/viewer/controls/notifications.dart'; +import 'package:aves/widgets/viewer/hero.dart'; +import 'package:aves/widgets/viewer/video/conductor.dart'; +import 'package:aves/widgets/viewer/view/conductor.dart'; +import 'package:aves/widgets/viewer/visual/error.dart'; +import 'package:aves/widgets/viewer/visual/raster.dart'; +import 'package:aves/widgets/viewer/visual/vector.dart'; +import 'package:aves/widgets/viewer/visual/video/cover.dart'; +import 'package:aves/widgets/viewer/visual/video/subtitle/subtitle.dart'; +import 'package:aves/widgets/viewer/visual/video/swipe_action.dart'; +import 'package:aves/widgets/viewer/visual/video/video_view.dart'; +import 'package:aves_magnifier/aves_magnifier.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:decorated_icon/decorated_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class EntryPageView extends StatefulWidget { + final AvesEntry mainEntry, pageEntry; + final ViewerController viewerController; + final VoidCallback? onDisposed; + + static const decorationCheckSize = 20.0; + static const rasterMaxScale = ScaleLevel(factor: 5); + static const vectorMaxScale = ScaleLevel(factor: 25); + + const EntryPageView({ + super.key, + required this.mainEntry, + required this.pageEntry, + required this.viewerController, + this.onDisposed, + }); + + @override + State createState() => _EntryPageViewState(); +} + +class _EntryPageViewState extends State with TickerProviderStateMixin { + late ValueNotifier _viewStateNotifier; + late AvesMagnifierController _magnifierController; + final Set _subscriptions = {}; + final ValueNotifier _actionFeedbackChildNotifier = ValueNotifier(null); + OverlayEntry? _actionFeedbackOverlayEntry; + + AvesEntry get mainEntry => widget.mainEntry; + + AvesEntry get entry => widget.pageEntry; + + ViewerController get viewerController => widget.viewerController; + + @override + void initState() { + super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(covariant EntryPageView oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.pageEntry != widget.pageEntry) { + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + } + + @override + void dispose() { + _unregisterWidget(widget); + widget.onDisposed?.call(); + _actionFeedbackChildNotifier.dispose(); + super.dispose(); + } + + void _registerWidget(EntryPageView widget) { + final entry = widget.pageEntry; + _viewStateNotifier = context.read().getOrCreateController(entry).viewStateNotifier; + _magnifierController = AvesMagnifierController(); + _subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged)); + _subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged)); + + // PATCH2 +WidgetsBinding.instance.addPostFrameCallback((_) { + final boundaries = _magnifierController.scaleBoundaries; + if (boundaries != null) { + final initial = boundaries.initialScale; + _magnifierController.update( + scale: initial, + source: ChangeSource.animation, + ); + } +}); + + + + + + + if (entry.isVideo) { + _subscriptions.add(mediaSessionService.mediaCommands.listen(_onMediaCommand)); + } + viewerController.startAutopilotAnimation( + vsync: this, + onUpdate: ({required scaleLevel}) { + final boundaries = _magnifierController.scaleBoundaries; + if (boundaries != null) { + final scale = boundaries.scaleForLevel(scaleLevel); + _magnifierController.update(scale: scale, source: ChangeSource.animation); + } + }, + ); + } + + void _unregisterWidget(EntryPageView oldWidget) { + viewerController.stopAutopilotAnimation(vsync: this); + _magnifierController.dispose(); + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + } + + @override + Widget build(BuildContext context) { + Widget child = AnimatedBuilder( + animation: entry.visualChangeNotifier, + builder: (context, child) { + Widget? child; + if (entry.isSvg) { + child = _buildSvgView(); + } else if (!entry.displaySize.isEmpty) { + if (entry.isVideo) { + child = _buildVideoView(); + } else if (entry.isDecodingSupported) { + child = _buildRasterView(); + } + } + + child ??= ErrorView( + entry: entry, + onTap: _onTap, + ); + return child; + }, + ); + + if (!settings.viewerUseCutout) { + child = SafeCutoutArea( + child: ClipRect( + child: child, + ), + ); + } + + final animate = context.select((v) => v.animate); + if (animate) { + child = Consumer( + builder: (context, info, child) => Hero( + tag: info != null && info.entry == mainEntry ? info.tag : hashCode, + transitionOnUserGestures: true, + child: child!, + ), + child: child, + ); + } + + return child; + } + + Widget _buildRasterView() { + return _buildMagnifier( + applyScale: false, + child: RasterImageView( + entry: entry, + viewStateNotifier: _viewStateNotifier, + errorBuilder: (context, error, stackTrace) => ErrorView( + entry: entry, + onTap: _onTap, + ), + ), + ); + } + + Widget _buildSvgView() { + return _buildMagnifier( + maxScale: EntryPageView.vectorMaxScale, + scaleStateCycle: _vectorScaleStateCycle, + applyScale: false, + child: VectorImageView( + entry: entry, + viewStateNotifier: _viewStateNotifier, + errorBuilder: (context, error, stackTrace) => ErrorView( + entry: entry, + onTap: _onTap, + ), + ), + ); + } + + Widget _buildVideoView() { + final videoController = context.read().getController(entry); + if (videoController == null) return const SizedBox(); + + return ValueListenableBuilder( + valueListenable: videoController.sarNotifier, + builder: (context, sar, child) { + final videoDisplaySize = entry.videoDisplaySize(sar); + final isPureVideo = entry.isPureVideo; + + return Selector( + selector: (context, s) => ( + isPureVideo && s.videoGestureDoubleTapTogglePlay, + isPureVideo && s.videoGestureSideDoubleTapSeek, + isPureVideo && s.videoGestureVerticalDragBrightnessVolume, + ), + builder: (context, s, child) { + final (playGesture, seekGesture, useVerticalDragGesture) = s; + final useTapGesture = playGesture || seekGesture; + + MagnifierDoubleTapCallback? onDoubleTap; + MagnifierGestureScaleStartCallback? onScaleStart; + MagnifierGestureScaleUpdateCallback? onScaleUpdate; + MagnifierGestureScaleEndCallback? onScaleEnd; + + if (useTapGesture) { + void _applyAction(EntryAction action, {IconData? Function()? icon}) { + _actionFeedbackChildNotifier.value = DecoratedIcon( + icon?.call() ?? action.getIconData(), + size: 48, + color: Colors.white, + shadows: const [ + Shadow( + color: Colors.black, + blurRadius: 4, + ), + ], + ); + VideoActionNotification( + controller: videoController, + entry: entry, + action: action, + ).dispatch(context); + } + + onDoubleTap = (alignment) { + final x = alignment.x; + if (seekGesture) { + final sideRatio = _getSideRatio(); + if (sideRatio != null) { + if (x < sideRatio) { + _applyAction(EntryAction.videoReplay10); + return true; + } else if (x > 1 - sideRatio) { + _applyAction(EntryAction.videoSkip10); + return true; + } + } + } + if (playGesture) { + _applyAction( + EntryAction.videoTogglePlay, + icon: () => videoController.isPlaying ? AIcons.pause : AIcons.play, + ); + return true; + } + return false; + }; + } + + if (useVerticalDragGesture) { + SwipeAction? swipeAction; + var move = Offset.zero; + var dropped = false; + double? startValue; + ValueNotifier? valueNotifier; + + onScaleStart = (details, doubleTap, boundaries) { + dropped = details.pointerCount > 1 || doubleTap; + if (dropped) return; + + startValue = null; + valueNotifier = ValueNotifier(null); + final alignmentX = details.focalPoint.dx / boundaries.viewportSize.width; + final action = alignmentX > .5 ? SwipeAction.volume : SwipeAction.brightness; + action.get().then((v) => startValue = v); + swipeAction = action; + move = Offset.zero; + _actionFeedbackOverlayEntry = OverlayEntry( + builder: (context) => SwipeActionFeedback( + action: action, + valueNotifier: valueNotifier!, + ), + ); + Overlay.of(context).insert(_actionFeedbackOverlayEntry!); + }; + onScaleUpdate = (details) { + if (valueNotifier == null) return false; + + move += details.focalPointDelta; + dropped |= details.pointerCount > 1; + if (valueNotifier!.value == null) { + dropped |= MagnifierGestureRecognizer.isXPan(move); + } + if (dropped) return false; + + final _startValue = startValue; + if (_startValue != null) { + final double value = (_startValue - move.dy / SwipeActionFeedback.height).clamp(0, 1); + valueNotifier!.value = value; + swipeAction?.set(value); + } + return true; + }; + onScaleEnd = (details) { + valueNotifier?.dispose(); + + _actionFeedbackOverlayEntry + ?..remove() + ..dispose(); + _actionFeedbackOverlayEntry = null; + }; + } + + Widget videoChild = Stack( + children: [ + _buildMagnifier( + displaySize: videoDisplaySize, + onScaleStart: onScaleStart, + onScaleUpdate: onScaleUpdate, + onScaleEnd: onScaleEnd, + onDoubleTap: onDoubleTap, + child: VideoView( + entry: entry, + controller: videoController, + ), + ), + VideoSubtitles( + entry: entry, + controller: videoController, + viewStateNotifier: _viewStateNotifier, + ), + if (useTapGesture) + ValueListenableBuilder( + valueListenable: _actionFeedbackChildNotifier, + builder: (context, feedbackChild, child) => ActionFeedback( + child: feedbackChild, + ), + ), + ], + ); + if (useVerticalDragGesture) { + final scope = MagnifierGestureDetectorScope.maybeOf(context); + if (scope != null) { + videoChild = scope.copyWith( + acceptPointerEvent: MagnifierGestureRecognizer.isYPan, + child: videoChild, + ); + } + } + return Stack( + fit: StackFit.expand, + children: [ + videoChild, + VideoCover( + mainEntry: mainEntry, + pageEntry: entry, + magnifierController: _magnifierController, + videoController: videoController, + videoDisplaySize: videoDisplaySize, + onTap: _onTap, + magnifierBuilder: (coverController, coverSize, videoCoverUriImage) => _buildMagnifier( + controller: coverController, + displaySize: coverSize, + onDoubleTap: onDoubleTap, + child: Image( + image: videoCoverUriImage, + ), + ), + ), + ], + ); + }, + ); + }, + ); + } + + Widget _buildMagnifier({ + AvesMagnifierController? controller, + Size? displaySize, + ScaleLevel maxScale = EntryPageView.rasterMaxScale, + ScaleStateCycle scaleStateCycle = defaultScaleStateCycle, + bool applyScale = true, + MagnifierGestureScaleStartCallback? onScaleStart, + MagnifierGestureScaleUpdateCallback? onScaleUpdate, + MagnifierGestureScaleEndCallback? onScaleEnd, + MagnifierDoubleTapCallback? onDoubleTap, + required Widget child, + }) { + final isWallpaperMode = context.read>().value == AppMode.setWallpaper; + final minScale = isWallpaperMode ? const ScaleLevel(ref: ScaleReference.covered) : const ScaleLevel(ref: ScaleReference.contained); + +// DEBUG REMOTE + final effectiveContentSize = displaySize ?? + (entry.isRemote && entry.remoteWidth != null && entry.remoteHeight != null + ? Size(entry.remoteWidth!.toDouble(), entry.remoteHeight!.toDouble()) + : entry.displaySize); + + // ignore: avoid_print + print('DEBUG REMOTE: ' + 'uri=${entry.uri} ' + 'isRemote=${entry.isRemote} ' + 'remoteWidth=${entry.remoteWidth} ' + 'remoteHeight=${entry.remoteHeight} ' + 'entry.displaySize=${entry.displaySize} ' + 'effectiveContentSize=$effectiveContentSize'); + + + + return ValueListenableBuilder( + valueListenable: AvesApp.canGestureToOtherApps, + builder: (context, canGestureToOtherApps, child) { + return AvesMagnifier( + // key includes modified date to refresh when the image is modified by metadata (e.g. rotated) + key: Key('${entry.uri}_${entry.pageId}_${entry.dateModifiedMillis}'), + controller: controller ?? _magnifierController, + contentSize: effectiveContentSize, + allowOriginalScaleBeyondRange: !isWallpaperMode, + allowDoubleTap: _allowDoubleTap, + minScale: minScale, + maxScale: maxScale, + initialScale: viewerController.initialScale, + scaleStateCycle: scaleStateCycle, + applyScale: applyScale, + onScaleStart: onScaleStart, + onScaleUpdate: onScaleUpdate, + onScaleEnd: onScaleEnd, + onFling: _onFling, + onTap: (context, _, alignment, _) { + if (context.mounted) { + _onTap(alignment: alignment); + } + }, + onLongPress: canGestureToOtherApps ? _startGlobalDrag : null, + onDoubleTap: onDoubleTap, + child: child!, + ); + }, + child: child, + ); + } + + Future _startGlobalDrag() async { + const dragShadowSize = Size.square(128); + final cornerRadiusPx = await deviceService.getWidgetCornerRadiusPx(); + + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + final brightness = Theme.of(context).brightness; + final outline = await WidgetOutline.systemBlackAndWhite.color(brightness); + + final dragShadowBytes = + await HomeWidgetPainter( + entry: entry, + devicePixelRatio: devicePixelRatio, + ).drawWidget( + sizeDip: dragShadowSize, + cornerRadiusPx: cornerRadiusPx, + outline: outline, + shape: WidgetShape.rrect, + ); + + await windowService.startGlobalDrag(entry.uri, entry.bestTitle, dragShadowSize, dragShadowBytes); + } + + void _onFling(AxisDirection direction) { + const animate = true; + switch (direction) { + case AxisDirection.left: + (context.isRtl ? const ShowNextEntryNotification(animate: animate) : const ShowPreviousEntryNotification(animate: animate)).dispatch(context); + case AxisDirection.right: + (context.isRtl ? const ShowPreviousEntryNotification(animate: animate) : const ShowNextEntryNotification(animate: animate)).dispatch(context); + case AxisDirection.up: + PopVisualNotification().dispatch(context); + case AxisDirection.down: + ShowInfoPageNotification().dispatch(context); + } + } + + Notification? _handleSideSingleTap(Alignment? alignment) { + if (settings.viewerGestureSideTapNext && alignment != null) { + final x = alignment.x; + final sideRatio = _getSideRatio(); + if (sideRatio != null) { + const animate = false; + if (x < sideRatio) { + return context.isRtl ? const ShowNextEntryNotification(animate: animate) : const ShowPreviousEntryNotification(animate: animate); + } else if (x > 1 - sideRatio) { + return context.isRtl ? const ShowPreviousEntryNotification(animate: animate) : const ShowNextEntryNotification(animate: animate); + } + } + } + return null; + } + + void _onTap({Alignment? alignment}) => (_handleSideSingleTap(alignment) ?? const ToggleOverlayNotification()).dispatch(context); + + // side gesture handling by precedence: + // - seek in video by side double tap (if enabled) + // - go to previous/next entry by side single tap (if enabled) + // - zoom in/out by double tap + bool _allowDoubleTap(Alignment alignment) { + if (entry.isVideo && settings.videoGestureSideDoubleTapSeek) { + return true; + } + final actionNotification = _handleSideSingleTap(alignment); + return actionNotification == null; + } + + void _onMediaCommand(MediaCommandEvent event) { + final videoController = context.read().getController(entry); + if (videoController == null) return; + + switch (event.command) { + case MediaCommand.play: + videoController.play(); + case MediaCommand.pause: + videoController.pause(); + case MediaCommand.skipToNext: + ShowNextVideoNotification().dispatch(context); + case MediaCommand.skipToPrevious: + ShowPreviousVideoNotification().dispatch(context); + case MediaCommand.stop: + videoController.pause(); + case MediaCommand.seek: + if (event is MediaSeekCommandEvent) { + videoController.seekTo(event.position); + } + } + } + + void _onViewStateChanged(MagnifierState v) { + if (!mounted) return; + _viewStateNotifier.value = _viewStateNotifier.value.copyWith( + position: v.position, + scale: v.scale, + ); + } + + + +// PATCH3 +void _onViewScaleBoundariesChanged(ScaleBoundaries v) { + _viewStateNotifier.value = _viewStateNotifier.value.copyWith( + viewportSize: v.viewportSize, + contentSize: v.contentSize, + ); + + if (v.viewportSize.width <= 0 || v.viewportSize.height <= 0) return; + + final vw = v.viewportSize.width; + final vh = v.viewportSize.height; + + // dimensioni di base + double cw = v.contentSize.width; + double ch = v.contentSize.height; + + // se l’immagine è ruotata di 90°/270°, inverti larghezza/altezza + final rotation = entry.rotationDegrees ?? 0; + if (rotation == 90 || rotation == 270) { + final tmp = cw; + cw = ch; + ch = tmp; + } + + double scale; + + if (entry.isRemote) { + // qui puoi scegliere la politica: ad es. fit “contenuto” intelligente + // per non zoomare troppo né lasciarla minuscola + final sx = vw / cw; + final sy = vh / ch; + // ad esempio: usa il min (contained) ma con orientamento corretto + scale = sx < sy ? sx : sy; + } else { + scale = v.initialScale; + } + + _magnifierController.update( + scale: scale, + source: ChangeSource.animation, + ); +} + + double? _getSideRatio() { + if (!mounted) return null; + final isPortrait = MediaQuery.orientationOf(context) == Orientation.portrait; + return isPortrait ? 1 / 6 : 1 / 8; + } + + static ScaleState _vectorScaleStateCycle(ScaleState actual) { + switch (actual) { + case ScaleState.initial: + return ScaleState.covering; + default: + return ScaleState.initial; + } + } +}