mirror of
https://github.com/immich-app/immich.git
synced 2026-06-12 11:01:45 -07:00
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:
@@ -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/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/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_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/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
@@ -100,6 +101,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
OcrToggleButton(asset: asset),
|
||||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||||
if (!isReadonlyModeEnabled)
|
if (!isReadonlyModeEnabled)
|
||||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||||
|
|||||||
@@ -157,6 +157,55 @@ class _OcrBoxes extends StatelessWidget {
|
|||||||
final cx = viewportWidth / 2 + position.dx;
|
final cx = viewportWidth / 2 + position.dx;
|
||||||
final cy = viewportHeight / 2 + position.dy;
|
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 (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;
|
||||||
|
|
||||||
|
// 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(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
onTap: () => onSelectionChanged(null),
|
onTap: () => onSelectionChanged(null),
|
||||||
@@ -165,47 +214,13 @@ class _OcrBoxes extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Fills the viewport so taps outside boxes deselect
|
// Fills the viewport so taps outside boxes deselect
|
||||||
SizedBox(width: viewportWidth, height: viewportHeight),
|
SizedBox(width: viewportWidth, height: viewportHeight),
|
||||||
...ocrData.asMap().entries.map((entry) {
|
// Dark scrim with the text boxes punched out
|
||||||
final index = entry.key;
|
Positioned.fill(
|
||||||
final ocr = entry.value;
|
child: IgnorePointer(
|
||||||
|
child: CustomPaint(painter: _OcrScrimPainter(quads: quads)),
|
||||||
// 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;
|
...boxes,
|
||||||
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,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -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 {
|
class _OcrBoxPainter extends CustomPainter {
|
||||||
final List<Offset> points;
|
final List<Offset> points;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
@@ -322,7 +366,7 @@ class _OcrBoxPainter extends CustomPainter {
|
|||||||
..strokeWidth = 2.0;
|
..strokeWidth = 2.0;
|
||||||
|
|
||||||
final fillPaint = Paint()
|
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;
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
final path = Path()
|
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/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.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/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/infrastructure/readonly_mode.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.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 isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
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));
|
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);
|
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||||
|
|
||||||
final originalTheme = context.themeData;
|
final originalTheme = context.themeData;
|
||||||
final showingOcr = ref.watch(assetViewerProvider.select((state) => state.showingOcr));
|
|
||||||
|
|
||||||
final actions = <Widget>[
|
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 (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||||
if (album != null && album.isActivityEnabled && album.isShared)
|
if (album != null && album.isActivityEnabled && album.isShared)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
Reference in New Issue
Block a user