mirror of
https://github.com/immich-app/immich.git
synced 2025-12-15 01:00:44 -08:00
Compare commits
5 Commits
workflow-u
...
push-xqttk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c291c4c522 | ||
|
|
1109c32891 | ||
|
|
3c80049192 | ||
|
|
8f1669efbe | ||
|
|
146bf65d02 |
@@ -58,10 +58,6 @@ services:
|
|||||||
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
|
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
|
||||||
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
|
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
|
||||||
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
|
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
|
||||||
ulimits:
|
|
||||||
nofile:
|
|
||||||
soft: 1048576
|
|
||||||
hard: 1048576
|
|
||||||
ports:
|
ports:
|
||||||
- 9230:9230
|
- 9230:9230
|
||||||
- 9231:9231
|
- 9231:9231
|
||||||
@@ -100,10 +96,6 @@ services:
|
|||||||
- app-node_modules:/usr/src/app/node_modules
|
- app-node_modules:/usr/src/app/node_modules
|
||||||
- sveltekit:/usr/src/app/web/.svelte-kit
|
- sveltekit:/usr/src/app/web/.svelte-kit
|
||||||
- coverage:/usr/src/app/web/coverage
|
- coverage:/usr/src/app/web/coverage
|
||||||
ulimits:
|
|
||||||
nofile:
|
|
||||||
soft: 1048576
|
|
||||||
hard: 1048576
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
immich-server:
|
immich-server:
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ server {
|
|||||||
|
|
||||||
# enable websockets: http://nginx.org/en/docs/http/websocket.html
|
# enable websockets: http://nginx.org/en/docs/http/websocket.html
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
|
||||||
# set timeout
|
# set timeout
|
||||||
@@ -43,6 +41,8 @@ server {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://<backend_url>:2283;
|
proxy_pass http://<backend_url>:2283;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
}
|
}
|
||||||
|
|
||||||
# useful when using Let's Encrypt http-01 challenge
|
# useful when using Let's Encrypt http-01 challenge
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(album.name),
|
title: Text(album.name),
|
||||||
actions: [const LikeActivityActionButton(menuItem: true)],
|
actions: [const LikeActivityActionButton(iconOnly: true)],
|
||||||
actionsPadding: const EdgeInsets.only(right: 8),
|
actionsPadding: const EdgeInsets.only(right: 8),
|
||||||
),
|
),
|
||||||
body: activities.widgetWhen(
|
body: activities.widgetWhen(
|
||||||
|
|||||||
@@ -21,12 +21,34 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
|
|||||||
|
|
||||||
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
|
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
|
||||||
|
|
||||||
class AddActionButton extends ConsumerWidget {
|
class AddActionButton extends ConsumerStatefulWidget {
|
||||||
const AddActionButton({super.key});
|
const AddActionButton({super.key});
|
||||||
|
|
||||||
Future<void> _showAddOptions(BuildContext context, WidgetRef ref) async {
|
@override
|
||||||
|
ConsumerState<AddActionButton> createState() => _AddActionButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||||
|
void _handleMenuSelection(AddToMenuItem selected) {
|
||||||
|
switch (selected) {
|
||||||
|
case AddToMenuItem.album:
|
||||||
|
_openAlbumSelector();
|
||||||
|
break;
|
||||||
|
case AddToMenuItem.archive:
|
||||||
|
performArchiveAction(context, ref, source: ActionSource.viewer);
|
||||||
|
break;
|
||||||
|
case AddToMenuItem.unarchive:
|
||||||
|
performUnArchiveAction(context, ref, source: ActionSource.viewer);
|
||||||
|
break;
|
||||||
|
case AddToMenuItem.lockedFolder:
|
||||||
|
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildMenuChildren() {
|
||||||
final asset = ref.read(currentAssetNotifier);
|
final asset = ref.read(currentAssetNotifier);
|
||||||
if (asset == null) return;
|
if (asset == null) return [];
|
||||||
|
|
||||||
final user = ref.read(currentUserProvider);
|
final user = ref.read(currentUserProvider);
|
||||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||||
@@ -35,93 +57,57 @@ class AddActionButton extends ConsumerWidget {
|
|||||||
final hasRemote = asset is RemoteAsset;
|
final hasRemote = asset is RemoteAsset;
|
||||||
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
|
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
|
||||||
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
|
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
|
||||||
final menuItemHeight = 30.0;
|
|
||||||
|
|
||||||
final List<PopupMenuEntry<AddToMenuItem>> items = [
|
return [
|
||||||
PopupMenuItem(
|
Padding(
|
||||||
enabled: false,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
textStyle: context.textTheme.labelMedium,
|
child: Text("add_to_bottom_bar".tr(), style: context.textTheme.labelMedium),
|
||||||
height: 40,
|
|
||||||
child: Text("add_to_bottom_bar".tr()),
|
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
BaseActionButton(
|
||||||
height: menuItemHeight,
|
iconData: Icons.photo_album_outlined,
|
||||||
value: AddToMenuItem.album,
|
label: "album".tr(),
|
||||||
child: ListTile(leading: const Icon(Icons.photo_album_outlined), title: Text("album".tr())),
|
menuItem: true,
|
||||||
|
onPressed: () => _handleMenuSelection(AddToMenuItem.album),
|
||||||
),
|
),
|
||||||
const PopupMenuDivider(),
|
|
||||||
PopupMenuItem(enabled: false, textStyle: context.textTheme.labelMedium, height: 40, child: Text("move_to".tr())),
|
|
||||||
if (isOwner) ...[
|
if (isOwner) ...[
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
|
||||||
|
),
|
||||||
if (showArchive)
|
if (showArchive)
|
||||||
PopupMenuItem(
|
BaseActionButton(
|
||||||
height: menuItemHeight,
|
iconData: Icons.archive_outlined,
|
||||||
value: AddToMenuItem.archive,
|
label: "archive".tr(),
|
||||||
child: ListTile(leading: const Icon(Icons.archive_outlined), title: Text("archive".tr())),
|
menuItem: true,
|
||||||
|
onPressed: () => _handleMenuSelection(AddToMenuItem.archive),
|
||||||
),
|
),
|
||||||
if (showUnarchive)
|
if (showUnarchive)
|
||||||
PopupMenuItem(
|
BaseActionButton(
|
||||||
height: menuItemHeight,
|
iconData: Icons.unarchive_outlined,
|
||||||
value: AddToMenuItem.unarchive,
|
label: "unarchive".tr(),
|
||||||
child: ListTile(leading: const Icon(Icons.unarchive_outlined), title: Text("unarchive".tr())),
|
menuItem: true,
|
||||||
|
onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
BaseActionButton(
|
||||||
height: menuItemHeight,
|
iconData: Icons.lock_outline,
|
||||||
value: AddToMenuItem.lockedFolder,
|
label: "locked_folder".tr(),
|
||||||
child: ListTile(leading: const Icon(Icons.lock_outline), title: Text("locked_folder".tr())),
|
menuItem: true,
|
||||||
|
onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
final AddToMenuItem? selected = await showMenu<AddToMenuItem>(
|
|
||||||
context: context,
|
|
||||||
color: context.themeData.scaffoldBackgroundColor,
|
|
||||||
position: _menuPosition(context),
|
|
||||||
items: items,
|
|
||||||
popUpAnimationStyle: AnimationStyle.noAnimation,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selected == null) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (selected) {
|
void _openAlbumSelector() {
|
||||||
case AddToMenuItem.album:
|
|
||||||
_openAlbumSelector(context, ref);
|
|
||||||
break;
|
|
||||||
case AddToMenuItem.archive:
|
|
||||||
await performArchiveAction(context, ref, source: ActionSource.viewer);
|
|
||||||
break;
|
|
||||||
case AddToMenuItem.unarchive:
|
|
||||||
await performUnArchiveAction(context, ref, source: ActionSource.viewer);
|
|
||||||
break;
|
|
||||||
case AddToMenuItem.lockedFolder:
|
|
||||||
await performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RelativeRect _menuPosition(BuildContext context) {
|
|
||||||
final renderObject = context.findRenderObject();
|
|
||||||
if (renderObject is! RenderBox) {
|
|
||||||
return RelativeRect.fill;
|
|
||||||
}
|
|
||||||
|
|
||||||
final size = renderObject.size;
|
|
||||||
final position = renderObject.localToGlobal(Offset.zero);
|
|
||||||
|
|
||||||
return RelativeRect.fromLTRB(position.dx, position.dy - size.height - 200, position.dx + size.width, position.dy);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _openAlbumSelector(BuildContext context, WidgetRef ref) {
|
|
||||||
final currentAsset = ref.read(currentAssetNotifier);
|
final currentAsset = ref.read(currentAssetNotifier);
|
||||||
if (currentAsset == null) {
|
if (currentAsset == null) {
|
||||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<Widget> slivers = [
|
final List<Widget> slivers = [AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(album))];
|
||||||
AlbumSelector(onAlbumSelected: (album) => _addCurrentAssetToAlbum(context, ref, album)),
|
|
||||||
];
|
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -141,7 +127,7 @@ class AddActionButton extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addCurrentAssetToAlbum(BuildContext context, WidgetRef ref, RemoteAlbum album) async {
|
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
|
||||||
final latest = ref.read(currentAssetNotifier);
|
final latest = ref.read(currentAssetNotifier);
|
||||||
|
|
||||||
if (latest == null) {
|
if (latest == null) {
|
||||||
@@ -174,17 +160,27 @@ class AddActionButton extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = ref.watch(currentAssetNotifier);
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
return Builder(
|
|
||||||
builder: (buttonContext) {
|
return MenuAnchor(
|
||||||
|
consumeOutsideTap: true,
|
||||||
|
style: MenuStyle(
|
||||||
|
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
|
||||||
|
elevation: const WidgetStatePropertyAll(4),
|
||||||
|
shape: const WidgetStatePropertyAll(
|
||||||
|
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
menuChildren: _buildMenuChildren(),
|
||||||
|
builder: (context, controller, child) {
|
||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.add,
|
iconData: Icons.add,
|
||||||
label: "add_to_bottom_bar".tr(),
|
label: "add_to_bottom_bar".tr(),
|
||||||
onPressed: () => _showAddOptions(buttonContext, ref),
|
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class BaseActionButton extends StatelessWidget {
|
|||||||
this.onLongPressed,
|
this.onLongPressed,
|
||||||
this.maxWidth = 90.0,
|
this.maxWidth = 90.0,
|
||||||
this.minWidth,
|
this.minWidth,
|
||||||
|
this.iconOnly = false,
|
||||||
this.menuItem = false,
|
this.menuItem = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,6 +20,11 @@ class BaseActionButton extends StatelessWidget {
|
|||||||
final Color? iconColor;
|
final Color? iconColor;
|
||||||
final double maxWidth;
|
final double maxWidth;
|
||||||
final double? minWidth;
|
final double? minWidth;
|
||||||
|
|
||||||
|
/// When true, renders only an IconButton without text label
|
||||||
|
final bool iconOnly;
|
||||||
|
|
||||||
|
/// When true, renders as a MenuItemButton for use in MenuAnchor menus
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
final void Function()? onPressed;
|
final void Function()? onPressed;
|
||||||
final void Function()? onLongPressed;
|
final void Function()? onLongPressed;
|
||||||
@@ -31,13 +37,26 @@ class BaseActionButton extends StatelessWidget {
|
|||||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||||
final textColor = context.themeData.textTheme.labelLarge?.color;
|
final textColor = context.themeData.textTheme.labelLarge?.color;
|
||||||
|
|
||||||
if (menuItem) {
|
if (iconOnly) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
icon: Icon(iconData, size: iconSize, color: iconColor),
|
icon: Icon(iconData, size: iconSize, color: iconColor),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (menuItem) {
|
||||||
|
final theme = context.themeData;
|
||||||
|
final effectiveStyle = theme.textTheme.labelLarge;
|
||||||
|
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
return MenuItemButton(
|
||||||
|
style: MenuItemButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12)),
|
||||||
|
leadingIcon: Icon(iconData, color: effectiveIconColor, size: 20),
|
||||||
|
onPressed: onPressed,
|
||||||
|
child: Text(label, style: effectiveStyle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return ConstrainedBox(
|
return ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||||
child: MaterialButton(
|
child: MaterialButton(
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import 'package:immich_mobile/providers/cast.provider.dart';
|
|||||||
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
||||||
|
|
||||||
class CastActionButton extends ConsumerWidget {
|
class CastActionButton extends ConsumerWidget {
|
||||||
const CastActionButton({super.key, this.menuItem = true});
|
const CastActionButton({super.key, this.iconOnly = true, this.menuItem = false});
|
||||||
|
|
||||||
|
final bool iconOnly;
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -22,6 +23,7 @@ class CastActionButton extends ConsumerWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
showDialog(context: context, builder: (context) => const CastDialog());
|
showDialog(context: context, builder: (context) => const CastDialog());
|
||||||
},
|
},
|
||||||
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|||||||
|
|
||||||
class DownloadActionButton extends ConsumerWidget {
|
class DownloadActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
const DownloadActionButton({super.key, required this.source, this.menuItem = false});
|
const DownloadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
|
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -38,6 +39,7 @@ class DownloadActionButton extends ConsumerWidget {
|
|||||||
iconData: Icons.download,
|
iconData: Icons.download,
|
||||||
maxWidth: 95,
|
maxWidth: 95,
|
||||||
label: "download".t(context: context),
|
label: "download".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref, backgroundManager),
|
onPressed: () => _onTap(context, ref, backgroundManager),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
|
|
||||||
class FavoriteActionButton extends ConsumerWidget {
|
class FavoriteActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
|
|
||||||
const FavoriteActionButton({super.key, required this.source, this.menuItem = false});
|
const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -44,6 +45,7 @@ class FavoriteActionButton extends ConsumerWidget {
|
|||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.favorite_border_rounded,
|
iconData: Icons.favorite_border_rounded,
|
||||||
label: "favorite".t(context: context),
|
label: "favorite".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
|
|||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
|
||||||
class LikeActivityActionButton extends ConsumerWidget {
|
class LikeActivityActionButton extends ConsumerWidget {
|
||||||
const LikeActivityActionButton({super.key, this.menuItem = false});
|
const LikeActivityActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
|
final bool iconOnly;
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -49,6 +50,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
|||||||
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
|
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
|
||||||
label: "like".t(context: context),
|
label: "like".t(context: context),
|
||||||
onPressed: () => onTap(liked),
|
onPressed: () => onTap(liked),
|
||||||
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -57,6 +59,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
|||||||
loading: () => BaseActionButton(
|
loading: () => BaseActionButton(
|
||||||
iconData: Icons.favorite_border,
|
iconData: Icons.favorite_border,
|
||||||
label: "like".t(context: context),
|
label: "like".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
),
|
),
|
||||||
error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])),
|
error: (error, stack) => Text('error_saving_image'.tr(args: [error.toString()])),
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
|||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
|
|
||||||
class MotionPhotoActionButton extends ConsumerWidget {
|
class MotionPhotoActionButton extends ConsumerWidget {
|
||||||
const MotionPhotoActionButton({super.key, this.menuItem = true});
|
const MotionPhotoActionButton({super.key, this.iconOnly = true, this.menuItem = false});
|
||||||
|
|
||||||
|
final bool iconOnly;
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -17,6 +18,7 @@ class MotionPhotoActionButton extends ConsumerWidget {
|
|||||||
iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
|
iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
|
||||||
label: "play_motion_photo".t(context: context),
|
label: "play_motion_photo".t(context: context),
|
||||||
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
|
onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
|
||||||
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
|
|
||||||
class UnFavoriteActionButton extends ConsumerWidget {
|
class UnFavoriteActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
|
|
||||||
const UnFavoriteActionButton({super.key, required this.source, this.menuItem = false});
|
const UnFavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -45,6 +46,7 @@ class UnFavoriteActionButton extends ConsumerWidget {
|
|||||||
iconData: Icons.favorite_rounded,
|
iconData: Icons.favorite_rounded,
|
||||||
label: "unfavorite".t(context: context),
|
label: "unfavorite".t(context: context),
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_actio
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
||||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
@@ -65,8 +66,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true),
|
||||||
if (album != null && album.isActivityEnabled && album.isShared)
|
if (album != null && album.isActivityEnabled && album.isShared)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.chat_outlined),
|
icon: const Icon(Icons.chat_outlined),
|
||||||
@@ -85,16 +86,16 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
tooltip: 'view_in_timeline'.t(context: context),
|
tooltip: 'view_in_timeline'.t(context: context),
|
||||||
),
|
),
|
||||||
if (asset.hasRemote && isOwner && !asset.isFavorite)
|
if (asset.hasRemote && isOwner && !asset.isFavorite)
|
||||||
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true),
|
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||||
if (asset.hasRemote && isOwner && asset.isFavorite)
|
if (asset.hasRemote && isOwner && asset.isFavorite)
|
||||||
const UnFavoriteActionButton(source: ActionSource.viewer, menuItem: true),
|
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(menuItem: true),
|
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||||
const _KebabMenu(),
|
const ViewerKebabMenu(),
|
||||||
];
|
];
|
||||||
|
|
||||||
final lockedViewActions = <Widget>[
|
final lockedViewActions = <Widget>[
|
||||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true),
|
||||||
const _KebabMenu(),
|
const ViewerKebabMenu(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
@@ -122,20 +123,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
Size get preferredSize => const Size.fromHeight(60.0);
|
Size get preferredSize => const Size.fromHeight(60.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _KebabMenu extends ConsumerWidget {
|
|
||||||
const _KebabMenu();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return IconButton(
|
|
||||||
onPressed: () {
|
|
||||||
EventStream.shared.emit(const ViewerOpenBottomSheetEvent());
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.more_vert_rounded),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AppBarBackButton extends ConsumerWidget {
|
class _AppBarBackButton extends ConsumerWidget {
|
||||||
const _AppBarBackButton();
|
const _AppBarBackButton();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
|
||||||
|
class ViewerKebabMenu extends ConsumerWidget {
|
||||||
|
const ViewerKebabMenu({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final asset = ref.watch(currentAssetNotifier);
|
||||||
|
if (asset == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final menuChildren = <Widget>[
|
||||||
|
BaseActionButton(
|
||||||
|
label: 'about'.tr(),
|
||||||
|
iconData: Icons.info_outline,
|
||||||
|
menuItem: true,
|
||||||
|
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return MenuAnchor(
|
||||||
|
consumeOutsideTap: true,
|
||||||
|
style: MenuStyle(
|
||||||
|
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
|
||||||
|
elevation: const WidgetStatePropertyAll(4),
|
||||||
|
shape: const WidgetStatePropertyAll(
|
||||||
|
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
menuChildren: menuChildren,
|
||||||
|
builder: (context, controller, child) {
|
||||||
|
return IconButton(
|
||||||
|
icon: const Icon(Icons.more_vert_rounded),
|
||||||
|
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,7 +167,7 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: LoginRoute.page, guards: [_duplicateGuard]),
|
AutoRoute(page: LoginRoute.page, guards: [_duplicateGuard]),
|
||||||
AutoRoute(page: ChangePasswordRoute.page),
|
AutoRoute(page: ChangePasswordRoute.page),
|
||||||
AutoRoute(page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false),
|
AutoRoute(page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false),
|
||||||
CustomRoute(
|
AutoRoute(
|
||||||
page: TabControllerRoute.page,
|
page: TabControllerRoute.page,
|
||||||
guards: [_authGuard, _duplicateGuard],
|
guards: [_authGuard, _duplicateGuard],
|
||||||
children: [
|
children: [
|
||||||
@@ -176,9 +176,8 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
],
|
],
|
||||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
|
||||||
),
|
),
|
||||||
CustomRoute(
|
AutoRoute(
|
||||||
page: TabShellRoute.page,
|
page: TabShellRoute.page,
|
||||||
guards: [_authGuard, _duplicateGuard],
|
guards: [_authGuard, _duplicateGuard],
|
||||||
children: [
|
children: [
|
||||||
@@ -187,7 +186,6 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: DriftLibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftLibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: DriftAlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftAlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
],
|
],
|
||||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
|
||||||
),
|
),
|
||||||
CustomRoute(
|
CustomRoute(
|
||||||
page: GalleryViewerRoute.page,
|
page: GalleryViewerRoute.page,
|
||||||
|
|||||||
225
web/src/lib/stores/ocr.svelte.spec.ts
Normal file
225
web/src/lib/stores/ocr.svelte.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||||
|
import { getAssetOcr } from '@immich/sdk';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the SDK
|
||||||
|
vi.mock('@immich/sdk', () => ({
|
||||||
|
getAssetOcr: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createMockOcrData = (overrides?: Partial<OcrBoundingBox>): OcrBoundingBox[] => [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
assetId: 'asset-123',
|
||||||
|
x1: 0,
|
||||||
|
y1: 0,
|
||||||
|
x2: 100,
|
||||||
|
y2: 0,
|
||||||
|
x3: 100,
|
||||||
|
y3: 50,
|
||||||
|
x4: 0,
|
||||||
|
y4: 50,
|
||||||
|
boxScore: 0.95,
|
||||||
|
textScore: 0.98,
|
||||||
|
text: 'Hello World',
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('OcrManager', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset the singleton state before each test
|
||||||
|
ocrManager.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('should initialize with empty data', () => {
|
||||||
|
expect(ocrManager.data).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with showOverlay as false', () => {
|
||||||
|
expect(ocrManager.showOverlay).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with hasOcrData as false', () => {
|
||||||
|
expect(ocrManager.hasOcrData).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAssetOcr', () => {
|
||||||
|
it('should load OCR data for an asset', async () => {
|
||||||
|
const mockData = createMockOcrData();
|
||||||
|
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||||
|
|
||||||
|
await ocrManager.getAssetOcr('asset-123');
|
||||||
|
|
||||||
|
expect(getAssetOcr).toHaveBeenCalledWith({ id: 'asset-123' });
|
||||||
|
expect(ocrManager.data).toEqual(mockData);
|
||||||
|
expect(ocrManager.hasOcrData).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty OCR data', async () => {
|
||||||
|
vi.mocked(getAssetOcr).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await ocrManager.getAssetOcr('asset-456');
|
||||||
|
|
||||||
|
expect(ocrManager.data).toEqual([]);
|
||||||
|
expect(ocrManager.hasOcrData).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset the loader when previously cleared', async () => {
|
||||||
|
const mockData = createMockOcrData();
|
||||||
|
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||||
|
|
||||||
|
// First clear
|
||||||
|
ocrManager.clear();
|
||||||
|
expect(ocrManager.data).toEqual([]);
|
||||||
|
|
||||||
|
// Then load new data
|
||||||
|
await ocrManager.getAssetOcr('asset-789');
|
||||||
|
|
||||||
|
expect(ocrManager.data).toEqual(mockData);
|
||||||
|
expect(ocrManager.hasOcrData).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle concurrent requests safely', async () => {
|
||||||
|
const firstData = createMockOcrData({ id: '1', text: 'First' });
|
||||||
|
const secondData = createMockOcrData({ id: '2', text: 'Second' });
|
||||||
|
|
||||||
|
vi.mocked(getAssetOcr)
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
setTimeout(() => resolve(firstData), 100);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(secondData);
|
||||||
|
|
||||||
|
// Start first request
|
||||||
|
const promise1 = ocrManager.getAssetOcr('asset-1');
|
||||||
|
// Start second request immediately (should wait for first to complete)
|
||||||
|
const promise2 = ocrManager.getAssetOcr('asset-2');
|
||||||
|
|
||||||
|
await Promise.all([promise1, promise2]);
|
||||||
|
|
||||||
|
// CancellableTask waits for first request, so second request is ignored
|
||||||
|
// The data should be from the first request that completed
|
||||||
|
expect(ocrManager.data).toEqual(firstData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors gracefully', async () => {
|
||||||
|
const error = new Error('Network error');
|
||||||
|
vi.mocked(getAssetOcr).mockRejectedValue(error);
|
||||||
|
|
||||||
|
// The error should be handled by CancellableTask
|
||||||
|
await expect(ocrManager.getAssetOcr('asset-error')).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
it('should clear OCR data', async () => {
|
||||||
|
const mockData = createMockOcrData({ text: 'Test' });
|
||||||
|
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||||
|
await ocrManager.getAssetOcr('asset-123');
|
||||||
|
|
||||||
|
ocrManager.clear();
|
||||||
|
|
||||||
|
expect(ocrManager.data).toEqual([]);
|
||||||
|
expect(ocrManager.hasOcrData).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset showOverlay to false', () => {
|
||||||
|
ocrManager.showOverlay = true;
|
||||||
|
|
||||||
|
ocrManager.clear();
|
||||||
|
|
||||||
|
expect(ocrManager.showOverlay).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark as cleared for next load', async () => {
|
||||||
|
const mockData = createMockOcrData({ text: 'Test' });
|
||||||
|
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||||
|
|
||||||
|
ocrManager.clear();
|
||||||
|
await ocrManager.getAssetOcr('asset-123');
|
||||||
|
|
||||||
|
// Should successfully load after clear
|
||||||
|
expect(ocrManager.data).toEqual(mockData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleOcrBoundingBox', () => {
|
||||||
|
it('should toggle showOverlay from false to true', () => {
|
||||||
|
expect(ocrManager.showOverlay).toBe(false);
|
||||||
|
|
||||||
|
ocrManager.toggleOcrBoundingBox();
|
||||||
|
|
||||||
|
expect(ocrManager.showOverlay).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle showOverlay from true to false', () => {
|
||||||
|
ocrManager.showOverlay = true;
|
||||||
|
|
||||||
|
ocrManager.toggleOcrBoundingBox();
|
||||||
|
|
||||||
|
expect(ocrManager.showOverlay).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle multiple times', () => {
|
||||||
|
ocrManager.toggleOcrBoundingBox();
|
||||||
|
expect(ocrManager.showOverlay).toBe(true);
|
||||||
|
|
||||||
|
ocrManager.toggleOcrBoundingBox();
|
||||||
|
expect(ocrManager.showOverlay).toBe(false);
|
||||||
|
|
||||||
|
ocrManager.toggleOcrBoundingBox();
|
||||||
|
expect(ocrManager.showOverlay).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasOcrData derived state', () => {
|
||||||
|
it('should be false when data is empty', () => {
|
||||||
|
expect(ocrManager.hasOcrData).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be true when data is present', async () => {
|
||||||
|
const mockData = createMockOcrData({ text: 'Test' });
|
||||||
|
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||||
|
await ocrManager.getAssetOcr('asset-123');
|
||||||
|
|
||||||
|
expect(ocrManager.hasOcrData).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update when data is cleared', async () => {
|
||||||
|
const mockData = createMockOcrData({ text: 'Test' });
|
||||||
|
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||||
|
await ocrManager.getAssetOcr('asset-123');
|
||||||
|
expect(ocrManager.hasOcrData).toBe(true);
|
||||||
|
|
||||||
|
ocrManager.clear();
|
||||||
|
expect(ocrManager.hasOcrData).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data immutability', () => {
|
||||||
|
it('should return the same reference when data does not change', () => {
|
||||||
|
const firstReference = ocrManager.data;
|
||||||
|
const secondReference = ocrManager.data;
|
||||||
|
|
||||||
|
expect(firstReference).toBe(secondReference);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a new reference when data changes', async () => {
|
||||||
|
const firstReference = ocrManager.data;
|
||||||
|
const mockData = createMockOcrData({ text: 'Test' });
|
||||||
|
|
||||||
|
vi.mocked(getAssetOcr).mockResolvedValue(mockData);
|
||||||
|
await ocrManager.getAssetOcr('asset-123');
|
||||||
|
|
||||||
|
const secondReference = ocrManager.data;
|
||||||
|
|
||||||
|
expect(firstReference).not.toBe(secondReference);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||||
import { getAssetOcr } from '@immich/sdk';
|
import { getAssetOcr } from '@immich/sdk';
|
||||||
|
|
||||||
export type OcrBoundingBox = {
|
export type OcrBoundingBox = {
|
||||||
@@ -20,6 +21,8 @@ class OcrManager {
|
|||||||
#data = $state<OcrBoundingBox[]>([]);
|
#data = $state<OcrBoundingBox[]>([]);
|
||||||
showOverlay = $state(false);
|
showOverlay = $state(false);
|
||||||
#hasOcrData = $derived(this.#data.length > 0);
|
#hasOcrData = $derived(this.#data.length > 0);
|
||||||
|
#ocrLoader = new CancellableTask();
|
||||||
|
#cleared = false;
|
||||||
|
|
||||||
get data() {
|
get data() {
|
||||||
return this.#data;
|
return this.#data;
|
||||||
@@ -30,10 +33,17 @@ class OcrManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAssetOcr(id: string) {
|
async getAssetOcr(id: string) {
|
||||||
|
if (this.#cleared) {
|
||||||
|
await this.#ocrLoader.reset();
|
||||||
|
this.#cleared = false;
|
||||||
|
}
|
||||||
|
await this.#ocrLoader.execute(async () => {
|
||||||
this.#data = await getAssetOcr({ id });
|
this.#data = await getAssetOcr({ id });
|
||||||
|
}, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
|
this.#cleared = true;
|
||||||
this.#data = [];
|
this.#data = [];
|
||||||
this.showOverlay = false;
|
this.showOverlay = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user