feat(mobile): support zoom

This commit is contained in:
Yaros
2026-03-24 13:20:49 +01:00
parent 5348a44be9
commit 630ae1cbe2
2 changed files with 173 additions and 107 deletions
@@ -406,12 +406,13 @@ class _AssetPageState extends ConsumerState<AssetPage> {
isPlayingMotionVideo: isPlayingMotionVideo, isPlayingMotionVideo: isPlayingMotionVideo,
), ),
), ),
if (showingOcr && !_isZoomed && displayAsset.width != null && displayAsset.height != null) if (showingOcr && displayAsset.width != null && displayAsset.height != null)
Positioned.fill( Positioned.fill(
child: OcrOverlay( child: OcrOverlay(
asset: displayAsset, asset: displayAsset,
imageSize: Size(displayAsset.width!.toDouble(), displayAsset.height!.toDouble()), imageSize: Size(displayAsset.width!.toDouble(), displayAsset.height!.toDouble()),
viewportSize: Size(viewportWidth, viewportHeight), viewportSize: Size(viewportWidth, viewportHeight),
controller: _viewController,
), ),
), ),
IgnorePointer( IgnorePointer(
@@ -1,16 +1,25 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/ocr.model.dart'; import 'package:immich_mobile/domain/models/ocr.model.dart';
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart'; import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
class OcrOverlay extends ConsumerStatefulWidget { class OcrOverlay extends ConsumerStatefulWidget {
final BaseAsset asset; final BaseAsset asset;
final Size imageSize; final Size imageSize;
final Size viewportSize; 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 @override
ConsumerState<OcrOverlay> createState() => _OcrOverlayState(); ConsumerState<OcrOverlay> createState() => _OcrOverlayState();
@@ -19,6 +28,57 @@ class OcrOverlay extends ConsumerStatefulWidget {
class _OcrOverlayState extends ConsumerState<OcrOverlay> { class _OcrOverlayState extends ConsumerState<OcrOverlay> {
int? _selectedBoxIndex; 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<PhotoViewControllerValue>? _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.asset is! RemoteAsset) { if (widget.asset is! RemoteAsset) {
@@ -32,7 +92,6 @@ class _OcrOverlayState extends ConsumerState<OcrOverlay> {
if (data == null || data.isEmpty) { if (data == null || data.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return _buildOcrBoxes(data); return _buildOcrBoxes(data);
}, },
loading: () => const SizedBox.shrink(), loading: () => const SizedBox.shrink(),
@@ -41,58 +100,63 @@ class _OcrOverlayState extends ConsumerState<OcrOverlay> {
} }
Widget _buildOcrBoxes(List<DriftOcr> ocrData) { Widget _buildOcrBoxes(List<DriftOcr> ocrData) {
// Calculate the scale factor to fit the image in the viewport // Use the actual decoded image size from PhotoView's scaleBoundaries when
final imageWidth = widget.imageSize.width; // available. The image provider may serve a downscaled preview (e.g. Immich
final imageHeight = widget.imageSize.height; // 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<DriftOcr> ocrData, Size imageSize, double scale, Offset position) {
final imageWidth = imageSize.width;
final imageHeight = imageSize.height;
final viewportWidth = widget.viewportSize.width; final viewportWidth = widget.viewportSize.width;
final viewportHeight = widget.viewportSize.height; final viewportHeight = widget.viewportSize.height;
// Calculate how the image is scaled to fit in the viewport // Image center in viewport space, accounting for pan
final scaleX = viewportWidth / imageWidth; final cx = viewportWidth / 2 + position.dx;
final scaleY = viewportHeight / imageHeight; final cy = viewportHeight / 2 + position.dy;
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;
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.translucent,
onTap: () { onTap: () {
setState(() { setState(() {
_selectedBoxIndex = null; _selectedBoxIndex = null;
}); });
}, },
child: ClipRect(
child: Stack( child: Stack(
children: [ children: [
// Invisible layer to catch taps outside of boxes // Fills the viewport so taps outside boxes deselect
SizedBox(width: viewportWidth, height: viewportHeight), SizedBox(width: viewportWidth, height: viewportHeight),
...ocrData.asMap().entries.map((entry) { ...ocrData.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
final ocr = entry.value; final ocr = entry.value;
final isSelected = _selectedBoxIndex == index; final isSelected = _selectedBoxIndex == index;
// Normalize coordinates (0-1 range) and scale to displayed image size // Map normalized image coords (01) to viewport space
final x1 = ocr.x1 * displayedWidth + offsetX; final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
final y1 = ocr.y1 * displayedHeight + offsetY; final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale;
final x2 = ocr.x2 * displayedWidth + offsetX; final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale;
final y2 = ocr.y2 * displayedHeight + offsetY; final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale;
final x3 = ocr.x3 * displayedWidth + offsetX; final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale;
final y3 = ocr.y3 * displayedHeight + offsetY; final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale;
final x4 = ocr.x4 * displayedWidth + offsetX; final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale;
final y4 = ocr.y4 * displayedHeight + offsetY; final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale;
// Calculate bounding rectangle for hit testing // Bounding rectangle for hit testing and Positioned placement
final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b); 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 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 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); 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 angle = math.atan2(y2 - y1, x2 - x1);
final centerX = (minX + maxX) / 2; final centerX = (minX + maxX) / 2;
final centerY = (minY + maxY) / 2; final centerY = (minY + maxY) / 2;
@@ -171,6 +235,7 @@ class _OcrOverlayState extends ConsumerState<OcrOverlay> {
}), }),
], ],
), ),
),
); );
} }
} }