diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index e317c598f5..01a48e7e97 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -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), diff --git a/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart index 86941a4550..a9291f3173 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart @@ -157,6 +157,55 @@ class _OcrBoxes extends StatelessWidget { final cx = viewportWidth / 2 + position.dx; final cy = viewportHeight / 2 + position.dy; + final quads = >[]; + final boxes = []; + + 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( 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 (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); - - 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> 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 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() diff --git a/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart new file mode 100644 index 0000000000..73c7b2d3e7 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart @@ -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), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index f7ccf3ba12..3b158c63a8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -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 = [ - 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(