mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat(mobile): support zoom
This commit is contained in:
@@ -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 (0–1) 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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}),
|
||||||
}),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user