diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 012db3a130..21755f3a35 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -406,12 +406,13 @@ class _AssetPageState extends ConsumerState { isPlayingMotionVideo: isPlayingMotionVideo, ), ), - if (showingOcr && !_isZoomed && displayAsset.width != null && displayAsset.height != null) + if (showingOcr && displayAsset.width != null && displayAsset.height != null) Positioned.fill( child: OcrOverlay( asset: displayAsset, imageSize: Size(displayAsset.width!.toDouble(), displayAsset.height!.toDouble()), viewportSize: Size(viewportWidth, viewportHeight), + controller: _viewController, ), ), IgnorePointer( diff --git a/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart index c3390862d3..0557a31ab3 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart @@ -1,16 +1,25 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/ocr.model.dart'; import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; class OcrOverlay extends ConsumerStatefulWidget { final BaseAsset asset; final Size imageSize; final Size viewportSize; + final PhotoViewControllerBase? controller; - const OcrOverlay({super.key, required this.asset, required this.imageSize, required this.viewportSize}); + const OcrOverlay({ + super.key, + required this.asset, + required this.imageSize, + required this.viewportSize, + this.controller, + }); @override ConsumerState createState() => _OcrOverlayState(); @@ -19,6 +28,57 @@ class OcrOverlay extends ConsumerStatefulWidget { class _OcrOverlayState extends ConsumerState { int? _selectedBoxIndex; + // Current transform read from the PhotoView controller. + // Null until the controller has emitted at least one real event or until + // we can seed a reliable value from controller.value on init. + PhotoViewControllerValue? _controllerValue; + StreamSubscription? _controllerSub; + + @override + void initState() { + super.initState(); + _attachController(widget.controller); + } + + @override + void didUpdateWidget(OcrOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _detachController(); + _attachController(widget.controller); + } + } + + @override + void dispose() { + _detachController(); + super.dispose(); + } + + void _attachController(PhotoViewControllerBase? controller) { + if (controller == null) return; + + // Seed with the current value only when scaleBoundaries is already set. + // Before the image finishes loading, PhotoView uses childSize = outerSize + // (viewport) as a placeholder, which sets scale = 1.0. That placeholder + // is wrong for any image that doesn't exactly fill the viewport. + // Once scaleBoundaries is set the value is trustworthy (the image has rendered + // at least one frame and setScaleInvisibly has been called with the real + // initial/zoomed scale). + if (controller.scaleBoundaries != null) { + _controllerValue = controller.value; + } + + _controllerSub = controller.outputStateStream.listen((value) { + if (mounted) setState(() => _controllerValue = value); + }); + } + + void _detachController() { + _controllerSub?.cancel(); + _controllerSub = null; + } + @override Widget build(BuildContext context) { if (widget.asset is! RemoteAsset) { @@ -32,7 +92,6 @@ class _OcrOverlayState extends ConsumerState { if (data == null || data.isEmpty) { return const SizedBox.shrink(); } - return _buildOcrBoxes(data); }, loading: () => const SizedBox.shrink(), @@ -41,135 +100,141 @@ class _OcrOverlayState extends ConsumerState { } Widget _buildOcrBoxes(List ocrData) { - // Calculate the scale factor to fit the image in the viewport - final imageWidth = widget.imageSize.width; - final imageHeight = widget.imageSize.height; + // Use the actual decoded image size from PhotoView's scaleBoundaries when + // available. The image provider may serve a downscaled preview (e.g. Immich + // serves a ~1440px preview for large originals), so the decoded dimensions + // can differ significantly from the stored asset dimensions. Using the wrong + // size would scale every coordinate by the ratio between the two resolutions. + final imageSize = widget.controller?.scaleBoundaries?.childSize ?? widget.imageSize; + + final scale = + _controllerValue?.scale ?? + math.min(widget.viewportSize.width / imageSize.width, widget.viewportSize.height / imageSize.height); + final position = _controllerValue?.position ?? Offset.zero; + return _buildBoxStack(ocrData, imageSize, scale, position); + } + + Widget _buildBoxStack(List ocrData, Size imageSize, double scale, Offset position) { + final imageWidth = imageSize.width; + final imageHeight = imageSize.height; final viewportWidth = widget.viewportSize.width; final viewportHeight = widget.viewportSize.height; - // Calculate how the image is scaled to fit in the viewport - final scaleX = viewportWidth / imageWidth; - final scaleY = viewportHeight / imageHeight; - final scale = scaleX < scaleY ? scaleX : scaleY; - - // Calculate the actual displayed image size - final displayedWidth = imageWidth * scale; - final displayedHeight = imageHeight * scale; - - // Calculate the offset to center the image - final offsetX = (viewportWidth - displayedWidth) / 2; - final offsetY = (viewportHeight - displayedHeight) / 2; + // Image center in viewport space, accounting for pan + final cx = viewportWidth / 2 + position.dx; + final cy = viewportHeight / 2 + position.dy; return GestureDetector( - behavior: HitTestBehavior.opaque, + behavior: HitTestBehavior.translucent, onTap: () { setState(() { _selectedBoxIndex = null; }); }, - child: Stack( - children: [ - // Invisible layer to catch taps outside of boxes - SizedBox(width: viewportWidth, height: viewportHeight), - ...ocrData.asMap().entries.map((entry) { - final index = entry.key; - final ocr = entry.value; - final isSelected = _selectedBoxIndex == index; + child: ClipRect( + child: Stack( + children: [ + // Fills the viewport so taps outside boxes deselect + SizedBox(width: viewportWidth, height: viewportHeight), + ...ocrData.asMap().entries.map((entry) { + final index = entry.key; + final ocr = entry.value; + final isSelected = _selectedBoxIndex == index; - // Normalize coordinates (0-1 range) and scale to displayed image size - final x1 = ocr.x1 * displayedWidth + offsetX; - final y1 = ocr.y1 * displayedHeight + offsetY; - final x2 = ocr.x2 * displayedWidth + offsetX; - final y2 = ocr.y2 * displayedHeight + offsetY; - final x3 = ocr.x3 * displayedWidth + offsetX; - final y3 = ocr.y3 * displayedHeight + offsetY; - final x4 = ocr.x4 * displayedWidth + offsetX; - final y4 = ocr.y4 * displayedHeight + offsetY; + // Map normalized image coords (0–1) to viewport space + final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale; + final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale; + final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale; + final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale; + final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale; + final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale; + final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale; + final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale; - // Calculate bounding rectangle for hit testing - final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b); - final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b); - final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b); - final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b); + // Bounding rectangle for hit testing and Positioned placement + final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b); + final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b); + final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b); + final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b); - // Calculate rotation angle from the bottom edge (x1,y1) to (x2,y2) - final angle = math.atan2(y2 - y1, x2 - x1); - final centerX = (minX + maxX) / 2; - final centerY = (minY + maxY) / 2; + final angle = math.atan2(y2 - y1, x2 - x1); + final centerX = (minX + maxX) / 2; + final centerY = (minY + maxY) / 2; - return Positioned( - left: minX, - top: minY, - child: GestureDetector( - onTap: () { - setState(() { - _selectedBoxIndex = isSelected ? null : index; - }); - }, - behavior: HitTestBehavior.translucent, - child: SizedBox( - width: maxX - minX, - height: maxY - minY, - child: Stack( - children: [ - CustomPaint( - painter: _OcrBoxPainter( - points: [ - Offset(x1 - minX, y1 - minY), - Offset(x2 - minX, y2 - minY), - Offset(x3 - minX, y3 - minY), - Offset(x4 - minX, y4 - minY), - ], - isSelected: isSelected, - context: context, + return Positioned( + left: minX, + top: minY, + child: GestureDetector( + onTap: () { + setState(() { + _selectedBoxIndex = isSelected ? null : index; + }); + }, + behavior: HitTestBehavior.translucent, + child: SizedBox( + width: maxX - minX, + height: maxY - minY, + child: Stack( + children: [ + CustomPaint( + painter: _OcrBoxPainter( + points: [ + Offset(x1 - minX, y1 - minY), + Offset(x2 - minX, y2 - minY), + Offset(x3 - minX, y3 - minY), + Offset(x4 - minX, y4 - minY), + ], + isSelected: isSelected, + context: context, + ), + size: Size(maxX - minX, maxY - minY), ), - size: Size(maxX - minX, maxY - minY), - ), - if (isSelected) - Positioned( - left: centerX - minX, - top: centerY - minY, - child: FractionalTranslation( - translation: const Offset(-0.5, -0.5), - child: Transform.rotate( - angle: angle, - alignment: Alignment.center, - child: Container( - margin: const EdgeInsets.all(2), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - color: Colors.grey[800]?.withValues(alpha: 0.4), - borderRadius: const BorderRadius.all(Radius.circular(4)), - ), - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: math.max(50, maxX - minX), - maxHeight: math.max(20, maxY - minY), + if (isSelected) + Positioned( + left: centerX - minX, + top: centerY - minY, + child: FractionalTranslation( + translation: const Offset(-0.5, -0.5), + child: Transform.rotate( + angle: angle, + alignment: Alignment.center, + child: Container( + margin: const EdgeInsets.all(2), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.grey[800]?.withValues(alpha: 0.4), + borderRadius: const BorderRadius.all(Radius.circular(4)), ), - child: FittedBox( - fit: BoxFit.scaleDown, - child: SelectableText( - ocr.text, - style: TextStyle( - color: Colors.white, - fontSize: math.max(12, (maxY - minY) * 0.6), - fontWeight: FontWeight.bold, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: math.max(50, maxX - minX), + maxHeight: math.max(20, maxY - minY), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: SelectableText( + ocr.text, + style: TextStyle( + color: Colors.white, + fontSize: math.max(12, (maxY - minY) * 0.6), + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), ), ), ), ), ), - ), - ], + ], + ), ), ), - ), - ); - }), - ], + ); + }), + ], + ), ), ); }