ok3
Some checks failed
Quality check / Flutter analysis (push) Has been cancelled
Quality check / CodeQL analysis (java-kotlin) (push) Has been cancelled

This commit is contained in:
FabioMich66 2026-03-08 13:24:52 +01:00
parent 507c131502
commit 4925c6e3eb
5 changed files with 642 additions and 594 deletions

View file

@ -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<AvesEntry>? 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<AvesEntry>? 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<String, dynamic> 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<String, dynamic> 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<String>? _tags;
Set<String> 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<void> 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<void> refresh({
required bool background,
required bool persist,
required Set<EntryDataType> 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<bool> delete() {
final opCompleter = Completer<bool>();
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<void> _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();
}
}
}

View file

@ -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<ViewState>(initialValue),

View file

@ -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<ViewState>(initialValue),

View file

@ -97,24 +97,6 @@ class _EntryPageViewState extends State<EntryPageView> 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<ValueNotifier<AppMode>>().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<bool>(
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 limmagine è 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 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;

View file

@ -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<EntryPageView> createState() => _EntryPageViewState();
}
class _EntryPageViewState extends State<EntryPageView> with TickerProviderStateMixin {
late ValueNotifier<ViewState> _viewStateNotifier;
late AvesMagnifierController _magnifierController;
final Set<StreamSubscription> _subscriptions = {};
final ValueNotifier<Widget?> _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<ViewStateConductor>().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<Settings, bool>((v) => v.animate);
if (animate) {
child = Consumer<EntryHeroInfo?>(
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<VideoConductor>().getController(entry);
if (videoController == null) return const SizedBox();
return ValueListenableBuilder<double?>(
valueListenable: videoController.sarNotifier,
builder: (context, sar, child) {
final videoDisplaySize = entry.videoDisplaySize(sar);
final isPureVideo = entry.isPureVideo;
return Selector<Settings, (bool, bool, bool)>(
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<double?>? valueNotifier;
onScaleStart = (details, doubleTap, boundaries) {
dropped = details.pointerCount > 1 || doubleTap;
if (dropped) return;
startValue = null;
valueNotifier = ValueNotifier<double?>(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<Widget?>(
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<ValueNotifier<AppMode>>().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<bool>(
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<void> _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<VideoConductor>().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 limmagine è 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;
}
}
}