Compare commits

..

5 Commits

Author SHA1 Message Date
shenlong-tanwen 95b7d66d74 feat: archive action 2026-06-28 02:05:15 +05:30
shenlong-tanwen b2e3702cf4 chore: cleanup unit tests 2026-06-28 02:02:09 +05:30
github-actions ac74bca18b chore: version v3.0.0-rc.4 2026-06-27 13:14:47 +00:00
shenlong e9d1951858 fix: dispatch menu onPressed manually (#29353)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-27 08:10:47 -05:00
Santo Shakil 6e1143e799 fix(mobile): hide video thumbnail when video is ready (#29012) 2026-06-26 22:20:40 -05:00
50 changed files with 423 additions and 544 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
[
{
"label": "v3.0.0-rc.3",
"url": "https://docs.v3.0.0-rc.3.archive.immich.app"
"label": "v3.0.0-rc.4",
"url": "https://docs.v3.0.0-rc.4.archive.immich.app"
},
{
"label": "v2.7.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "",
"main": "index.js",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "3.0.0rc3"
version = "3.0.0rc4"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"
+1 -1
View File
@@ -974,7 +974,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "3.0.0rc3"
version = "3.0.0rc4"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },
+4 -4
View File
@@ -22,8 +22,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3051,
"android.injected.version.name" => "3.0.0-rc.3",
"android.injected.version.code" => 3052,
"android.injected.version.name" => "3.0.0-rc.4",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3051,
"android.injected.version.name" => "3.0.0-rc.3",
"android.injected.version.code" => 3052,
"android.injected.version.name" => "3.0.0-rc.4",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
-2
View File
@@ -9,8 +9,6 @@ enum SortOrder {
enum TextSearchType { context, filename, description, ocr }
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
enum ActionSource { timeline, viewer }
enum ShareAssetType { original, preview }
@@ -77,4 +77,14 @@ class AssetService {
await _apiRepository.updateFavorite(remoteIds, isFavorite);
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
}
Future<void> updateArchive(List<String> remoteIds, bool isArchived) async {
if (remoteIds.isEmpty) {
return;
}
final visibility = isArchived ? AssetVisibility.archive : AssetVisibility.timeline;
await _apiRepository.updateVisibility(remoteIds, visibility);
await _remoteRepository.updateVisibility(remoteIds, visibility);
}
}
@@ -138,9 +138,7 @@ class LocalSyncService {
final Stopwatch stopwatch = Stopwatch()..start();
final deviceAlbums = await _nativeSyncApi.getAlbums();
final getAlbumsTime = stopwatch.elapsedMilliseconds;
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
final getAllTime = stopwatch.elapsedMilliseconds;
await diffSortedLists(
dbAlbums,
@@ -150,15 +148,10 @@ class LocalSyncService {
onlyFirst: removeAlbum,
onlySecond: addAlbum,
);
final diffTime = stopwatch.elapsedMilliseconds;
await _nativeSyncApi.checkpointSync();
stopwatch.stop();
_log.info(
"Full device sync took - ${stopwatch.elapsedMilliseconds}ms "
"(getAlbums=${getAlbumsTime}ms, getAll=${getAllTime - getAlbumsTime}ms, "
"diff=${diffTime - getAllTime}ms, checkpoint=${stopwatch.elapsedMilliseconds - diffTime}ms)",
);
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
} on PlatformException catch (e, s) {
if (e.code == _kSyncCancelledCode) {
_log.warning("Full device sync cancelled");
+2 -4
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:isolate';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
@@ -67,16 +66,15 @@ class LogService {
}
void _handleLogRecord(LogRecord r) {
final int isolateHash = Isolate.current.hashCode;
dPrint(
() =>
'[${r.level.name}] [${r.time}] [${r.loggerName}] [$isolateHash] ${r.message}'
'[${r.level.name}] [${r.time}] [${r.loggerName}] ${r.message}'
'${r.error == null ? '' : '\nError: ${r.error}'}'
'${r.stackTrace == null ? '' : '\nStack: ${r.stackTrace}'}',
);
final record = LogMessage(
message: '[$isolateHash] ${r.message}',
message: r.message,
level: r.level.toLogLevel(),
createdAt: r.time,
logger: r.loggerName,
@@ -10,7 +10,6 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:logging/logging.dart';
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
@@ -91,7 +90,6 @@ class TimelineFactory {
}
class TimelineService {
static final Logger _log = Logger('TimelineService');
final TimelineAssetSource _assetSource;
final TimelineBucketSource _bucketSource;
final TimelineOrigin origin;
@@ -107,49 +105,34 @@ class TimelineService {
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
_bucketSubscription = _bucketSource().listen(
(buckets) {
_mutex.run(() async {
try {
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
_bucketSubscription = _bucketSource().listen((buckets) {
_mutex.run(() async {
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
_log.info(
'[$origin] bucket emission: ${buckets.length} buckets / $totalAssets assets '
'(current _totalAssets=$_totalAssets, _bufferOffset=$_bufferOffset, _buffer=${_buffer.length})',
);
if (totalAssets == 0) {
_bufferOffset = 0;
_buffer = [];
} else {
final int offset;
final int count;
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
// we need to reset the buffer and load the first batch of assets.
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
offset = 0;
count = kTimelineAssetLoadBatchSize;
} else {
offset = _bufferOffset;
count = math.min(_buffer.length, totalAssets - _bufferOffset);
}
_buffer = await _assetSource(offset, count);
_bufferOffset = offset;
_log.info('[$origin] buffer reloaded: offset=$offset requested=$count got=${_buffer.length}');
}
_totalAssets = totalAssets;
EventStream.shared.emit(const TimelineReloadEvent());
} catch (error, stack) {
_log.severe('[$origin] bucket reload FAILED — _totalAssets stuck at $_totalAssets', error, stack);
rethrow;
if (totalAssets == 0) {
_bufferOffset = 0;
_buffer = [];
} else {
final int offset;
final int count;
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
// we need to reset the buffer and load the first batch of assets.
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
offset = 0;
count = kTimelineAssetLoadBatchSize;
} else {
offset = _bufferOffset;
count = math.min(_buffer.length, totalAssets - _bufferOffset);
}
});
},
onError: (Object error, StackTrace stack) {
_log.severe('[$origin] bucket stream errored', error, stack);
},
);
_buffer = await _assetSource(offset, count);
_bufferOffset = offset;
}
// change the state's total assets count only after the buffer is reloaded
_totalAssets = totalAssets;
EventStream.shared.emit(const TimelineReloadEvent());
});
});
}
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
@@ -181,13 +164,6 @@ class TimelineService {
_buffer = await _assetSource(start, len);
_bufferOffset = start;
if (!hasRange(index, count)) {
_log.warning(
'[$origin] _loadAssets($index, $count): buffer loaded (offset=$start, got=${_buffer.length}) but still '
'out of range — _totalAssets=$_totalAssets. getAssets is about to throw RangeError.',
);
}
return getAssets(index, count);
}
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_ui/immich_ui.dart';
class ArchiveAction extends AssetAction<RemoteAsset> {
final bool shouldArchive;
ArchiveAction({required super.assets})
: shouldArchive = assets.any((asset) => asset is RemoteAsset && asset.visibility != .archive);
@override
IconData get icon => shouldArchive ? Icons.archive_outlined : Icons.unarchive_outlined;
@override
String label(ActionScope scope) => shouldArchive ? scope.context.t.archive : scope.context.t.unarchive;
@override
Iterable<RemoteAsset> filter(ActionScope scope) => assets
.where(
(asset) =>
asset is RemoteAsset &&
asset.ownerId == scope.authUser.id &&
asset.visibility == (shouldArchive ? AssetVisibility.timeline : AssetVisibility.archive),
)
.cast<RemoteAsset>();
@override
bool isVisible(ActionScope scope) => !scope.ref.watch(inLockedViewProvider) && filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:ref) = scope;
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateArchive(assets, shouldArchive);
final message = shouldArchive
? StaticTranslations.instance.archive_action_prompt(count: assets.length)
: StaticTranslations.instance.unarchive_action_prompt(count: assets.length);
snackbar.success(message);
}
}
@@ -1,26 +1,24 @@
import 'package:easy_localization/easy_localization.dart';
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/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_ui/immich_ui.dart';
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
enum AddToMenuItem { album, lockedFolder }
class AddActionButton extends ConsumerStatefulWidget {
const AddActionButton({super.key, this.originalTheme});
@@ -37,12 +35,6 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
case AddToMenuItem.album:
_openAlbumSelector();
break;
case AddToMenuItem.archive:
performArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.unarchive:
performUnArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.lockedFolder:
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
break;
@@ -57,11 +49,6 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
final user = ref.read(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final hasRemote = asset is RemoteAsset;
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
return [
Padding(
@@ -81,20 +68,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
),
if (showArchive)
BaseActionButton(
iconData: Icons.archive_outlined,
label: "archive".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.archive),
),
if (showUnarchive)
BaseActionButton(
iconData: Icons.unarchive_outlined,
label: "unarchive".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive),
),
ActionMenuItemWidget(action: ArchiveAction(assets: [asset])),
BaseActionButton(
iconData: Icons.lock_outline,
label: "locked_folder".tr(),
@@ -184,7 +158,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
final themeData = widget.originalTheme ?? context.themeData;
return MenuAnchor(
return ImmichMenu(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor),
@@ -195,7 +169,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: widget.originalTheme != null
children: widget.originalTheme != null
? [
Theme(
data: widget.originalTheme!,
@@ -1,59 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
// used to allow performing archive action from different sources (without duplicating code)
Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) {
return;
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).archive(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
class ArchiveActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const ArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performArchiveAction(context, ref, source: source);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.archive_outlined,
label: "to_archive".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -49,14 +49,21 @@ class BaseActionButton extends ConsumerWidget {
if (menuItem) {
final iconColor = this.iconColor;
final onPressed = this.onPressed;
return MenuItemButton(
closeOnActivate: false,
style: MenuItemButton.styleFrom(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
leadingIcon: Icon(iconData, color: iconColor, size: 20),
onPressed: onPressed,
onPressed: onPressed == null
? null
: () {
onPressed();
MenuController.maybeOf(context)?.close();
},
child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)),
);
}
@@ -1,61 +0,0 @@
// dart
// File: `lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart`
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
// used to allow performing unarchive action from different sources (without duplicating code)
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) {
return;
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).unArchive(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
class UnArchiveActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performUnArchiveAction(context, ref, source: source);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.unarchive_outlined,
label: "unarchive".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -260,7 +260,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
return IgnorePointer(
child: Stack(
children: [
Center(child: widget.image),
if (!_isVideoReady || widget.asset.isMotionPhoto || isCasting) Center(child: widget.image),
if (!isCasting) ...[
Visibility.maintain(
visible: _isVideoReady,
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
@@ -16,7 +17,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
@@ -77,7 +77,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
@@ -88,7 +88,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
@@ -5,9 +5,9 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
@@ -68,7 +68,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets)];
return BaseBottomSheet(
initialChildSize: 0.4,
@@ -79,7 +79,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const ArchiveActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
@@ -4,9 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
@@ -84,7 +84,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [AssetDebugAction(assets: assets)];
final actions = [AssetDebugAction(assets: assets), ArchiveAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
@@ -102,7 +102,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
@@ -4,9 +4,9 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
@@ -86,7 +86,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
final actions = [FavoriteAction(assets: assets), ArchiveAction(assets: assets)];
return BaseBottomSheet(
controller: sheetController,
@@ -100,7 +100,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const ShareLinkActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
const ArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
],
const DownloadActionButton(source: ActionSource.timeline),
@@ -23,7 +23,6 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart';
class FixedSegment extends Segment {
final double tileHeight;
@@ -91,7 +90,6 @@ class FixedSegment extends Segment {
}
class _FixedSegmentRow extends ConsumerWidget {
static final Logger _log = Logger('TimelineRow');
final int assetIndex;
final int assetCount;
final double tileHeight;
@@ -111,20 +109,8 @@ class _FixedSegmentRow extends ConsumerWidget {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final timelineService = ref.read(timelineServiceProvider);
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
final inRange = timelineService.hasRange(assetIndex, assetCount);
if (assetIndex == 0) {
_log.info(
'row[0] inRange=$inRange isScrubbing=$isScrubbing totalAssets=${timelineService.totalAssets} '
'branch=${inRange
? "assets"
: isScrubbing
? "placeholder(scrubbing)"
: "future(load)"}',
);
}
if (inRange) {
if (timelineService.hasRange(assetIndex, assetCount)) {
return _buildAssetRow(
context,
timelineService.getAssets(assetIndex, assetCount),
@@ -143,13 +129,6 @@ class _FixedSegmentRow extends ConsumerWidget {
if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context);
}
if (snapshot.hasError) {
_log.warning(
'render row loadAssets($assetIndex, $assetCount) failed (totalAssets=${timelineService.totalAssets})',
snapshot.error,
snapshot.stackTrace,
);
}
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
},
);
@@ -13,7 +13,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:intl/intl.dart' hide TextDirection;
import 'package:logging/logging.dart';
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView.
@@ -85,7 +84,6 @@ List<_Segment> _buildSegments({required List<Segment> layoutSegments, required d
}
class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin {
static final Logger _log = Logger('Scrubber');
String? _lastLabel;
double _thumbTopOffset = 0.0;
bool _isDragging = false;
@@ -116,7 +114,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
@override
void initState() {
super.initState();
_log.info('Scrubber initState');
_isDragging = false;
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
_thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
@@ -137,10 +134,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
void didUpdateWidget(covariant Scrubber oldWidget) {
super.didUpdateWidget(oldWidget);
final oldEnd = oldWidget.layoutSegments.lastOrNull?.endOffset;
final newEnd = widget.layoutSegments.lastOrNull?.endOffset;
if (oldEnd != newEnd) {
_log.info('Scrubber layoutSegments endOffset $oldEnd -> $newEnd (isDragging=$_isDragging)');
if (oldWidget.layoutSegments.lastOrNull?.endOffset != widget.layoutSegments.lastOrNull?.endOffset) {
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
_monthCount = getMonthCount();
}
@@ -148,15 +142,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
@override
void dispose() {
if (_isDragging || _currentScrubberDate != null || _scrubberDebouncer != null) {
_log.warning(
'Scrubber dispose mid-scrub '
'(isDragging=$_isDragging, pendingDate=$_currentScrubberDate, '
'debouncerPending=${_scrubberDebouncer != null}) — scrubbing reset may be orphaned',
);
} else {
_log.info('Scrubber dispose');
}
_thumbAnimationController.dispose();
_labelAnimationController.dispose();
_fadeOutTimer?.cancel();
@@ -223,7 +208,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
}
void _onDragStart(DragStartDetails _) {
_log.info('scrub dragStart');
setState(() {
_isDragging = true;
_labelAnimationController.forward();
@@ -238,15 +222,9 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
}
if (_scrubberHeight <= 0) {
_log.warning('drag ignored: scrubberHeight=$_scrubberHeight <= 0');
return;
}
final maxScrollExtent = _scrollController.hasClients ? _scrollController.position.maxScrollExtent : -1;
if (maxScrollExtent <= 0) {
_log.warning('drag ineffective: hasClients=${_scrollController.hasClients} maxScrollExtent=$maxScrollExtent');
}
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
@@ -366,7 +344,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
}
void _onDragEnd(DragEndDetails _) {
_log.info('scrub dragEnd -> setScrubbing(false)');
_labelAnimationController.reverse();
setState(() {
_isDragging = false;
@@ -7,7 +7,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builde
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:logging/logging.dart';
class TimelineArgs {
final double maxWidth;
@@ -72,27 +71,14 @@ class TimelineState {
}
class TimelineStateNotifier extends Notifier<TimelineState> {
static final Logger _log = Logger('TimelineState');
void setScrubbing(bool isScrubbing) {
if (state.isScrubbing != isScrubbing) {
_log.info('isScrubbing ${state.isScrubbing} -> $isScrubbing (from ${_callSite()})');
}
state = state.copyWith(isScrubbing: isScrubbing);
}
void setScrolling(bool isScrolling) {
if (state.isScrolling != isScrolling) {
_log.info('isScrolling ${state.isScrolling} -> $isScrolling (from ${_callSite()})');
}
state = state.copyWith(isScrolling: isScrolling);
}
static String _callSite() {
final frames = StackTrace.current.toString().split('\n');
return frames.length > 2 ? frames[2].trim() : 'unknown';
}
@override
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
}
@@ -110,11 +96,6 @@ final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref)
final timelineService = ref.watch(timelineServiceProvider);
yield* timelineService.watchBuckets().map((buckets) {
final layoutTotal = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
Logger('TimelineService').info(
'[${timelineService.origin}] segment layout: '
'${buckets.length} buckets / $layoutTotal assets (service.totalAssets=${timelineService.totalAssets})',
);
return FixedSegmentBuilder(
buckets: buckets,
tileHeight: tileExtent,
@@ -28,7 +28,6 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
import 'package:logging/logging.dart';
class Timeline extends StatelessWidget {
const Timeline({
@@ -137,7 +136,6 @@ class _SliverTimeline extends ConsumerStatefulWidget {
}
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
static final Logger _log = Logger('Timeline');
late final ScrollController _scrollController;
StreamSubscription? _eventSubscription;
@@ -155,7 +153,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override
void initState() {
super.initState();
_log.info('SliverTimeline initState');
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
_eventSubscription = EventStream.shared.listen(_onEvent);
@@ -182,7 +179,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}
void _onEvent(Event event) {
_log.info('event ${event.runtimeType}');
switch (event) {
case ScrollToTopEvent():
{
@@ -190,10 +186,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
timelineState.setScrubbing(true);
_scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() {
_log.info('ScrollToTop animation done -> setScrubbing(false)');
timelineState.setScrubbing(false);
});
.whenComplete(() => timelineState.setScrubbing(false));
}
case ScrollToDateEvent scrollToDateEvent:
@@ -253,7 +246,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override
void dispose() {
_log.info('SliverTimeline dispose');
_scrollController.dispose();
_eventSubscription?.cancel();
super.dispose();
@@ -294,12 +286,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
)
.whenComplete(() {
_log.info('ScrollToDate animation done -> setScrubbing(false)');
timelineState.setScrubbing(false);
});
.whenComplete(() => timelineState.setScrubbing(false));
} else {
_log.info('ScrollToDate: no matching segment for $date -> setScrubbing(false)');
timelineState.setScrubbing(false);
}
});
@@ -101,20 +101,8 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
}
}
Future<void> _cancelSync() async {
final backgroundManager = _ref.read(backgroundSyncProvider);
final nativeSync = _ref.read(nativeSyncApiProvider);
await Future.wait([
nativeSync.cancelSync(),
nativeSync.cancelHashing(),
backgroundManager.cancel(),
]).timeout(const Duration(seconds: 5), onTimeout: () => const <void>[]);
}
Future<void> _handleBetaTimelineResume() async {
unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock());
_log.info("Handling beta timeline resume");
await _cancelSync();
// Give isolates time to complete any ongoing database transactions
await Future.delayed(const Duration(milliseconds: 500));
@@ -208,7 +196,6 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
Future<void> _performPause() {
if (_ref.read(authProvider).isAuthenticated) {
_ref.read(driftBackupProvider.notifier).stopForegroundBackup();
unawaited(_cancelSync());
_ref.read(websocketProvider.notifier).disconnect();
}
@@ -178,28 +178,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> archive(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.archive(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to archive assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> unArchive(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.unArchive(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to unarchive assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> moveToLockFolder(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true);
@@ -5,9 +5,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:logging/logging.dart';
final _log = Logger('TimelineProvider');
final timelineRepositoryProvider = Provider<DriftTimelineRepository>(
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
@@ -21,11 +18,7 @@ final timelineServiceProvider = Provider<TimelineService>(
(ref) {
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
_log.info('main TimelineService built users=$timelineUsers');
ref.onDispose(() {
_log.info('main TimelineService disposed');
timelineService.dispose();
});
ref.onDispose(timelineService.dispose);
return timelineService;
},
// Empty dependencies to inform the framework that this provider
@@ -43,12 +36,8 @@ final timelineFactoryProvider = Provider<TimelineFactory>(
final timelineUsersProvider = StreamProvider<List<String>>((ref) {
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
if (currentUserId == null) {
_log.info('timelineUsers: currentUserId=null -> []');
return Stream.value([]);
}
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId).map((users) {
_log.info('timelineUsers emission: $users');
return users;
});
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
});
@@ -1,12 +1,13 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction;
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart';
import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;
final assetApiRepositoryProvider = Provider(
(ref) => AssetApiRepository(
@@ -41,7 +42,7 @@ class AssetApiRepository extends ApiRepository {
return response?.count ?? 0;
}
Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async {
Future<void> updateVisibility(List<String> ids, AssetVisibility visibility) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
}
@@ -77,11 +78,11 @@ class AssetApiRepository extends ApiRepository {
return _api.downloadAssetWithHttpInfo(id, edited: edited);
}
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
AssetVisibilityEnum.timeline => AssetVisibility.timeline,
AssetVisibilityEnum.hidden => AssetVisibility.hidden,
AssetVisibilityEnum.locked => AssetVisibility.locked,
AssetVisibilityEnum.archive => AssetVisibility.archive,
_mapVisibility(AssetVisibility visibility) => switch (visibility) {
AssetVisibility.timeline => api.AssetVisibility.timeline,
AssetVisibility.hidden => api.AssetVisibility.hidden,
AssetVisibility.locked => api.AssetVisibility.locked,
AssetVisibility.archive => api.AssetVisibility.archive,
};
Future<String?> getAssetMIMEType(String assetId) async {
+2 -12
View File
@@ -78,18 +78,8 @@ class ActionService {
await _remoteAssetRepository.updateFavorite(remoteIds, false);
}
Future<void> archive(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
}
Future<void> unArchive(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked);
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
// Ask user if they want to delete local copies
@@ -99,7 +89,7 @@ class ActionService {
}
Future<void> removeFromLockFolder(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
+3 -8
View File
@@ -8,8 +8,8 @@ import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
@@ -29,7 +29,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -200,12 +199,8 @@ enum ActionButtonType {
menuItem: menuItem,
),
ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unarchive => UnArchiveActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.archive ||
ActionButtonType.unarchive => ActionMenuItemWidget(action: ArchiveAction(assets: [context.asset])),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.restoreTrash => RestoreActionButton(
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 3.0.0-rc.3
- API version: 3.0.0-rc.4
- Generator version: 7.22.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
+4 -4
View File
@@ -366,18 +366,18 @@ packages:
dependency: "direct main"
description:
name: drift
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
sha256: "8033500116b24398fba0cca0369cc31678cd627c01e41753a61186911cea743e"
url: "https://pub.dev"
source: hosted
version: "2.34.0"
version: "2.33.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "9cfff1576b49725da0d32c040651a41ae195e8c4af8d8da301593e41d7abc2f7"
sha256: b3dd5b75e30522a91da8abda9f5bb17230cb038097f6d15fa75d42bb563428aa
url: "https://pub.dev"
source: hosted
version: "2.34.0"
version: "2.33.0"
drift_sqlite_async:
dependency: "direct main"
description:
+3 -3
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 3.0.0-rc.3+3051
version: 3.0.0-rc.4+3052
environment:
sdk: '>=3.12.0 <4.0.0'
@@ -19,7 +19,7 @@ dependencies:
crypto: ^3.0.7
device_info_plus: ^12.4.0
diacritic: ^0.1.6
drift: ^2.34.0
drift: ^2.32.1
drift_sqlite_async: 0.3.1
dynamic_color: ^1.8.1
easy_localization: ^3.0.8
@@ -96,7 +96,7 @@ dev_dependencies:
auto_route_generator: ^10.5.0
build_runner: ^2.13.1
# Drift generator
drift_dev: ^2.34.0
drift_dev: ^2.32.1
fake_async: ^1.3.3
file: ^7.0.1 # for MemoryFileSystem
flutter_launcher_icons: ^0.14.4
@@ -5,7 +5,13 @@ import '../../utils.dart';
class RemoteAssetFactory {
const RemoteAssetFactory();
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) {
static RemoteAsset create({
String? id,
String? name,
String? ownerId,
bool isFavorite = false,
AssetVisibility visibility = AssetVisibility.timeline,
}) {
id = TestUtils.uuid(id);
return RemoteAsset(
@@ -17,6 +23,7 @@ class RemoteAssetFactory {
createdAt: TestUtils.yesterday(),
updatedAt: TestUtils.now(),
isFavorite: isFavorite,
visibility: visibility,
isEdited: false,
);
}
+60 -14
View File
@@ -1,7 +1,10 @@
import 'dart:typed_data';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:mocktail/mocktail.dart' as mock;
import 'package:mocktail/mocktail.dart';
@@ -12,11 +15,11 @@ import 'factories/local_asset_factory.dart';
import 'factories/user_factory.dart';
class RepositoryMocks {
final localAlbum = MockLocalAlbumRepository();
final localAsset = MockDriftLocalAssetRepository();
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
final trashedAsset = MockTrashedLocalAssetRepository();
final nativeApi = MockNativeSyncApi();
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
RepositoryMocks() {
resetAll();
@@ -24,17 +27,34 @@ class RepositoryMocks {
void resetAll() {
_registerFallbacks();
reset(localAlbum);
reset(localAsset);
localAlbum.reset();
localAsset.reset();
reset(trashedAsset);
reset(nativeApi);
nativeApi.reset();
_stubLocalAlbumRepository();
_stubLocalAssetRepository();
_stubNativeSyncApi();
}
void _stubLocalAlbumRepository() {
when(localAlbum.getBackupAlbums).thenAnswer((_) async => []);
when(localAlbum.getAssetsToHash).thenAnswer((_) async => []);
}
void _stubLocalAssetRepository() {
when(localAsset.reconcileHashesFromCloudId).thenAnswer((_) async => {});
when(localAsset.updateHashes).thenAnswer((_) async => {});
}
void _stubNativeSyncApi() {
when(nativeApi.hashAssets).thenAnswer((_) async => []);
}
}
class ServiceMocks {
final PartnerStub partner = PartnerStub(MockPartnerService());
final UserStub user = UserStub(MockUserService());
final asset = AssetStub(MockAssetService());
final partner = PartnerServiceStub(MockPartnerService());
final user = UserServiceStub(MockUserService());
final asset = AssetServiceStub(MockAssetService());
ServiceMocks() {
resetAll();
@@ -69,6 +89,7 @@ class ServiceMocks {
void _stubAssetService() {
when(asset.updateFavorite).thenAnswer((_) async {});
when(asset.updateArchive).thenAnswer((_) async {});
}
}
@@ -78,11 +99,28 @@ void _registerFallbacks() {
registerFallbackValue(Uint8List(0));
}
extension type const Stub<T extends Mock>(T mockedService) {
void reset() => mock.reset(mockedService);
extension type const Stub<T extends Mock>(T mockedClass) {
void reset() => mock.reset(mockedClass);
}
extension type const PartnerStub(MockPartnerService service) implements Stub<MockPartnerService> {
extension type const LocalAlbumRepositoryStub(MockLocalAlbumRepository repo) implements Stub<MockLocalAlbumRepository> {
Future<List<LocalAlbum>> Function() get getBackupAlbums =>
() => repo.getBackupAlbums();
Future<List<LocalAsset>> Function() get getAssetsToHash =>
() => repo.getAssetsToHash(any());
}
extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo)
implements Stub<MockDriftLocalAssetRepository> {
Future<void> Function() get reconcileHashesFromCloudId =>
() => repo.reconcileHashesFromCloudId();
Future<void> Function() get updateHashes =>
() => repo.updateHashes(any());
}
extension type const PartnerServiceStub(MockPartnerService service) implements Stub<MockPartnerService> {
Stream<Iterable<User>> Function() get getCandidates =>
() => service.getCandidates(any());
@@ -110,7 +148,7 @@ extension type const PartnerStub(MockPartnerService service) implements Stub<Moc
);
}
extension type const UserStub(MockUserService service) implements Stub<MockUserService> {
extension type const UserServiceStub(MockUserService service) implements Stub<MockUserService> {
UserDto Function() get getMyUser =>
() => service.getMyUser();
@@ -127,7 +165,15 @@ extension type const UserStub(MockUserService service) implements Stub<MockUserS
() => service.createProfileImage(any(), any());
}
extension type const AssetStub(MockAssetService service) implements Stub<MockAssetService> {
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
Future<void> Function() get updateFavorite =>
() => service.updateFavorite(any(), any());
Future<void> Function() get updateArchive =>
() => service.updateArchive(any(), any());
}
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
Future<List<HashResult>> Function() get hashAssets =>
() => api.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'));
}
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/remote_asset_factory.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetService assetService;
setUp(() async {
context = await PresentationContext.create();
assetService = context.service.asset.service;
});
tearDown(() {
context.dispose();
});
RemoteAsset owned({AssetVisibility visibility = AssetVisibility.timeline}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, visibility: visibility);
group('ArchiveAction', () {
testWidgets('archives the eligible owned assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, ArchiveAction(assets: [asset]));
verify(() => assetService.updateArchive([asset.id], true)).called(1);
});
testWidgets('unarchive the eligible owned assets', (tester) async {
final asset = owned(visibility: AssetVisibility.archive);
await tester.pumpTestAction(context, ArchiveAction(assets: [asset]));
verify(() => assetService.updateArchive([asset.id], false)).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(context, ArchiveAction(assets: [mine, theirs]));
verify(() => assetService.updateArchive([mine.id], true)).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, ArchiveAction(assets: [first, second]));
verify(() => assetService.updateArchive([first.id, second.id], true)).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
final stale = owned();
final alreadyArchived = owned(visibility: AssetVisibility.archive);
await tester.pumpTestAction(context, ArchiveAction(assets: [stale, alreadyArchived]));
verify(() => assetService.updateArchive([stale.id], true)).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(context, ArchiveAction(assets: [owned()]));
await tester.pumpUntilFound(find.byType(SnackBar));
expect(find.byType(SnackBar), findsOneWidget);
});
});
}
@@ -6,7 +6,7 @@ import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_ui/immich_ui.dart';
import '../../factories/remote_asset_factory.dart';
import '../../presentation_context.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
@@ -23,8 +23,8 @@ void main() {
group('AssetDebugAction', () {
testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
overrides: context.overrides,
);
expect(find.byType(ImmichIconButton), findsOneWidget);
@@ -32,10 +32,10 @@ void main() {
testWidgets('hidden for multiple assets', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(
action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]),
),
overrides: context.overrides,
);
expect(find.byType(ImmichIconButton), findsNothing);
@@ -44,8 +44,8 @@ void main() {
testWidgets('hidden when advanced troubleshooting is off', (tester) async {
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
overrides: context.overrides,
);
expect(find.byType(ImmichIconButton), findsNothing);
@@ -1,30 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/remote_asset_factory.dart';
import '../../presentation_context.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetService assetService;
setUp(() async {
context = await PresentationContext.create();
assetService = context.service.asset.service;
});
tearDown(() {
context.dispose();
});
List<Override> overrides() => [
...context.overrides,
assetServiceProvider.overrideWithValue(context.mocks.asset.service),
];
RemoteAsset owned({bool isFavorite = false}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
@@ -32,48 +28,48 @@ void main() {
testWidgets('favorites the eligible owned assets', (tester) async {
final asset = owned();
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
verify(() => context.mocks.asset.service.updateFavorite([asset.id], true)).called(1);
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
});
testWidgets('unfavorite the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
verify(() => context.mocks.asset.service.updateFavorite([asset.id], false)).called(1);
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(FavoriteAction(assets: [mine, theirs]), overrides: overrides());
await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
verify(() => context.mocks.asset.service.updateFavorite([mine.id], true)).called(1);
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(FavoriteAction(assets: [first, second]), overrides: overrides());
await tester.pumpTestAction(context, FavoriteAction(assets: [first, second]));
verify(() => context.mocks.asset.service.updateFavorite([first.id, second.id], true)).called(1);
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
final stale = owned();
final alreadyFavorite = owned(isFavorite: true);
await tester.pumpTestAction(FavoriteAction(assets: [stale, alreadyFavorite]), overrides: overrides());
await tester.pumpTestAction(context, FavoriteAction(assets: [stale, alreadyFavorite]));
verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1);
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(FavoriteAction(assets: [owned()]), overrides: overrides());
await tester.pumpTestAction(context, FavoriteAction(assets: [owned()]));
await tester.pumpUntilFound(find.byType(SnackBar));
expect(find.byType(SnackBar), findsOneWidget);
@@ -4,17 +4,19 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/presentation/actions/partner.action.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/user_factory.dart';
import '../../presentation_context.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockPartnerService partnerService;
setUp(() async {
context = await PresentationContext.create();
partnerService = context.service.partner.service;
});
tearDown(() {
@@ -22,8 +24,6 @@ void main() {
});
List<Override> overrides({List<User> candidates = const []}) => [
...context.overrides,
partnerServiceProvider.overrideWithValue(context.mocks.partner.service),
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
];
@@ -31,22 +31,24 @@ void main() {
testWidgets('creates a partner for the selected candidate', (tester) async {
final candidate = UserFactory.create();
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
await tester.pumpTestAction(context, const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
await tester.pumpUntilFound(find.text(candidate.name));
await tester.tap(find.text(candidate.name));
await tester.pumpAndSettle();
verify(
() => context.mocks.partner.service.create(sharedById: context.currentUser.id, sharedWithId: candidate.id),
).called(1);
verify(() => partnerService.create(sharedById: context.currentUser.id, sharedWithId: candidate.id)).called(1);
});
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [UserFactory.create()]));
await tester.pumpTestAction(
context,
const PartnerAddAction(),
overrides: overrides(candidates: [UserFactory.create()]),
);
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
await tester.pumpAndSettle();
verifyNever(context.mocks.partner.create);
verifyNever(context.service.partner.create);
});
});
@@ -54,27 +56,27 @@ void main() {
testWidgets('deletes the partner after confirmation', (tester) async {
final partner = UserFactory.create();
await tester.pumpTestAction(
context,
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
overrides: overrides(),
);
await tester.tap(find.byType(TextButton).last); // confirm
await tester.pumpAndSettle();
verify(
() => context.mocks.partner.service.delete(sharedById: context.currentUser.id, sharedWithId: partner.id),
).called(1);
verify(() => partnerService.delete(sharedById: context.currentUser.id, sharedWithId: partner.id)).called(1);
});
testWidgets('deletes nothing when the confirmation is cancelled', (tester) async {
final partner = UserFactory.create();
await tester.pumpTestAction(
context,
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
overrides: overrides(),
);
await tester.tap(find.byType(TextButton).first); // cancel
await tester.pumpAndSettle();
verifyNever(context.mocks.partner.delete);
verifyNever(context.service.partner.delete);
});
});
}
@@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import '../../factories/remote_asset_factory.dart';
import '../../presentation_context.dart';
import '../presentation_context.dart';
class _FakeAction extends BaseAction {
_FakeAction({this.visible = true, this.error});
@@ -48,8 +48,7 @@ void main() {
context.dispose();
});
List<Override> seededOverrides() => [
...context.overrides,
List<Override> overrides() => [
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
@@ -61,6 +60,7 @@ void main() {
late ActionScope scope;
late ProviderContainer container;
await tester.pumpTestWidget(
context,
Consumer(
builder: (innerContext, ref, _) {
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
@@ -68,7 +68,7 @@ void main() {
return const SizedBox.shrink();
},
),
overrides: seededOverrides(),
overrides: overrides(),
);
return (scope, container);
}
@@ -97,8 +97,8 @@ void main() {
testWidgets('delegates visibility to the wrapped action', (tester) async {
await tester.pumpTestWidget(
context,
ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))),
overrides: context.overrides,
);
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
@@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/partner.action.dart';
import '../factories/partner_user_factory.dart';
import '../factories/user_factory.dart';
import '../presentation_context.dart';
import 'presentation_context.dart';
void main() {
late PresentationContext context;
@@ -19,7 +19,7 @@ void main() {
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
final action = const PartnerAddAction();
await tester.pumpTestWidget(const PartnerSharedByList(partners: []), overrides: context.overrides);
await tester.pumpTestWidget(context, const PartnerSharedByList(partners: []));
expect(find.byType(ListView), findsNothing);
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
@@ -28,8 +28,7 @@ void main() {
testWidgets('renders a tile per partner with name and email', (tester) async {
final partner1 = PartnerFactory.create();
final partner2 = PartnerFactory.create();
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
expect(find.byType(ListTile), findsNWidgets(2));
expect(find.text(partner1.name), findsOneWidget);
expect(find.text(partner1.email), findsOneWidget);
@@ -41,7 +40,7 @@ void main() {
final partner1 = PartnerFactory.create(inTimeline: true);
final partner2 = PartnerFactory.create();
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
expect(find.byIcon(action.icon), findsNWidgets(2));
});
});
@@ -62,13 +61,12 @@ void main() {
}
List<Override> withCandidates(List<User> candidates) => [
...context.overrides,
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
];
testWidgets('renders an option per candidate fetched from the provider', (tester) async {
final user = UserFactory.create();
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates([user]));
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates([user]));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -78,7 +76,7 @@ void main() {
});
testWidgets('shows no options when the provider returns no candidates', (tester) async {
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates(const []));
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates(const []));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -89,7 +87,11 @@ void main() {
testWidgets('pops the selected candidate when an option is tapped', (tester) async {
final user = UserFactory.create();
User? selected;
await tester.pumpTestWidget(dialogWidget(onClosed: (user) => selected = user), overrides: withCandidates([user]));
await tester.pumpTestWidget(
context,
dialogWidget(onClosed: (user) => selected = user),
overrides: withCandidates([user]),
);
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -13,16 +13,21 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
import '../test_utils.dart';
import 'factories/user_factory.dart';
import 'mocks.dart';
import '../../test_utils.dart';
import '../factories/user_factory.dart';
import '../mocks.dart';
class PresentationContext {
PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() {
PresentationContext._({required UserDto user})
: currentUser = user,
service = ServiceMocks(),
repository = RepositoryMocks() {
setup();
}
@@ -31,9 +36,14 @@ class PresentationContext {
static Drift? _db;
final UserDto currentUser;
final ServiceMocks mocks;
final ServiceMocks service;
final RepositoryMocks repository;
List<Override> get overrides => [currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service))];
List<Override> get overrides => [
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
assetServiceProvider.overrideWithValue(service.asset.service),
partnerServiceProvider.overrideWithValue(service.partner.service),
];
static Future<PresentationContext> create() async {
TestUtils.init();
@@ -47,18 +57,18 @@ class PresentationContext {
}
void setup() {
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
when(service.user.tryGetMyUser).thenReturn(currentUser);
}
void dispose() {
addTearDown(() {
mocks.resetAll();
service.resetAll();
});
}
}
extension PumpPresentationWidget on WidgetTester {
Future<void> pumpTestWidget(Widget widget, {List<Override> overrides = const []}) async {
Future<void> pumpTestWidget(PresentationContext context, Widget widget, {List<Override> overrides = const []}) async {
await pumpWidget(
EasyLocalization(
supportedLocales: locales.values.toList(),
@@ -69,7 +79,7 @@ extension PumpPresentationWidget on WidgetTester {
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: ProviderScope(
overrides: overrides,
overrides: [...context.overrides, ...overrides],
child: Builder(
builder: (context) => MaterialApp(
debugShowCheckedModeBanner: false,
@@ -86,8 +96,12 @@ extension PumpPresentationWidget on WidgetTester {
await pumpAndSettle();
}
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides);
Future<void> pumpTestAction(
PresentationContext context,
BaseAction action, {
List<Override> overrides = const [],
}) async {
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
await tap(find.byType(ImmichIconButton));
await pump();
}
@@ -14,14 +14,11 @@ void main() {
setUp(() {
sut = HashService(
localAlbumRepository: mocks.localAlbum,
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
localAlbumRepository: mocks.localAlbum.repo,
localAssetRepository: mocks.localAsset.repo,
nativeSyncApi: mocks.nativeApi.api,
trashedLocalAssetRepository: mocks.trashedAsset,
);
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
});
tearDown(() {
@@ -32,22 +29,20 @@ void main() {
group('hashAssets', () {
test('skips albums with no assets to hash', () async {
final album = LocalAlbumFactory.create(assetCount: 0);
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
await sut.hashAssets();
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
verifyNever(mocks.nativeApi.hashAssets);
});
test('skips empty batches', () async {
final album = LocalAlbumFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
await sut.hashAssets();
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
verifyNever(mocks.nativeApi.hashAssets);
});
test('processes assets when available', () async {
@@ -55,15 +50,17 @@ void main() {
final asset = LocalAssetFactory.create();
final result = HashResult(assetId: asset.id, hash: 'test-hash');
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).thenAnswer((_) async => [result]);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [result]);
await sut.hashAssets();
verify(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
verify(() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
final captured =
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1);
expect(captured[asset.id], result.hash);
});
@@ -72,16 +69,16 @@ void main() {
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 0);
});
@@ -89,25 +86,25 @@ void main() {
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 0);
});
test('batches by size limit', () async {
const batchSize = 2;
final sut = HashService(
localAlbumRepository: mocks.localAlbum,
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
localAlbumRepository: mocks.localAlbum.repo,
localAssetRepository: mocks.localAsset.repo,
nativeSyncApi: mocks.nativeApi.api,
batchSize: batchSize,
trashedLocalAssetRepository: mocks.trashedAsset,
);
@@ -119,12 +116,9 @@ void main() {
final capturedCalls = <List<String>>[];
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
invocation,
) async {
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async {
final assetIds = invocation.positionalArguments[0] as List<String>;
capturedCalls.add(List<String>.from(assetIds));
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
@@ -136,7 +130,7 @@ void main() {
expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets');
expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset');
verify(() => mocks.localAsset.updateHashes(any())).called(2);
verify(() => mocks.localAsset.repo.updateHashes(any())).called(2);
});
test('handles mixed success and failure in batch', () async {
@@ -144,9 +138,9 @@ void main() {
final asset1 = LocalAssetFactory.create();
final asset2 = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
when(() => mocks.nativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
when(() => mocks.nativeApi.api.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
(_) async => [
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
@@ -156,7 +150,7 @@ void main() {
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1);
expect(captured[asset1.id], 'asset1-hash');
});
@@ -167,20 +161,18 @@ void main() {
final asset1 = LocalAssetFactory.create();
final asset2 = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
when(() => mocks.localAlbum.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
when(() => mocks.localAlbum.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
invocation,
) async {
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
when(() => mocks.localAlbum.repo.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
when(() => mocks.localAlbum.repo.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async {
final assetIds = invocation.positionalArguments[0] as List<String>;
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
});
await sut.hashAssets();
verify(() => mocks.nativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
verify(() => mocks.nativeApi.api.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
verify(() => mocks.nativeApi.api.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
});
});
});
+1 -1
View File
@@ -16206,7 +16206,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"contact": {}
},
"tags": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "Monorepo for Immich",
"type": "module",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "Command Line Interface (CLI) for Immich",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "Auto-generated TypeScript SDK for the Immich API",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 3.0.0-rc.3
* 3.0.0-rc.4
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"description": "",
"author": "",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.4",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {