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,135 +100,141 @@ 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: Stack( child: ClipRect(
children: [ child: Stack(
// Invisible layer to catch taps outside of boxes children: [
SizedBox(width: viewportWidth, height: viewportHeight), // Fills the viewport so taps outside boxes deselect
...ocrData.asMap().entries.map((entry) { SizedBox(width: viewportWidth, height: viewportHeight),
final index = entry.key; ...ocrData.asMap().entries.map((entry) {
final ocr = entry.value; final index = entry.key;
final isSelected = _selectedBoxIndex == index; final ocr = entry.value;
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;
return Positioned( return Positioned(
left: minX, left: minX,
top: minY, top: minY,
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
setState(() { setState(() {
_selectedBoxIndex = isSelected ? null : index; _selectedBoxIndex = isSelected ? null : index;
}); });
}, },
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
child: SizedBox( child: SizedBox(
width: maxX - minX, width: maxX - minX,
height: maxY - minY, height: maxY - minY,
child: Stack( child: Stack(
children: [ children: [
CustomPaint( CustomPaint(
painter: _OcrBoxPainter( painter: _OcrBoxPainter(
points: [ points: [
Offset(x1 - minX, y1 - minY), Offset(x1 - minX, y1 - minY),
Offset(x2 - minX, y2 - minY), Offset(x2 - minX, y2 - minY),
Offset(x3 - minX, y3 - minY), Offset(x3 - minX, y3 - minY),
Offset(x4 - minX, y4 - minY), Offset(x4 - minX, y4 - minY),
], ],
isSelected: isSelected, isSelected: isSelected,
context: context, context: context,
),
size: Size(maxX - minX, maxY - minY),
), ),
size: Size(maxX - minX, maxY - minY), if (isSelected)
), Positioned(
if (isSelected) left: centerX - minX,
Positioned( top: centerY - minY,
left: centerX - minX, child: FractionalTranslation(
top: centerY - minY, translation: const Offset(-0.5, -0.5),
child: FractionalTranslation( child: Transform.rotate(
translation: const Offset(-0.5, -0.5), angle: angle,
child: Transform.rotate( alignment: Alignment.center,
angle: angle, child: Container(
alignment: Alignment.center, margin: const EdgeInsets.all(2),
child: Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
margin: const EdgeInsets.all(2), decoration: BoxDecoration(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), color: Colors.grey[800]?.withValues(alpha: 0.4),
decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4)),
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),
), ),
child: FittedBox( child: ConstrainedBox(
fit: BoxFit.scaleDown, constraints: BoxConstraints(
child: SelectableText( maxWidth: math.max(50, maxX - minX),
ocr.text, maxHeight: math.max(20, maxY - minY),
style: TextStyle( ),
color: Colors.white, child: FittedBox(
fontSize: math.max(12, (maxY - minY) * 0.6), fit: BoxFit.scaleDown,
fontWeight: FontWeight.bold, 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,
), ),
), ),
), ),
), ),
), ),
), ),
), ],
], ),
), ),
), ),
), );
); }),
}), ],
], ),
), ),
); );
} }