mirror of
https://github.com/immich-app/immich.git
synced 2025-12-28 09:14:59 +03:00
Compare commits
6 Commits
fix/partne
...
lower-case
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a62435d5a | ||
|
|
0df618feee | ||
|
|
363b9276eb | ||
|
|
36d7dd9319 | ||
|
|
a57c4d9a9e | ||
|
|
724948d36d |
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
/* @import '/usr/ui/dist/theme/default.css'; */
|
||||
|
||||
@utility immich-form-input {
|
||||
@apply rounded-xl bg-slate-200 px-3 py-3 text-sm focus:border-immich-primary disabled:cursor-not-allowed disabled:bg-gray-400 disabled:text-gray-100 dark:bg-gray-600 dark:text-immich-dark-fg dark:disabled:bg-gray-800 dark:disabled:text-gray-200;
|
||||
@apply bg-gray-100 ring-1 ring-gray-200 transition outline-none focus-within:ring-1 disabled:cursor-not-allowed dark:bg-gray-800 dark:ring-black flex w-full items-center rounded-lg disabled:bg-gray-300 disabled:text-dark dark:disabled:bg-gray-900 dark:disabled:text-gray-200 flex-1 py-2.5 text-base pl-4 pr-4;
|
||||
}
|
||||
|
||||
@utility immich-form-label {
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
<h3 class="text-base font-medium text-primary">{$t('template')}</h3>
|
||||
|
||||
<div class="my-2 text-sm">
|
||||
<h4 class="uppercase">{$t('preview')}</h4>
|
||||
<h4 class="text-sm">{$t('preview')}</h4>
|
||||
</div>
|
||||
|
||||
<p class="text-sm">
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
</script>
|
||||
|
||||
<div class="mt-4 text-sm">
|
||||
<h4 class="uppercase">{$t('other_variables')}</h4>
|
||||
<h4 class="">{$t('other_variables')}</h4>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
|
||||
<div class="flex gap-12">
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('filename')}</p>
|
||||
<p class="font-medium text-primary">{$t('filename')}</p>
|
||||
<ul>
|
||||
<li>{`{{filename}}`} - IMG_123</li>
|
||||
<li>{`{{ext}}`} - jpg</li>
|
||||
@@ -17,14 +17,14 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('filetype')}</p>
|
||||
<p class="font-medium text-primary">{$t('filetype')}</p>
|
||||
<ul>
|
||||
<li>{`{{filetype}}`} - VID or IMG</li>
|
||||
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('other')}</p>
|
||||
<p class="font-medium text-primary">{$t('other')}</p>
|
||||
<ul>
|
||||
<li>{`{{assetId}}`} - Asset ID</li>
|
||||
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getParentPath } from '$lib/utils/tree-utils';
|
||||
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui';
|
||||
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiCalendar,
|
||||
mdiCamera,
|
||||
@@ -163,7 +163,7 @@
|
||||
{#if !authManager.isSharedLink && isOwner}
|
||||
<section class="px-4 pt-4 text-sm">
|
||||
<div class="flex h-10 w-full items-center justify-between">
|
||||
<h2 class="uppercase">{$t('people')}</h2>
|
||||
<Text size="small" color="muted">{$t('people')}</Text>
|
||||
<div class="flex gap-2 items-center">
|
||||
{#if people.some((person) => person.isHidden)}
|
||||
<IconButton
|
||||
@@ -266,10 +266,10 @@
|
||||
<div class="px-4 py-4">
|
||||
{#if asset.exifInfo}
|
||||
<div class="flex h-10 w-full items-center justify-between text-sm">
|
||||
<h2 class="uppercase">{$t('details')}</h2>
|
||||
<Text size="small" color="muted">{$t('details')}</Text>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="uppercase text-sm">{$t('no_exif_info_available')}</p>
|
||||
<Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
|
||||
{/if}
|
||||
|
||||
{#if dateTime}
|
||||
@@ -496,7 +496,7 @@
|
||||
|
||||
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
|
||||
<section class="px-6 dark:text-immich-dark-fg mt-4">
|
||||
<p class="uppercase text-sm">{$t('shared_by')}</p>
|
||||
<Text size="small" color="muted">{$t('shared_by')}</Text>
|
||||
<div class="flex gap-4 pt-4">
|
||||
<div>
|
||||
<UserAvatar user={asset.owner} size="md" />
|
||||
@@ -513,7 +513,9 @@
|
||||
|
||||
{#if albums.length > 0}
|
||||
<section class="px-6 py-6 dark:text-immich-dark-fg">
|
||||
<p class="uppercase pb-4 text-sm">{$t('appears_in')}</p>
|
||||
<div class="pb-4">
|
||||
<Text size="small" color="muted">{$t('appears_in')}</Text>
|
||||
</div>
|
||||
{#each albums as album (album.id)}
|
||||
<a href={resolve(`${AppRoute.ALBUMS}/${album.id}`)}>
|
||||
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { Icon, IconButton, Label } from '@immich/ui';
|
||||
import { mdiClose, mdiMagnify, mdiUnfoldMoreHorizontal } from '@mdi/js';
|
||||
import { mdiChevronDown, mdiClose, mdiMagnify } from '@mdi/js';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { FormEventHandler } from 'svelte/elements';
|
||||
@@ -251,7 +251,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:window onresize={onPositionChange} />
|
||||
<Label class="block mb-1 {hideLabel ? 'sr-only' : ''}" for={inputId}>{label}</Label>
|
||||
<Label class="block mb-1 {hideLabel ? 'sr-only' : ''} text-sm" color="muted" for={inputId}>{label}</Label>
|
||||
<div
|
||||
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
|
||||
use:focusOutside={{ onFocusOut: deactivate }}
|
||||
@@ -351,7 +351,7 @@
|
||||
size="small"
|
||||
/>
|
||||
{:else if !isOpen}
|
||||
<Icon icon={mdiUnfoldMoreHorizontal} aria-hidden />
|
||||
<Icon icon={mdiChevronDown} aria-hidden />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,7 +391,7 @@
|
||||
<li
|
||||
aria-selected={index === selectedIndex}
|
||||
bind:this={optionRefs[index]}
|
||||
class="text-start w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 break-words"
|
||||
class="text-start w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 wrap-break-words"
|
||||
id={`${listboxId}-${index}`}
|
||||
onclick={() => handleSelect(option)}
|
||||
role="option"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
|
||||
import { Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -81,8 +82,7 @@
|
||||
</script>
|
||||
|
||||
<div id="camera-selection">
|
||||
<p class="uppercase immich-form-label">{$t('camera')}</p>
|
||||
|
||||
<Text class="font-semibold">{$t('camera')}</Text>
|
||||
<div class="grid grid-auto-fit-40 gap-5 mt-1">
|
||||
<div class="w-full">
|
||||
<Combobox
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts" module>
|
||||
export interface SearchDateFilter {
|
||||
takenBefore?: string;
|
||||
takenAfter?: string;
|
||||
takenBefore?: DateTime;
|
||||
takenAfter?: DateTime;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import DateInput from '$lib/elements/DateInput.svelte';
|
||||
import { Text } from '@immich/ui';
|
||||
import { DatePicker, Text } from '@immich/ui';
|
||||
import type { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -17,23 +17,19 @@
|
||||
let { filters = $bindable() }: Props = $props();
|
||||
|
||||
let invalid = $derived(filters.takenAfter && filters.takenBefore && filters.takenAfter > filters.takenBefore);
|
||||
|
||||
const inputClasses = $derived(
|
||||
`immich-form-input w-full mt-1 hover:cursor-pointer ${invalid ? 'border border-danger' : ''}`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div id="date-range-selection" class="grid grid-auto-fit-40 gap-5">
|
||||
<label class="immich-form-label" for="start-date">
|
||||
<span class="uppercase">{$t('start_date')}</span>
|
||||
<DateInput class={inputClasses} type="date" id="start-date" name="start-date" bind:value={filters.takenAfter} />
|
||||
</label>
|
||||
<div>
|
||||
<Text class="font-semibold mb-2">{$t('start_date')}</Text>
|
||||
<DatePicker bind:value={filters.takenAfter} />
|
||||
</div>
|
||||
|
||||
<label class="immich-form-label" for="end-date">
|
||||
<span class="uppercase">{$t('end_date')}</span>
|
||||
<DateInput class={inputClasses} type="date" id="end-date" name="end-date" bind:value={filters.takenBefore} />
|
||||
</label>
|
||||
<div>
|
||||
<Text class="font-semibold mb-2">{$t('end_date')}</Text>
|
||||
<DatePicker bind:value={filters.takenBefore} />
|
||||
</div>
|
||||
</div>
|
||||
{#if invalid}
|
||||
<Text color="danger">{$t('start_date_before_end_date')}</Text>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Checkbox, Label } from '@immich/ui';
|
||||
import { Checkbox, Label, Text } from '@immich/ui';
|
||||
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
|
||||
<div id="display-options-selection">
|
||||
<fieldset>
|
||||
<legend class="uppercase immich-form-label">{$t('display_options')}</legend>
|
||||
<Text class="font-semibold mb-2">{$t('display_options')}</Text>
|
||||
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="not-in-album-checkbox" size="tiny" bind:checked={filters.isNotInAlbum} />
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
|
||||
import { Text } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -74,7 +75,7 @@
|
||||
</script>
|
||||
|
||||
<div id="location-selection">
|
||||
<p class="uppercase immich-form-label">{$t('place')}</p>
|
||||
<Text class="font-semibold">{$t('place')}</Text>
|
||||
|
||||
<div class="grid grid-auto-fit-40 gap-5 mt-1">
|
||||
<div class="w-full">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { MediaType } from '$lib/constants';
|
||||
import RadioButton from '$lib/elements/RadioButton.svelte';
|
||||
import { Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -12,7 +13,8 @@
|
||||
|
||||
<div id="media-type-selection">
|
||||
<fieldset>
|
||||
<legend class="uppercase immich-form-label">{$t('media_type')}</legend>
|
||||
<Text class="font-semibold mb-2">{$t('media_type')}</Text>
|
||||
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
|
||||
<RadioButton name="media-type" id="type-all" bind:group={filteredMedia} label={$t('all')} value={MediaType.All} />
|
||||
<RadioButton
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, LoadingSpinner } from '@immich/ui';
|
||||
import { Button, LoadingSpinner, Text } from '@immich/ui';
|
||||
import { mdiArrowRight, mdiClose } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { SvelteSet } from 'svelte/reactivity';
|
||||
@@ -63,12 +63,12 @@
|
||||
|
||||
<div id="people-selection" class="max-h-60 -mb-4 overflow-y-auto immich-scrollbar">
|
||||
<div class="flex items-center w-full justify-between gap-6">
|
||||
<p class="uppercase immich-form-label py-3">{$t('people')}</p>
|
||||
<Text class="font-semibold py-3">{$t('people')}</Text>
|
||||
<SearchBar bind:name placeholder={$t('filter_people')} showLoadingSpinner={false} />
|
||||
</div>
|
||||
|
||||
<SingleGridRow
|
||||
class="grid grid-auto-fill-20 gap-1 mt-2 overflow-y-auto immich-scrollbar"
|
||||
class="grid grid-auto-fill-20 gap-1 mt-2 overflow-y-auto immich-scrollbar space-between"
|
||||
bind:itemCount={numberOfPeople}
|
||||
>
|
||||
{#each peopleList as person (person.id)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import RadioButton from '$lib/elements/RadioButton.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { Field, Input, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -12,7 +13,7 @@
|
||||
</script>
|
||||
|
||||
<fieldset>
|
||||
<legend class="immich-form-label">{$t('search_type')}</legend>
|
||||
<Text class="font-semibold py-3">{$t('search_type')}</Text>
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1 mb-2">
|
||||
{#if featureFlagsManager.value.smartSearch}
|
||||
<RadioButton name="query-type" id="context-radio" label={$t('context')} bind:group={queryType} value="smart" />
|
||||
@@ -38,46 +39,47 @@
|
||||
</fieldset>
|
||||
|
||||
{#if queryType === 'smart'}
|
||||
<label for="context-input" class="immich-form-label">{$t('search_by_context')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full mt-1!"
|
||||
type="text"
|
||||
id="context-input"
|
||||
name="context"
|
||||
placeholder={$t('sunrise_on_the_beach')}
|
||||
bind:value={query}
|
||||
/>
|
||||
<Field label={$t('search_by_context')} class="text-sm" for="context-input">
|
||||
<Input
|
||||
type="text"
|
||||
id="context-input"
|
||||
name="context"
|
||||
placeholder={$t('sunrise_on_the_beach')}
|
||||
bind:value={query}
|
||||
aria-labelledby="context-label"
|
||||
/>
|
||||
</Field>
|
||||
{:else if queryType === 'metadata'}
|
||||
<label for="file-name-input" class="immich-form-label">{$t('search_by_filename')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full mt-1!"
|
||||
type="text"
|
||||
id="file-name-input"
|
||||
name="file-name"
|
||||
placeholder={$t('search_by_filename_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="file-name-label"
|
||||
/>
|
||||
<Field label={$t('search_by_filename')} class="text-sm" for="file-name-input">
|
||||
<Input
|
||||
type="text"
|
||||
id="file-name-input"
|
||||
name="context"
|
||||
placeholder={$t('search_by_filename_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="file-name-label"
|
||||
/>
|
||||
</Field>
|
||||
{:else if queryType === 'description'}
|
||||
<label for="description-input" class="immich-form-label">{$t('search_by_description')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full mt-1!"
|
||||
type="text"
|
||||
id="description-input"
|
||||
name="description"
|
||||
placeholder={$t('search_by_description_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="description-label"
|
||||
/>
|
||||
<Field label={$t('search_by_description')} class="text-sm" for="description">
|
||||
<Input
|
||||
type="text"
|
||||
id="description-input"
|
||||
name="description"
|
||||
placeholder={$t('search_by_description_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="description-label"
|
||||
/>
|
||||
</Field>
|
||||
{:else if queryType === 'ocr'}
|
||||
<label for="ocr-input" class="immich-form-label">{$t('search_by_ocr')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full mt-1!"
|
||||
type="text"
|
||||
id="ocr-input"
|
||||
name="ocr"
|
||||
placeholder={$t('search_by_ocr_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="ocr-label"
|
||||
/>
|
||||
<Field label={$t('search_by_ocr')} class="text-sm" for="ocr-input">
|
||||
<Input
|
||||
type="text"
|
||||
id="ocr-input"
|
||||
name="ocr"
|
||||
placeholder={$t('search_by_ocr_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="ocr-label"
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { mdiTune } from '@mdi/js';
|
||||
import type { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
@@ -47,8 +48,8 @@
|
||||
|
||||
let { searchQuery, onClose }: Props = $props();
|
||||
|
||||
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
|
||||
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
|
||||
const parseOptionalDate = (dateString?: DateTime) => (dateString ? parseUtcDate(dateString.toString()) : undefined);
|
||||
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day') || undefined;
|
||||
const formId = generateId();
|
||||
|
||||
// combobox and all the search components have terrible support for value | null so we use empty string instead.
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user