chore: improve OCR button and display on mobile (#28926)

* chore: improve OCR button and display on mobile

* Refactor

* format

* simplify ocr toggle button

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Alex
2026-06-09 13:20:18 -05:00
committed by GitHub
parent 6c5c6a1035
commit d3438cf4a7
4 changed files with 130 additions and 51 deletions
@@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_act
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -100,6 +101,7 @@ class ViewerBottomBar extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
OcrToggleButton(asset: asset),
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
@@ -157,6 +157,55 @@ class _OcrBoxes extends StatelessWidget {
final cx = viewportWidth / 2 + position.dx;
final cy = viewportHeight / 2 + position.dy;
final quads = <List<Offset>>[];
final boxes = <Widget>[];
for (final entry in ocrData.asMap().entries) {
final index = entry.key;
final ocr = entry.value;
// Map normalized image coords (01) 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;
// 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);
quads.add([Offset(x1, y1), Offset(x2, y2), Offset(x3, y3), Offset(x4, y4)]);
boxes.add(
_OcrBoxItem(
key: ValueKey(index),
ocr: ocr,
index: index,
isSelected: selectedBoxIndex == index,
points: [
Offset(x1 - minX, y1 - minY),
Offset(x2 - minX, y2 - minY),
Offset(x3 - minX, y3 - minY),
Offset(x4 - minX, y4 - minY),
],
left: minX,
top: minY,
width: maxX - minX,
height: maxY - minY,
angle: math.atan2(y2 - y1, x2 - x1),
labelDx: (minX + maxX) / 2 - minX,
labelDy: (minY + maxY) / 2 - minY,
onSelectionChanged: onSelectionChanged,
),
);
}
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => onSelectionChanged(null),
@@ -165,47 +214,13 @@ class _OcrBoxes extends StatelessWidget {
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;
// Map normalized image coords (01) 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;
// 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);
return _OcrBoxItem(
key: ValueKey(index),
ocr: ocr,
index: index,
isSelected: selectedBoxIndex == index,
points: [
Offset(x1 - minX, y1 - minY),
Offset(x2 - minX, y2 - minY),
Offset(x3 - minX, y3 - minY),
Offset(x4 - minX, y4 - minY),
],
left: minX,
top: minY,
width: maxX - minX,
height: maxY - minY,
angle: math.atan2(y2 - y1, x2 - x1),
labelDx: (minX + maxX) / 2 - minX,
labelDy: (minY + maxY) / 2 - minY,
onSelectionChanged: onSelectionChanged,
);
}),
// Dark scrim with the text boxes punched out
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(painter: _OcrScrimPainter(quads: quads)),
),
),
...boxes,
],
),
),
@@ -307,6 +322,35 @@ class _OcrBoxItem extends StatelessWidget {
}
}
class _OcrScrimPainter extends CustomPainter {
final List<List<Offset>> quads;
const _OcrScrimPainter({required this.quads});
@override
void paint(Canvas canvas, Size size) {
// Fill the whole viewport, then subtract each text quad using the even-odd
// rule so the original image shows through the boxes.
final path = Path()
..fillType = PathFillType.evenOdd
..addRect(Offset.zero & size);
for (final quad in quads) {
path
..moveTo(quad[0].dx, quad[0].dy)
..lineTo(quad[1].dx, quad[1].dy)
..lineTo(quad[2].dx, quad[2].dy)
..lineTo(quad[3].dx, quad[3].dy)
..close();
}
canvas.drawPath(path, Paint()..color = Colors.black54);
}
@override
bool shouldRepaint(_OcrScrimPainter oldDelegate) => true;
}
class _OcrBoxPainter extends CustomPainter {
final List<Offset> points;
final bool isSelected;
@@ -322,7 +366,7 @@ class _OcrBoxPainter extends CustomPainter {
..strokeWidth = 2.0;
final fillPaint = Paint()
..color = (isSelected ? colorScheme.primary : colorScheme.secondary).withValues(alpha: 0.1)
..color = isSelected ? colorScheme.primary.withValues(alpha: 0.45) : Colors.transparent
..style = PaintingStyle.fill;
final path = Path()
@@ -0,0 +1,42 @@
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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
class OcrToggleButton extends ConsumerWidget {
final BaseAsset asset;
const OcrToggleButton({super.key, required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = this.asset;
final hasOcr = asset is RemoteAsset && ref.watch(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr));
return AnimatedSwitcher(
duration: Durations.short4,
child: !hasOcr
? const SizedBox.shrink()
: Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 32, bottom: 8),
child: Material(
color: showingOcr ? context.primaryColor : Colors.black.withValues(alpha: 0.4),
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: ref.read(assetViewerProvider.notifier).toggleOcr,
child: const Padding(
padding: EdgeInsets.all(10.0),
child: Icon(Icons.text_fields_rounded, size: 22, color: Colors.white),
),
),
),
),
),
);
}
}
@@ -13,7 +13,6 @@ import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -36,7 +35,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final hasOcr = asset is RemoteAsset && ref.watch(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
@@ -48,15 +46,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
final originalTheme = context.themeData;
final showingOcr = ref.watch(assetViewerProvider.select((state) => state.showingOcr));
final actions = <Widget>[
if (hasOcr)
IconButton(
icon: Icon(showingOcr ? Icons.text_fields : Icons.text_fields_outlined),
onPressed: ref.read(assetViewerProvider.notifier).toggleOcr,
color: showingOcr ? context.primaryColor : null,
),
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(