diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 8a237f801a..2df1283dd2 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -243,7 +243,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { } if (Platform.isIOS) { - return _ref?.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id); + return _ref?.read(driftBackupProvider.notifier).startBackupWithURLSession(currentUser.id); } final networkCapabilities = await _ref?.read(connectivityApiProvider).getCapabilities() ?? []; diff --git a/mobile/lib/infrastructure/repositories/storage.repository.dart b/mobile/lib/infrastructure/repositories/storage.repository.dart index 774f44f568..eaa6ce79f7 100644 --- a/mobile/lib/infrastructure/repositories/storage.repository.dart +++ b/mobile/lib/infrastructure/repositories/storage.repository.dart @@ -6,7 +6,9 @@ import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart'; class StorageRepository { - const StorageRepository(); + final log = Logger('StorageRepository'); + + StorageRepository(); Future getFileForAsset(String assetId) async { File? file; @@ -82,10 +84,7 @@ 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'); - try { final entity = await AssetEntity.fromId(assetId); if (entity == null) { @@ -100,10 +99,7 @@ class StorageRepository { } } - /// Load file from iCloud with progress handler (for iOS) Future loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async { - final log = Logger('StorageRepository'); - try { final entity = await AssetEntity.fromId(assetId); if (entity == null) { @@ -118,10 +114,7 @@ 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'); - try { final entity = await AssetEntity.fromId(assetId); if (entity == null) { diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index 3af009542e..058bcb559b 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -97,7 +97,6 @@ class _DriftBackupPageState extends ConsumerState { } Future stopBackup() async { - // await backupNotifier.cancel(); await backupNotifier.stopBackup(); } diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart index 5fe1dfb6a1..e09c9f2577 100644 --- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -113,7 +113,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState backgroundSync.hashAssets())); if (isBackupEnabled) { unawaited( - backupNotifier.cancel().whenComplete( + backupNotifier.stopBackup().whenComplete( () => backgroundSync.syncRemote().then((success) { if (success) { return backupNotifier.startBackup(user.id); diff --git a/mobile/lib/pages/backup/drift_backup_options.page.dart b/mobile/lib/pages/backup/drift_backup_options.page.dart index 1e5c326478..78e8b339b4 100644 --- a/mobile/lib/pages/backup/drift_backup_options.page.dart +++ b/mobile/lib/pages/backup/drift_backup_options.page.dart @@ -60,7 +60,7 @@ class DriftBackupOptionsPage extends ConsumerWidget { final backupNotifier = ref.read(driftBackupProvider.notifier); final backgroundSync = ref.read(backgroundSyncProvider); unawaited( - backupNotifier.cancel().whenComplete( + backupNotifier.stopBackup().whenComplete( () => backgroundSync.syncRemote().then((success) { if (success) { return backupNotifier.startBackup(currentUser.id); diff --git a/mobile/lib/pages/backup/drift_upload_detail.page.dart b/mobile/lib/pages/backup/drift_upload_detail.page.dart index fca1e58a04..135da2eef6 100644 --- a/mobile/lib/pages/backup/drift_upload_detail.page.dart +++ b/mobile/lib/pages/backup/drift_upload_detail.page.dart @@ -57,13 +57,11 @@ class DriftUploadDetailPage extends ConsumerWidget { itemCount: totalItems, separatorBuilder: (context, index) => const SizedBox(height: 4), itemBuilder: (context, index) { - // Show iCloud downloads first if (index < iCloudProgress.length) { final entry = iCloudProgress.entries.elementAt(index); return _buildICloudDownloadCard(context, entry.key, entry.value); } - // Then show upload items final uploadIndex = index - iCloudProgress.length; final item = uploadItems.values.elementAt(uploadIndex); return _buildUploadCard(context, item); diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 79db33104d..cbe3282621 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -132,7 +132,7 @@ class SplashScreenPageState extends ConsumerState { if (isEnableBackup) { final currentUser = Store.tryGet(StoreKey.currentUser); if (currentUser != null) { - unawaited(notifier.handleBackupResume(currentUser.id)); + unawaited(notifier.startBackup(currentUser.id)); } } } 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/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 8727f40a1a..538a9bde20 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -96,7 +96,7 @@ class NativeVideoViewer extends HookConsumerWidget { try { if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) { final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!; - final file = await const StorageRepository().getFileForAsset(id); + final file = await StorageRepository().getFileForAsset(id); if (!context.mounted) { return null; } 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/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 4b1bf3e809..eb6f0f1d5f 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -179,10 +179,7 @@ class AppLifeCycleNotifier extends StateNotifier { if (isEnableBackup) { final currentUser = Store.tryGet(StoreKey.currentUser); if (currentUser != null) { - await _safeRun( - _ref.read(driftBackupProvider.notifier).handleBackupResume(currentUser.id), - "handleBackupResume", - ); + await _safeRun(_ref.read(driftBackupProvider.notifier).startBackup(currentUser.id), "handleBackupResume"); } } } 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..d1fd206f99 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -14,7 +14,6 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/upload.service.dart'; -import 'package:immich_mobile/utils/debug_print.dart'; class EnqueueStatus { final int enqueueCount; @@ -117,7 +116,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 +225,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 +237,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 +249,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 +312,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 +357,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 +376,10 @@ class DriftBackupNotifier extends StateNotifier { } void updateError(BackupError error) async { + if (!mounted) { + _logger.warning("Skip updateError: notifier disposed"); + return; + } state = state.copyWith(error: error); } @@ -367,7 +389,6 @@ class DriftBackupNotifier extends StateNotifier { Future startBackup(String userId) { state = state.copyWith(error: BackupError.none); - // return _uploadService.startBackup(userId, _updateEnqueueCount); final cancelToken = CancellationToken(); state = state.copyWith(cancelToken: cancelToken); @@ -390,7 +411,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,39 +456,25 @@ 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) { - // state = state.copyWith(enqueueCount: status.enqueueCount, enqueueTotalCount: status.totalCount); - // } - - Future cancel() async { - dPrint(() => "Canceling backup tasks..."); - state = state.copyWith(enqueueCount: 0, enqueueTotalCount: 0, isCanceling: true, error: BackupError.none); - - final activeTaskCount = await _uploadService.cancelBackup(); - - if (activeTaskCount > 0) { - dPrint(() => "$activeTaskCount tasks left, continuing to cancel..."); - await cancel(); - } else { - dPrint(() => "All tasks canceled successfully."); - // Clear all upload items when cancellation is complete - state = state.copyWith(isCanceling: false, uploadItems: {}); + Future startBackupWithURLSession(String userId) async { + if (!mounted) { + _logger.warning("Skip handleBackupResume (pre-call): notifier disposed"); + return; } - } - - Future handleBackupResume(String userId) async { _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) { - // Start a new backup queue - _logger.info("Start a new backup queue"); - return startBackup(userId); + _logger.info("Start backup with URLSession"); + return _uploadService.startBackupWithURLSession(userId); } _logger.info("Tasks to resume: ${tasks.length}"); diff --git a/mobile/lib/providers/infrastructure/storage.provider.dart b/mobile/lib/providers/infrastructure/storage.provider.dart index ccca964027..82d1209c97 100644 --- a/mobile/lib/providers/infrastructure/storage.provider.dart +++ b/mobile/lib/providers/infrastructure/storage.provider.dart @@ -1,4 +1,4 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; -final storageRepositoryProvider = Provider((ref) => const StorageRepository()); +final storageRepositoryProvider = Provider((ref) => StorageRepository()); diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index a89e475318..120e86937b 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -21,6 +21,7 @@ class UploadTaskWithFile { final uploadRepositoryProvider = Provider((ref) => UploadRepository()); class UploadRepository { + final Logger logger = Logger('UploadRepository'); void Function(TaskStatusUpdate)? onUploadStatus; void Function(TaskProgressUpdate)? onTaskProgress; @@ -142,7 +143,6 @@ class UploadRepository { } } - /// Upload a single asset with progress tracking Future uploadSingleAsset({ required File file, required String originalFileName, @@ -160,7 +160,7 @@ class UploadRepository { httpClient: httpClient, cancelToken: cancelToken, onProgress: onProgress, - logContext: 'asset', + logContext: 'assetUpload', ); } @@ -182,7 +182,7 @@ class UploadRepository { httpClient: httpClient, cancelToken: cancelToken, onProgress: onProgress, - logContext: 'livePhoto video', + logContext: 'livePhotoVideoUpload', ); if (result.isSuccess && result.remoteAssetId != null) { @@ -192,7 +192,6 @@ class UploadRepository { return null; } - /// Internal method to upload a file to the server Future _uploadFile({ required File file, required String originalFileName, @@ -204,7 +203,6 @@ class UploadRepository { required String logContext, }) async { final String savedEndpoint = Store.get(StoreKey.serverEndpoint); - final Logger logger = Logger('UploadRepository'); try { final fileStream = file.openRead(); @@ -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..538a6afc61 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -15,7 +15,6 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; @@ -122,7 +121,7 @@ class UploadService { /// Find backup candidates /// Build the upload tasks /// Enqueue the tasks - Future startBackup(String userId, void Function(EnqueueStatus status) onEnqueueTasks) async { + Future startBackupWithURLSession(String userId) async { await _storageRepository.clearCache(); shouldAbortQueuingTasks = false; @@ -133,27 +132,17 @@ class UploadService { } const batchSize = 100; - int count = 0; - for (int i = 0; i < candidates.length; i += batchSize) { - if (shouldAbortQueuingTasks) { - break; + final batch = candidates.take(batchSize).toList(); + List tasks = []; + for (final asset in batch) { + final task = await getUploadTask(asset); + if (task != null) { + tasks.add(task); } + } - final batch = candidates.skip(i).take(batchSize).toList(); - List tasks = []; - for (final asset in batch) { - final task = await getUploadTask(asset); - if (task != null) { - tasks.add(task); - } - } - - if (tasks.isNotEmpty && !shouldAbortQueuingTasks) { - count += tasks.length; - await enqueueTasks(tasks); - - onEnqueueTasks(EnqueueStatus(enqueueCount: count, totalCount: candidates.length)); - } + if (tasks.isNotEmpty && !shouldAbortQueuingTasks) { + await enqueueTasks(tasks); } } @@ -215,39 +204,42 @@ class UploadService { } try { - int clientIndex = 0; + int currentIndex = 0; - for (int i = 0; i < candidates.length; i += concurrentUploads) { - if (shouldAbortQueuingTasks || cancelToken.isCancelled) { - break; - } + Future worker(Client httpClient) async { + while (true) { + if (shouldAbortQueuingTasks || cancelToken.isCancelled) { + break; + } - final batch = candidates.skip(i).take(concurrentUploads).toList(); - final uploadFutures = >[]; + final index = currentIndex; + if (index >= candidates.length) { + break; + } + currentIndex++; - for (final asset in batch) { - final httpClient = httpClients[clientIndex % concurrentUploads]; - clientIndex++; + final asset = candidates[index]; - uploadFutures.add( - _uploadSingleAsset( - asset, - httpClient, - cancelToken, - (bytes, totalBytes) => onProgress(asset.localId!, asset.name, bytes, totalBytes), - onSuccess, - onError, - onICloudProgress: onICloudProgress, - ), + await _uploadSingleAsset( + asset, + httpClient, + cancelToken, + (bytes, totalBytes) => onProgress(asset.localId!, asset.name, bytes, totalBytes), + onSuccess, + onError, + onICloudProgress: onICloudProgress, ); } - - await Future.wait(uploadFutures); - - if (shouldAbortQueuingTasks) { - break; - } } + + // Start all workers in parallel - each worker continuously pulls from the queue + final workerFutures = >[]; + + for (int i = 0; i < concurrentUploads; i++) { + workerFutures.add(worker(httpClients[i])); + } + + await Future.wait(workerFutures); } finally { for (final client in httpClients) { client.close(); @@ -322,7 +314,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()) {