diff --git a/mobile/lib/infrastructure/repositories/storage.repository.dart b/mobile/lib/infrastructure/repositories/storage.repository.dart index 774f44f568..ed2bb678ce 100644 --- a/mobile/lib/infrastructure/repositories/storage.repository.dart +++ b/mobile/lib/infrastructure/repositories/storage.repository.dart @@ -82,7 +82,6 @@ class StorageRepository { return entity; } - /// Check if an asset is available locally or needs to be downloaded from iCloud Future isAssetAvailableLocally(String assetId) async { final log = Logger('StorageRepository'); @@ -100,7 +99,6 @@ class StorageRepository { } } - /// Load file from iCloud with progress handler (for iOS) Future loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async { final log = Logger('StorageRepository'); @@ -118,7 +116,6 @@ class StorageRepository { } } - /// Load live photo motion file from iCloud with progress handler (for iOS) Future loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async { final log = Logger('StorageRepository'); diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart index 9a52f28deb..ba9ccf2ffd 100644 --- a/mobile/lib/presentation/pages/drift_remote_album.page.dart +++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart @@ -171,67 +171,6 @@ class _RemoteAlbumPageState extends ConsumerState { unawaited(context.pushRoute(DriftActivitiesRoute(album: _album))); } - Future 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 { 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(); + 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( + 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, + ); + }, + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index 675b5bf219..1ca875e483 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -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)), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index d992d243ee..2a7ac9c7fe 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -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 { 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); } } diff --git a/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart b/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart index b82d951b68..355e1a01a8 100644 --- a/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart +++ b/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart @@ -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? 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 = []; - 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(), + ); + }, ); } } diff --git a/mobile/lib/providers/background_sync.provider.dart b/mobile/lib/providers/background_sync.provider.dart index a61cd93022..5d6a2f0f4d 100644 --- a/mobile/lib/providers/background_sync.provider.dart +++ b/mobile/lib/providers/background_sync.provider.dart @@ -5,16 +5,21 @@ import 'package:immich_mobile/providers/sync_status.provider.dart'; final backgroundSyncProvider = Provider((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, diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index f61cca9ddb..6d49b83f06 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -117,7 +117,6 @@ class DriftBackupState { final Map uploadItems; final CancellationToken? cancelToken; - /// iCloud download progress for assets (assetId -> progress 0.0-1.0) final Map iCloudDownloadProgress; const DriftBackupState({ @@ -227,8 +226,8 @@ class DriftBackupNotifier extends StateNotifier { ), ) { { - _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate); - _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate); + _statusSubscription = _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate); + _progressSubscription = _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate); } } @@ -239,6 +238,10 @@ class DriftBackupNotifier extends StateNotifier { /// 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.from(state.uploadItems); updatedItems.remove(taskId); @@ -247,6 +250,10 @@ class DriftBackupNotifier extends StateNotifier { } void _handleTaskStatusUpdate(TaskStatusUpdate update) { + if (!mounted) { + _logger.warning("Skip _handleTaskStatusUpdate: notifier disposed"); + return; + } final taskId = update.task.taskId; switch (update.status) { @@ -306,6 +313,10 @@ class DriftBackupNotifier extends StateNotifier { } 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; @@ -347,7 +358,15 @@ class DriftBackupNotifier extends StateNotifier { } Future 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, @@ -358,6 +377,10 @@ class DriftBackupNotifier extends StateNotifier { } void updateError(BackupError error) async { + if (!mounted) { + _logger.warning("Skip updateError: notifier disposed"); + return; + } state = state.copyWith(error: error); } @@ -390,7 +413,6 @@ class DriftBackupNotifier extends StateNotifier { void _handleICloudProgress(String localAssetId, double progress) { state = state.copyWith(iCloudDownloadProgress: {...state.iCloudDownloadProgress, localAssetId: progress}); - // Remove from progress map when download completes if (progress >= 1.0) { Future.delayed(const Duration(milliseconds: 250), () { final updatedProgress = Map.from(state.iCloudDownloadProgress); @@ -436,7 +458,6 @@ class DriftBackupNotifier extends StateNotifier { void _handleForegroundBackupError(String errorMessage) { _logger.severe("Upload failed: $errorMessage"); - // Here you can update the state to reflect the error if needed } // void _updateEnqueueCount(EnqueueStatus status) { @@ -444,10 +465,18 @@ class DriftBackupNotifier extends StateNotifier { // } Future 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..."); @@ -460,9 +489,17 @@ class DriftBackupNotifier extends StateNotifier { } Future 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) { diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index a89e475318..d170e6226d 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -142,7 +142,6 @@ class UploadRepository { } } - /// Upload a single asset with progress tracking Future uploadSingleAsset({ required File file, required String originalFileName, @@ -192,7 +191,6 @@ class UploadRepository { return null; } - /// Internal method to upload a file to the server Future _uploadFile({ required File file, required String originalFileName, @@ -239,7 +237,6 @@ class UploadRepository { } } -/// Result of an upload operation class UploadResult { final bool isSuccess; final bool isCancelled; @@ -268,7 +265,6 @@ class UploadResult { } } -/// Custom MultipartRequest with progress tracking class CustomMultipartRequest extends MultipartRequest { CustomMultipartRequest(super.method, super.url, {required this.onProgress}); diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index 724efe5e04..c015a96665 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -322,7 +322,6 @@ class UploadService { } final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name; - print("originalFileName: $originalFileName"); final deviceId = Store.get(StoreKey.deviceId); final headers = ApiService.getRequestHeaders(); diff --git a/mobile/lib/widgets/backup/backup_info_card.dart b/mobile/lib/widgets/backup/backup_info_card.dart index 2ef7e24cd7..7911679577 100644 --- a/mobile/lib/widgets/backup/backup_info_card.dart +++ b/mobile/lib/widgets/backup/backup_info_card.dart @@ -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) diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index c486d473b0..30eaf4c555 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -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 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) { diff --git a/mobile/test/presentation/widgets/remote_album/drift_album_option_widget_test.dart b/mobile/test/presentation/widgets/remote_album/drift_album_option_widget_test.dart new file mode 100644 index 0000000000..1706b4d307 --- /dev/null +++ b/mobile/test/presentation/widgets/remote_album/drift_album_option_widget_test.dart @@ -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(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(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(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); + }); + }); + }); +} diff --git a/web/src/lib/utils/date-time.ts b/web/src/lib/utils/date-time.ts index 8a50df9cfe..53996adfa2 100644 --- a/web/src/lib/utils/date-time.ts +++ b/web/src/lib/utils/date-time.ts @@ -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()) {