Compare commits

..

6 Commits

Author SHA1 Message Date
midzelis
fda5b3d824 fix: canceling a bucket while findMonthGroupForAsset is waiting fails 2025-12-28 14:15:53 +00:00
Lauritz Tieste
0df618feee feat: Hide/show controls when zoom state changes (#24784)
feat: hide/show controls based on zoom state in asset viewer
2025-12-27 16:02:42 -06:00
Daniel Dietzler
363b9276eb fix: album card timezone (#24855) 2025-12-26 21:40:07 -06:00
idubnori
36d7dd9319 feat(mobile): album options to kebab menu (#24204)
* feat(mobile): refactor album options into kebab menu for improved UX

* feat(mobile): update BaseActionButton to use iconColor for text styling and add delete button color in DriftRemoteAlbumOption

* feat: const Divider(height: 1)

* fix(mobile): update icon color for album options menu button

* chore: refactor

* chore: refactor

* add test

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-26 18:46:05 +00:00
Peter Ombodi
a57c4d9a9e fix(drift backup notifier): add lifecycle guards and dispose logging (#24806)
* fix(drift backup notifier): add lifecycle guards and dispose logging

* fix(drift backup notifier): re-read notifiers in callbacks to avoid disposed backup notifier

* fix(drift backup notifier): increase the log level to warning.

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2025-12-26 18:44:07 +00:00
Marcin Wróblewski
724948d36d feat(mobile): use tabular figures in backup info card (#24820)
* feat(mobile): use tabular figures in backup info card

during large (initial) backups current non-tabular figures are jumping around the UI, making the UI hard to follow. this change makes sure there’s no jump in text width between e.g. 7888 to 7111

* chore: use const
2025-12-25 22:27:33 -06:00
15 changed files with 994 additions and 417 deletions

View File

@@ -171,67 +171,6 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
unawaited(context.pushRoute(DriftActivitiesRoute(album: _album)));
}
Future<void> showOptionSheet(BuildContext context) async {
final user = ref.watch(currentUserProvider);
final isOwner = user != null ? user.id == _album.ownerId : false;
final canAddPhotos =
await ref.read(remoteAlbumServiceProvider).getUserRole(_album.id, user?.id ?? '') == AlbumUserRole.editor;
unawaited(
showModalBottomSheet(
context: context,
backgroundColor: context.colorScheme.surface,
isScrollControlled: false,
builder: (context) {
return DriftRemoteAlbumOption(
onDeleteAlbum: isOwner
? () async {
await deleteAlbum(context);
if (context.mounted) {
context.pop();
}
}
: null,
onAddUsers: isOwner
? () async {
await addUsers(context);
context.pop();
}
: null,
onAddPhotos: isOwner || canAddPhotos
? () async {
await addAssets(context);
context.pop();
}
: null,
onToggleAlbumOrder: isOwner
? () async {
await toggleAlbumOrder();
context.pop();
}
: null,
onEditAlbum: isOwner
? () async {
context.pop();
await showEditTitleAndDescription(context);
}
: null,
onCreateSharedLink: isOwner
? () async {
context.pop();
unawaited(context.pushRoute(SharedLinkEditRoute(albumId: _album.id)));
}
: null,
onShowOptions: () {
context.pop();
context.pushRoute(DriftAlbumOptionsRoute(album: _album));
},
);
},
),
);
}
@override
Widget build(BuildContext context) {
final user = ref.watch(currentUserProvider);
@@ -249,8 +188,16 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
child: Timeline(
appBar: RemoteAlbumSliverAppBar(
icon: Icons.photo_album_outlined,
onShowOptions: () => showOptionSheet(context),
onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null,
kebabMenu: _AlbumKebabMenu(
album: _album,
onDeleteAlbum: () => deleteAlbum(context),
onAddUsers: () => addUsers(context),
onAddPhotos: () => addAssets(context),
onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditAlbum: () => showEditTitleAndDescription(context),
onCreateSharedLink: () => unawaited(context.pushRoute(SharedLinkEditRoute(albumId: _album.id))),
onShowOptions: () => context.pushRoute(DriftAlbumOptionsRoute(album: _album)),
),
onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null,
onActivity: () => showActivity(context),
),
@@ -414,3 +361,77 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
);
}
}
class _AlbumKebabMenu extends ConsumerWidget {
final RemoteAlbum album;
final VoidCallback? onDeleteAlbum;
final VoidCallback? onAddUsers;
final VoidCallback? onAddPhotos;
final VoidCallback? onToggleAlbumOrder;
final VoidCallback? onEditAlbum;
final VoidCallback? onCreateSharedLink;
final VoidCallback? onShowOptions;
const _AlbumKebabMenu({
required this.album,
this.onDeleteAlbum,
this.onAddUsers,
this.onAddPhotos,
this.onToggleAlbumOrder,
this.onEditAlbum,
this.onCreateSharedLink,
this.onShowOptions,
});
double _calculateScrollProgress(FlexibleSpaceBarSettings? settings) {
if (settings?.maxExtent == null || settings?.minExtent == null) {
return 1.0;
}
final deltaExtent = settings!.maxExtent - settings.minExtent;
if (deltaExtent <= 0.0) {
return 1.0;
}
return (1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent).clamp(0.0, 1.0);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
final iconColor = Color.lerp(Colors.white, context.primaryColor, scrollProgress);
final iconShadows = [
if (scrollProgress < 0.95)
Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
else
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
];
final user = ref.watch(currentUserProvider);
final isOwner = user != null && user.id == album.ownerId;
return FutureBuilder<bool>(
future: ref
.read(remoteAlbumServiceProvider)
.getUserRole(album.id, user?.id ?? '')
.then((role) => role == AlbumUserRole.editor),
builder: (context, snapshot) {
final canAddPhotos = snapshot.data ?? false;
return DriftRemoteAlbumOption(
iconColor: iconColor,
iconShadows: iconShadows,
onDeleteAlbum: isOwner ? onDeleteAlbum : null,
onAddUsers: isOwner ? onAddUsers : null,
onAddPhotos: isOwner || canAddPhotos ? onAddPhotos : null,
onToggleAlbumOrder: isOwner ? onToggleAlbumOrder : null,
onEditAlbum: isOwner ? onEditAlbum : null,
onCreateSharedLink: isOwner ? onCreateSharedLink : null,
onShowOptions: onShowOptions,
);
},
);
}
}

View File

@@ -53,7 +53,7 @@ class BaseActionButton extends ConsumerWidget {
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
leadingIcon: Icon(iconData, color: effectiveIconColor),
onPressed: onPressed,
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16)),
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)),
);
}

View File

@@ -92,6 +92,8 @@ class AssetViewer extends ConsumerStatefulWidget {
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlayerControlsProvider.notifier).pause();
// Hide controls by default for videos and motion photos
ref.read(assetViewerProvider.notifier).setControls(false);
}
}
}
@@ -525,7 +527,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
if (scaleState != PhotoViewScaleState.initial) {
ref.read(assetViewerProvider.notifier).setControls(false);
ref.read(videoPlayerControlsProvider.notifier).pause();
return;
}
if (!showingBottomSheet) {
ref.read(assetViewerProvider.notifier).setControls(true);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
class DriftRemoteAlbumOption extends ConsumerWidget {
const DriftRemoteAlbumOption({
@@ -14,6 +15,8 @@ class DriftRemoteAlbumOption extends ConsumerWidget {
this.onToggleAlbumOrder,
this.onEditAlbum,
this.onShowOptions,
this.iconColor,
this.iconShadows,
});
final VoidCallback? onAddPhotos;
@@ -24,73 +27,131 @@ class DriftRemoteAlbumOption extends ConsumerWidget {
final VoidCallback? onToggleAlbumOrder;
final VoidCallback? onEditAlbum;
final VoidCallback? onShowOptions;
final Color? iconColor;
final List<Shadow>? iconShadows;
@override
Widget build(BuildContext context, WidgetRef ref) {
TextStyle textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w600);
final theme = context.themeData;
final menuChildren = <Widget>[];
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: ListView(
shrinkWrap: true,
children: [
if (onEditAlbum != null)
ListTile(
leading: const Icon(Icons.edit),
title: Text('edit_album'.t(context: context), style: textStyle),
onTap: onEditAlbum,
),
if (onAddPhotos != null)
ListTile(
leading: const Icon(Icons.add_a_photo),
title: Text('add_photos'.t(context: context), style: textStyle),
onTap: onAddPhotos,
),
if (onAddUsers != null)
ListTile(
leading: const Icon(Icons.group_add),
title: Text('album_viewer_page_share_add_users'.t(context: context), style: textStyle),
onTap: onAddUsers,
),
if (onLeaveAlbum != null)
ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: Text('leave_album'.t(context: context), style: textStyle),
onTap: onLeaveAlbum,
),
if (onToggleAlbumOrder != null)
ListTile(
leading: const Icon(Icons.swap_vert_rounded),
title: Text('change_display_order'.t(context: context), style: textStyle),
onTap: onToggleAlbumOrder,
),
if (onCreateSharedLink != null)
ListTile(
leading: const Icon(Icons.link),
title: Text('create_shared_link'.t(context: context), style: textStyle),
onTap: onCreateSharedLink,
),
if (onShowOptions != null)
ListTile(
leading: const Icon(Icons.settings),
title: Text('options'.t(context: context), style: textStyle),
onTap: onShowOptions,
),
if (onDeleteAlbum != null) ...[
const Divider(indent: 16, endIndent: 16),
ListTile(
leading: Icon(Icons.delete, color: context.isDarkTheme ? Colors.red[400] : Colors.red[800]),
title: Text(
'delete_album'.t(context: context),
style: textStyle.copyWith(color: context.isDarkTheme ? Colors.red[400] : Colors.red[800]),
),
onTap: onDeleteAlbum,
),
],
],
if (onEditAlbum != null) {
menuChildren.add(
BaseActionButton(
label: 'edit_album'.t(context: context),
iconData: Icons.edit,
onPressed: onEditAlbum,
menuItem: true,
),
);
}
if (onAddPhotos != null) {
menuChildren.add(
BaseActionButton(
label: 'add_photos'.t(context: context),
iconData: Icons.add_a_photo,
onPressed: onAddPhotos,
menuItem: true,
),
);
}
if (onAddUsers != null) {
menuChildren.add(
BaseActionButton(
label: 'album_viewer_page_share_add_users'.t(context: context),
iconData: Icons.group_add,
onPressed: onAddUsers,
menuItem: true,
),
);
}
if (onLeaveAlbum != null) {
menuChildren.add(
BaseActionButton(
label: 'leave_album'.t(context: context),
iconData: Icons.person_remove_rounded,
onPressed: onLeaveAlbum,
menuItem: true,
),
);
}
if (onToggleAlbumOrder != null) {
menuChildren.add(
BaseActionButton(
label: 'change_display_order'.t(context: context),
iconData: Icons.swap_vert_rounded,
onPressed: onToggleAlbumOrder,
menuItem: true,
),
);
}
if (onCreateSharedLink != null) {
menuChildren.add(
BaseActionButton(
label: 'create_shared_link'.t(context: context),
iconData: Icons.link,
onPressed: onCreateSharedLink,
menuItem: true,
),
);
}
if (onShowOptions != null) {
menuChildren.add(
BaseActionButton(
label: 'options'.t(context: context),
iconData: Icons.settings,
onPressed: onShowOptions,
menuItem: true,
),
);
}
if (onDeleteAlbum != null) {
menuChildren.add(const Divider(height: 1));
menuChildren.add(
BaseActionButton(
label: 'delete_album'.t(context: context),
iconData: Icons.delete,
iconColor: context.isDarkTheme ? Colors.red[400] : Colors.red[800],
onPressed: onDeleteAlbum,
menuItem: true,
),
);
}
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(theme.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 150),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: menuChildren,
),
),
],
builder: (context, controller, child) {
return IconButton(
icon: Icon(Icons.more_vert_rounded, color: iconColor ?? Colors.white, shadows: iconShadows),
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
);
},
);
}
}

View File

@@ -5,16 +5,21 @@ import 'package:immich_mobile/providers/sync_status.provider.dart';
final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
final syncStatusNotifier = ref.read(syncStatusProvider.notifier);
final backupProvider = ref.read(driftBackupProvider.notifier);
final manager = BackgroundSyncManager(
onRemoteSyncStart: () {
syncStatusNotifier.startRemoteSync();
backupProvider.updateError(BackupError.none);
final backupProvider = ref.read(driftBackupProvider.notifier);
if (backupProvider.mounted) {
backupProvider.updateError(BackupError.none);
}
},
onRemoteSyncComplete: (isSuccess) {
syncStatusNotifier.completeRemoteSync();
backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed);
final backupProvider = ref.read(driftBackupProvider.notifier);
if (backupProvider.mounted) {
backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed);
}
},
onRemoteSyncError: syncStatusNotifier.errorRemoteSync,
onLocalSyncStart: syncStatusNotifier.startLocalSync,

View File

@@ -212,8 +212,8 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
),
) {
{
_uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
_uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
_statusSubscription = _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate);
_progressSubscription = _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate);
}
}
@@ -224,6 +224,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
/// Remove upload item from state
void _removeUploadItem(String taskId) {
if (!mounted) {
_logger.warning("Skip _removeUploadItem: notifier disposed");
return;
}
if (state.uploadItems.containsKey(taskId)) {
final updatedItems = Map<String, DriftUploadStatus>.from(state.uploadItems);
updatedItems.remove(taskId);
@@ -232,6 +236,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
void _handleTaskStatusUpdate(TaskStatusUpdate update) {
if (!mounted) {
_logger.warning("Skip _handleTaskStatusUpdate: notifier disposed");
return;
}
final taskId = update.task.taskId;
switch (update.status) {
@@ -291,6 +299,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
void _handleTaskProgressUpdate(TaskProgressUpdate update) {
if (!mounted) {
_logger.warning("Skip _handleTaskProgressUpdate: notifier disposed");
return;
}
final taskId = update.task.taskId;
final filename = update.task.displayName;
final progress = update.progress;
@@ -332,7 +344,15 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> getBackupStatus(String userId) async {
if (!mounted) {
_logger.warning("Skip getBackupStatus (pre-call): notifier disposed");
return;
}
final counts = await _uploadService.getBackupCounts(userId);
if (!mounted) {
_logger.warning("Skip getBackupStatus (post-call): notifier disposed");
return;
}
state = state.copyWith(
totalCount: counts.total,
@@ -343,6 +363,10 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
void updateError(BackupError error) async {
if (!mounted) {
_logger.warning("Skip updateError: notifier disposed");
return;
}
state = state.copyWith(error: error);
}
@@ -360,10 +384,18 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> cancel() async {
if (!mounted) {
_logger.warning("Skip cancel (pre-call): notifier disposed");
return;
}
dPrint(() => "Canceling backup tasks...");
state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true, error: BackupError.none);
final activeTaskCount = await _uploadService.cancelBackup();
if (!mounted) {
_logger.warning("Skip cancel (post-call): notifier disposed");
return;
}
if (activeTaskCount > 0) {
dPrint(() => "$activeTaskCount tasks left, continuing to cancel...");
@@ -376,9 +408,17 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> handleBackupResume(String userId) async {
if (!mounted) {
_logger.warning("Skip handleBackupResume (pre-call): notifier disposed");
return;
}
_logger.info("Resuming backup tasks...");
state = state.copyWith(error: BackupError.none);
final tasks = await _uploadService.getActiveTasks(kBackupGroup);
if (!mounted) {
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
return;
}
_logger.info("Found ${tasks.length} tasks");
if (tasks.isEmpty) {

View File

@@ -53,6 +53,7 @@ class BackupInfoCard extends StatelessWidget {
info,
style: context.textTheme.titleLarge?.copyWith(
color: context.colorScheme.onSurface.withAlpha(isLoading ? 50 : 255),
fontFeatures: const [FontFeature.tabularFigures()],
),
),
if (isLoading)

View File

@@ -24,15 +24,13 @@ class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget {
const RemoteAlbumSliverAppBar({
super.key,
this.icon = Icons.camera,
this.onShowOptions,
this.onToggleAlbumOrder,
required this.kebabMenu,
this.onEditTitle,
this.onActivity,
});
final IconData icon;
final void Function()? onShowOptions;
final void Function()? onToggleAlbumOrder;
final Widget kebabMenu;
final void Function()? onEditTitle;
final void Function()? onActivity;
@@ -91,21 +89,12 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
onPressed: () => context.maybePop(),
),
actions: [
if (widget.onToggleAlbumOrder != null)
IconButton(
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onToggleAlbumOrder,
),
if (currentAlbum.isActivityEnabled && currentAlbum.isShared)
IconButton(
icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onActivity,
),
if (widget.onShowOptions != null)
IconButton(
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onShowOptions,
),
widget.kebabMenu,
],
title: Builder(
builder: (context) {

View File

@@ -0,0 +1,500 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart';
import '../../../widget_tester_extensions.dart';
void main() {
group('DriftRemoteAlbumOption', () {
testWidgets('shows kebab menu icon button', (tester) async {
await tester.pumpConsumerWidget(
const DriftRemoteAlbumOption(),
);
expect(find.byIcon(Icons.more_vert_rounded), findsOneWidget);
});
testWidgets('opens menu when icon button is tapped', (tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsOneWidget);
});
testWidgets('shows edit album option when onEditAlbum is provided',
(tester) async {
bool editCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () => editCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsOneWidget);
await tester.tap(find.byIcon(Icons.edit));
await tester.pumpAndSettle();
expect(editCalled, isTrue);
});
testWidgets('hides edit album option when onEditAlbum is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onAddPhotos: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsNothing);
});
testWidgets('shows add photos option when onAddPhotos is provided',
(tester) async {
bool addPhotosCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onAddPhotos: () => addPhotosCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.add_a_photo), findsOneWidget);
await tester.tap(find.byIcon(Icons.add_a_photo));
await tester.pumpAndSettle();
expect(addPhotosCalled, isTrue);
});
testWidgets('hides add photos option when onAddPhotos is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.add_a_photo), findsNothing);
});
testWidgets('shows add users option when onAddUsers is provided',
(tester) async {
bool addUsersCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onAddUsers: () => addUsersCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.group_add), findsOneWidget);
await tester.tap(find.byIcon(Icons.group_add));
await tester.pumpAndSettle();
expect(addUsersCalled, isTrue);
});
testWidgets('hides add users option when onAddUsers is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.group_add), findsNothing);
});
testWidgets('shows leave album option when onLeaveAlbum is provided',
(tester) async {
bool leaveAlbumCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onLeaveAlbum: () => leaveAlbumCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.person_remove_rounded), findsOneWidget);
await tester.tap(find.byIcon(Icons.person_remove_rounded));
await tester.pumpAndSettle();
expect(leaveAlbumCalled, isTrue);
});
testWidgets('hides leave album option when onLeaveAlbum is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.person_remove_rounded), findsNothing);
});
testWidgets(
'shows toggle album order option when onToggleAlbumOrder is provided',
(tester) async {
bool toggleOrderCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onToggleAlbumOrder: () => toggleOrderCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.swap_vert_rounded), findsOneWidget);
await tester.tap(find.byIcon(Icons.swap_vert_rounded));
await tester.pumpAndSettle();
expect(toggleOrderCalled, isTrue);
});
testWidgets('hides toggle album order option when onToggleAlbumOrder is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.swap_vert_rounded), findsNothing);
});
testWidgets(
'shows create shared link option when onCreateSharedLink is provided',
(tester) async {
bool createSharedLinkCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onCreateSharedLink: () => createSharedLinkCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.link), findsOneWidget);
await tester.tap(find.byIcon(Icons.link));
await tester.pumpAndSettle();
expect(createSharedLinkCalled, isTrue);
});
testWidgets('hides create shared link option when onCreateSharedLink is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.link), findsNothing);
});
testWidgets('shows options option when onShowOptions is provided',
(tester) async {
bool showOptionsCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onShowOptions: () => showOptionsCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.settings), findsOneWidget);
await tester.tap(find.byIcon(Icons.settings));
await tester.pumpAndSettle();
expect(showOptionsCalled, isTrue);
});
testWidgets('hides options option when onShowOptions is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.settings), findsNothing);
});
testWidgets('shows delete album option when onDeleteAlbum is provided',
(tester) async {
bool deleteAlbumCalled = false;
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onDeleteAlbum: () => deleteAlbumCalled = true,
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.delete), findsOneWidget);
await tester.tap(find.byIcon(Icons.delete));
await tester.pumpAndSettle();
expect(deleteAlbumCalled, isTrue);
});
testWidgets('hides delete album option when onDeleteAlbum is null',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.delete), findsNothing);
});
testWidgets('shows divider before delete album option', (tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
onDeleteAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byType(Divider), findsOneWidget);
});
testWidgets('shows all options when all callbacks are provided',
(tester) async {
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
onAddPhotos: () {},
onAddUsers: () {},
onLeaveAlbum: () {},
onToggleAlbumOrder: () {},
onCreateSharedLink: () {},
onShowOptions: () {},
onDeleteAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsOneWidget);
expect(find.byIcon(Icons.add_a_photo), findsOneWidget);
expect(find.byIcon(Icons.group_add), findsOneWidget);
expect(find.byIcon(Icons.person_remove_rounded), findsOneWidget);
expect(find.byIcon(Icons.swap_vert_rounded), findsOneWidget);
expect(find.byIcon(Icons.link), findsOneWidget);
expect(find.byIcon(Icons.settings), findsOneWidget);
expect(find.byIcon(Icons.delete), findsOneWidget);
expect(find.byType(Divider), findsOneWidget);
});
testWidgets('shows no options when all callbacks are null', (tester) async {
await tester.pumpConsumerWidget(
const DriftRemoteAlbumOption(),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.edit), findsNothing);
expect(find.byIcon(Icons.add_a_photo), findsNothing);
expect(find.byIcon(Icons.group_add), findsNothing);
expect(find.byIcon(Icons.person_remove_rounded), findsNothing);
expect(find.byIcon(Icons.swap_vert_rounded), findsNothing);
expect(find.byIcon(Icons.link), findsNothing);
expect(find.byIcon(Icons.settings), findsNothing);
expect(find.byIcon(Icons.delete), findsNothing);
});
testWidgets('uses custom icon color when provided', (tester) async {
const customColor = Colors.red;
await tester.pumpConsumerWidget(
const DriftRemoteAlbumOption(
iconColor: customColor,
),
);
final iconButton = tester.widget<IconButton>(find.byType(IconButton));
final icon = iconButton.icon as Icon;
expect(icon.color, equals(customColor));
});
testWidgets('uses default white color when iconColor is null',
(tester) async {
await tester.pumpConsumerWidget(
const DriftRemoteAlbumOption(),
);
final iconButton = tester.widget<IconButton>(find.byType(IconButton));
final icon = iconButton.icon as Icon;
expect(icon.color, equals(Colors.white));
});
testWidgets('applies icon shadows when provided', (tester) async {
final shadows = [
const Shadow(offset: Offset(0, 2), blurRadius: 5, color: Colors.black),
];
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
iconShadows: shadows,
),
);
final iconButton = tester.widget<IconButton>(find.byType(IconButton));
final icon = iconButton.icon as Icon;
expect(icon.shadows, equals(shadows));
});
group('owner vs non-owner scenarios', () {
testWidgets('owner sees all management options', (tester) async {
// Simulating owner scenario - all callbacks provided
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onEditAlbum: () {},
onAddPhotos: () {},
onAddUsers: () {},
onToggleAlbumOrder: () {},
onCreateSharedLink: () {},
onShowOptions: () {},
onDeleteAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
// Owner should see all management options
expect(find.byIcon(Icons.edit), findsOneWidget);
expect(find.byIcon(Icons.add_a_photo), findsOneWidget);
expect(find.byIcon(Icons.group_add), findsOneWidget);
expect(find.byIcon(Icons.swap_vert_rounded), findsOneWidget);
expect(find.byIcon(Icons.link), findsOneWidget);
expect(find.byIcon(Icons.delete), findsOneWidget);
// Owner should NOT see leave album
expect(find.byIcon(Icons.person_remove_rounded), findsNothing);
});
testWidgets('non-owner with editor role sees limited options',
(tester) async {
// Simulating non-owner with editor role - can add photos, show options, leave
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onAddPhotos: () {},
onShowOptions: () {},
onLeaveAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
// Editor can add photos
expect(find.byIcon(Icons.add_a_photo), findsOneWidget);
// Can see options
expect(find.byIcon(Icons.settings), findsOneWidget);
// Can leave album
expect(find.byIcon(Icons.person_remove_rounded), findsOneWidget);
// Cannot see owner-only options
expect(find.byIcon(Icons.edit), findsNothing);
expect(find.byIcon(Icons.group_add), findsNothing);
expect(find.byIcon(Icons.swap_vert_rounded), findsNothing);
expect(find.byIcon(Icons.link), findsNothing);
expect(find.byIcon(Icons.delete), findsNothing);
});
testWidgets('non-owner viewer sees minimal options', (tester) async {
// Simulating viewer - can only show options and leave
await tester.pumpConsumerWidget(
DriftRemoteAlbumOption(
onShowOptions: () {},
onLeaveAlbum: () {},
),
);
await tester.tap(find.byIcon(Icons.more_vert_rounded));
await tester.pumpAndSettle();
// Can see options
expect(find.byIcon(Icons.settings), findsOneWidget);
// Can leave album
expect(find.byIcon(Icons.person_remove_rounded), findsOneWidget);
// Cannot see any other options
expect(find.byIcon(Icons.edit), findsNothing);
expect(find.byIcon(Icons.add_a_photo), findsNothing);
expect(find.byIcon(Icons.group_add), findsNothing);
expect(find.byIcon(Icons.swap_vert_rounded), findsNothing);
expect(find.byIcon(Icons.link), findsNothing);
expect(find.byIcon(Icons.delete), findsNothing);
});
});
});
}

View File

@@ -8020,55 +8020,6 @@
"x-immich-state": "Alpha"
}
},
"/plugins/triggers": {
"get": {
"description": "Retrieve a list of all available plugin triggers.",
"operationId": "getPluginTriggers",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/PluginTriggerResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "List all plugin triggers",
"tags": [
"Plugins"
],
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-permission": "plugin.read",
"x-immich-state": "Alpha"
}
},
"/plugins/{id}": {
"get": {
"description": "Retrieve information about a specific plugin by its ID.",
@@ -10381,21 +10332,6 @@
"format": "uuid",
"type": "string"
}
},
{
"name": "id",
"required": false,
"in": "query",
"x-immich-history": [
{
"version": "v2.5.0",
"state": "Added"
}
],
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
@@ -18346,7 +18282,7 @@
},
"supportedContexts": {
"items": {
"$ref": "#/components/schemas/PluginContextType"
"$ref": "#/components/schemas/PluginContext"
},
"type": "array"
},
@@ -18365,7 +18301,7 @@
],
"type": "object"
},
"PluginContextType": {
"PluginContext": {
"enum": [
"asset",
"album",
@@ -18393,7 +18329,7 @@
},
"supportedContexts": {
"items": {
"$ref": "#/components/schemas/PluginContextType"
"$ref": "#/components/schemas/PluginContext"
},
"type": "array"
},
@@ -18465,29 +18401,6 @@
],
"type": "object"
},
"PluginTriggerResponseDto": {
"properties": {
"contextType": {
"allOf": [
{
"$ref": "#/components/schemas/PluginContextType"
}
]
},
"type": {
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
}
},
"required": [
"contextType",
"type"
],
"type": "object"
},
"PluginTriggerType": {
"enum": [
"AssetCreate",
@@ -23403,11 +23316,11 @@
"type": "string"
},
"triggerType": {
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
"enum": [
"AssetCreate",
"PersonRecognized"
],
"type": "string"
}
},
"required": [
@@ -23445,13 +23358,6 @@
},
"name": {
"type": "string"
},
"triggerType": {
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
}
},
"type": "object"

View File

@@ -183,107 +183,107 @@
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} />
{/if}
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
{#if showSlideshow && !isLocked}
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
{/if}
{#if isOwner && showDownloadButton}
<DownloadAction asset={toTimelineAsset(asset)} menuItem />
{/if}
{#if !isLocked}
{#if asset.isTrashed}
<RestoreAction {asset} {onAction} />
{:else}
<AddToAlbumAction {asset} {onAction} />
<AddToAlbumAction {asset} {onAction} shared />
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
{#if showSlideshow && !isLocked}
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
{/if}
{/if}
{#if isOwner}
<AddToStackAction {asset} {stack} {onAction} />
{#if stack}
<UnstackAction {stack} {onAction} />
<KeepThisDeleteOthersAction {stack} {asset} {onAction} />
{#if stack?.primaryAssetId !== asset.id}
<SetStackPrimaryAsset {stack} {asset} {onAction} />
{#if stack?.assets?.length > 2}
<RemoveAssetFromStack {asset} {stack} {onAction} />
{/if}
{/if}
{/if}
{#if album}
<SetAlbumCoverAction {asset} {album} />
{/if}
{#if person}
<SetFeaturedPhotoAction {asset} {person} {onAction} />
{/if}
{#if asset.type === AssetTypeEnum.Image && !isLocked}
<SetProfilePictureAction {asset} />
{#if showDownloadButton}
<DownloadAction asset={toTimelineAsset(asset)} menuItem />
{/if}
{#if !isLocked}
<ArchiveAction {asset} {onAction} {preAction} />
<MenuOption
icon={mdiUpload}
onClick={() => handleReplaceAsset(asset.id)}
text={$t('replace_with_upload')}
/>
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
icon={mdiImageSearch}
onClick={() => goto(resolve(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`))}
text={$t('view_in_timeline')}
/>
{/if}
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
<MenuOption
icon={mdiCompare}
onClick={() =>
goto(resolve(`${AppRoute.SEARCH}?query={"queryAssetId":"${stack?.primaryAssetId ?? asset.id}"}`))}
text={$t('view_similar_photos')}
/>
{#if asset.isTrashed}
<RestoreAction {asset} {onAction} />
{:else}
<AddToAlbumAction {asset} {onAction} />
<AddToAlbumAction {asset} {onAction} shared />
{/if}
{/if}
{#if !asset.isTrashed}
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
{/if}
{#if isOwner}
<AddToStackAction {asset} {stack} {onAction} />
{#if stack}
<UnstackAction {stack} {onAction} />
<KeepThisDeleteOthersAction {stack} {asset} {onAction} />
{#if stack?.primaryAssetId !== asset.id}
<SetStackPrimaryAsset {stack} {asset} {onAction} />
{#if stack?.assets?.length > 2}
<RemoveAssetFromStack {asset} {stack} {onAction} />
{/if}
{/if}
{/if}
{#if album}
<SetAlbumCoverAction {asset} {album} />
{/if}
{#if person}
<SetFeaturedPhotoAction {asset} {person} {onAction} />
{/if}
{#if asset.type === AssetTypeEnum.Image && !isLocked}
<SetProfilePictureAction {asset} />
{/if}
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiVideoOutline}
onClick={() => setPlayOriginalVideo(!playOriginalVideo)}
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
/>
{/if}
{#if !isLocked}
<ArchiveAction {asset} {onAction} {preAction} />
<MenuOption
icon={mdiUpload}
onClick={() => handleReplaceAsset(asset.id)}
text={$t('replace_with_upload')}
/>
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
icon={mdiImageSearch}
onClick={() => goto(resolve(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`))}
text={$t('view_in_timeline')}
/>
{/if}
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
<MenuOption
icon={mdiCompare}
onClick={() =>
goto(resolve(`${AppRoute.SEARCH}?query={"queryAssetId":"${stack?.primaryAssetId ?? asset.id}"}`))}
text={$t('view_similar_photos')}
/>
{/if}
{/if}
<hr />
<MenuOption
icon={mdiHeadSyncOutline}
onClick={() => onRunJob(AssetJobName.RefreshFaces)}
text={$getAssetJobName(AssetJobName.RefreshFaces)}
/>
<MenuOption
icon={mdiDatabaseRefreshOutline}
onClick={() => onRunJob(AssetJobName.RefreshMetadata)}
text={$getAssetJobName(AssetJobName.RefreshMetadata)}
/>
<MenuOption
icon={mdiImageRefreshOutline}
onClick={() => onRunJob(AssetJobName.RegenerateThumbnail)}
text={$getAssetJobName(AssetJobName.RegenerateThumbnail)}
/>
{#if asset.type === AssetTypeEnum.Video}
{#if !asset.isTrashed}
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
{/if}
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiVideoOutline}
onClick={() => setPlayOriginalVideo(!playOriginalVideo)}
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
/>
{/if}
<hr />
<MenuOption
icon={mdiCogRefreshOutline}
onClick={() => onRunJob(AssetJobName.TranscodeVideo)}
text={$getAssetJobName(AssetJobName.TranscodeVideo)}
icon={mdiHeadSyncOutline}
onClick={() => onRunJob(AssetJobName.RefreshFaces)}
text={$getAssetJobName(AssetJobName.RefreshFaces)}
/>
<MenuOption
icon={mdiDatabaseRefreshOutline}
onClick={() => onRunJob(AssetJobName.RefreshMetadata)}
text={$getAssetJobName(AssetJobName.RefreshMetadata)}
/>
<MenuOption
icon={mdiImageRefreshOutline}
onClick={() => onRunJob(AssetJobName.RegenerateThumbnail)}
text={$getAssetJobName(AssetJobName.RegenerateThumbnail)}
/>
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiCogRefreshOutline}
onClick={() => onRunJob(AssetJobName.TranscodeVideo)}
text={$getAssetJobName(AssetJobName.TranscodeVideo)}
/>
{/if}
{/if}
{/if}
</ButtonContextMenu>
</ButtonContextMenu>
{/if}
</div>
</div>

View File

@@ -346,7 +346,7 @@ export class TimelineManager extends VirtualScrollManager {
async findMonthGroupForAsset(asset: AssetDescriptor | AssetResponseDto) {
if (!this.isInitialized) {
await this.initTask.waitUntilCompletion();
await this.initTask.waitUntilExecution();
}
const { id } = asset;

View File

@@ -27,6 +27,9 @@ export const getShortDateRange = (startDate: string | Date, endDate: string | Da
const endDateLocalized = endDate.toLocaleString(userLocale, {
month: 'short',
year: 'numeric',
// The API returns the date in UTC. If the earliest asset was taken on Jan 1st at 1am,
// we expect the album to start in January, even if the local timezone is UTC-5 for instance.
timeZone: 'UTC',
});
if (startDate.getFullYear() === endDate.getFullYear()) {

View File

@@ -57,9 +57,6 @@
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
});
let isAllUserOwned = $derived($user && selectedAssets.every((asset) => asset.ownerId === $user.id));
const handleEscape = () => {
if ($showAssetViewer) {
return;
@@ -121,51 +118,45 @@
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
{#if isAllUserOwned}
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
></FavoriteAction>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem
unlink={assetInteraction.selectedAssets.length === 1}
onLink={handleLink}
onUnlink={handleUnlink}
/>
{/if}
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction
menuItem
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem
unlink={assetInteraction.selectedAssets.length === 1}
onLink={handleLink}
onUnlink={handleUnlink}
/>
{/if}
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction
menuItem
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if $preferences.tags.enabled}
<TagAction menuItem />
{/if}
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<hr />
<AssetJobActions />
</ButtonContextMenu>
{:else}
<DownloadAction />
{/if}
{#if $preferences.tags.enabled}
<TagAction menuItem />
{/if}
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<hr />
<AssetJobActions />
</ButtonContextMenu>
</AssetSelectControlBar>
{/if}

View File

@@ -26,7 +26,7 @@
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { lang, locale } from '$lib/stores/preferences.store';
import { preferences, user } from '$lib/stores/user.store';
import { preferences } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { parseUtcDate } from '$lib/utils/date-time';
@@ -70,8 +70,6 @@
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
let isAllUserOwned = $derived($user && assetInteraction.selectedAssets.every((asset) => asset.ownerId === $user.id));
$effect(() => {
// we want this to *only* be reactive on `terms`
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
@@ -259,6 +257,64 @@
<svelte:window bind:scrollY />
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
<section>
{#if assetInteraction.selectionActive}
<div class="fixed top-0 start-0 w-full">
<AssetSelectControlBar
assets={assetInteraction.selectedAssets}
clearSelect={() => cancelMultiselect(assetInteraction)}
>
<CreateSharedLink />
<IconButton
shape="round"
color="secondary"
variant="ghost"
aria-label={$t('select_all')}
icon={mdiSelectAll}
onclick={handleSelectAll}
/>
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum {onAddToAlbum} />
<AddToAlbum shared {onAddToAlbum} />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(assetIds, isFavorite) => {
for (const assetId of assetIds) {
const asset = searchResultAssets.find((searchAsset) => searchAsset.id === assetId);
if (asset) {
asset.isFavorite = isFavorite;
}
}
}}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />
{/if}
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
<hr />
<AssetJobActions />
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
{:else}
<div class="fixed top-0 start-0 w-full">
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div class="absolute bg-light"></div>
<div class="w-full flex-1 ps-4">
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
</div>
</ControlAppBar>
</div>
{/if}
</section>
{#if terms}
<section
id="search-chips"
@@ -362,38 +418,34 @@
<AddToAlbum {onAddToAlbum} />
<AddToAlbum shared {onAddToAlbum} />
</ButtonContextMenu>
{#if isAllUserOwned}
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => {
for (const id of ids) {
const asset = searchResultAssets.find((asset) => asset.id === id);
if (asset) {
asset.isFavorite = isFavorite;
}
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) => {
for (const id of ids) {
const asset = searchResultAssets.find((asset) => asset.id === id);
if (asset) {
asset.isFavorite = isFavorite;
}
}}
/>
}
}}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
{#if assetInteraction.isAllUserOwned}
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
{/if}
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />
{/if}
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
<hr />
<AssetJobActions />
</ButtonContextMenu>
{:else}
<DownloadAction />
{/if}
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
{#if assetInteraction.isAllUserOwned}
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
{/if}
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
<TagAction menuItem />
{/if}
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
<hr />
<AssetJobActions />
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
{:else}