Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export default typescriptEslint.config([
'unicorn/prefer-promise-with-resolvers': 'off',
'unicorn/no-declarations-before-early-exit': 'off',
'unicorn/prefer-simple-condition-first': 'off',
'unicorn/single-line-block-comment-style': ['error', 'single-line'],
curly: 2,
'prettier/prettier': 0,
'unicorn/name-replacements': 'off',
Expand Down
4 changes: 1 addition & 3 deletions e2e/src/specs/server/api/user.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@ describe('/users', () => {
});

describe('PUT /users/me', () => {
/**
@deprecated
*/
/** @deprecated */
it('should allow a user to change their password (deprecated)', async () => {
const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) });

Expand Down
5 changes: 3 additions & 2 deletions mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
}

private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
private static let processingTaskID = "app.alextran.immich.background.processingUpload"
private static let taskIDs = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as! [String]
private static let refreshTaskID = taskIDs.first { $0.hasSuffix(".refreshUpload") }!
private static let processingTaskID = taskIDs.first { $0.hasSuffix(".processingUpload") }!
private static let taskSemaphore = DispatchSemaphore(value: 1)

public static func registerBackgroundWorkers() {
Expand Down
6 changes: 5 additions & 1 deletion mobile/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,11 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale),
theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale),
builder: (context, child) => ImmichTranslationProvider(
translations: ImmichTranslations(submit: context.t.submit, password: context.t.password),
translations: ImmichTranslations(
submit: context.t.submit,
password: context.t.password,
undo: context.t.undo,
),
child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!),
),
routerConfig: router.config(
Expand Down
3 changes: 2 additions & 1 deletion mobile/lib/presentation/actions/archive.action.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ class ArchiveAction extends AssetActionBuilder {

try {
await assetService.update(assetIds, visibility: .some(shouldArchive ? .archive : .timeline));
toastService.success(message);
Future<void> undo() => assetService.update(assetIds, visibility: .some(shouldArchive ? .timeline : .archive));
toastService.success(message, toast: .new(onUndo: undo));
clearSelection();
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to update the archive status for assets");
Expand Down
7 changes: 6 additions & 1 deletion mobile/lib/presentation/actions/delete.action.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
import 'package:immich_mobile/services/toast.service.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';

Expand Down Expand Up @@ -67,15 +68,19 @@ class DeleteAction extends AssetActionBuilder {
}

final (:localIds, :remoteIds, :trash) = state;
final assetService = ref.read(assetServiceProvider);
final toastService = ref.read(toastServiceProvider);
final clearSelection = ref.read(clearSelectionProvider(source));

try {
final String? message;
// Only trashing is reversible; a permanent delete and a device cleanup are not.
ToastOption? undo;
if (remoteIds.isEmpty) {
message = await _removeLocalAssets(context, ref, localIds);
} else if (trash) {
message = await _moveToTrash(context, ref, remoteIds, localIds);
undo = .new(onUndo: () => assetService.restoreTrash(remoteIds));
} else {
message = await _deletePermanently(context, ref, remoteIds, localIds);
}
Expand All @@ -84,7 +89,7 @@ class DeleteAction extends AssetActionBuilder {
return;
}

toastService.success(message);
toastService.success(message, toast: undo);
clearSelection();
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to delete assets");
Expand Down
7 changes: 6 additions & 1 deletion mobile/lib/presentation/actions/lock.action.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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/infrastructure/toast.provider.dart';
import 'package:immich_mobile/services/toast.service.dart';
import 'package:immich_mobile/utils/error_handler.dart';

typedef _State = ({bool shouldLock, List<String> assetIds, List<String> localIds});
Expand Down Expand Up @@ -62,7 +63,11 @@ class LockAction extends AssetActionBuilder {
// A locked asset still sits in the device gallery, so offer to remove the local copy.
await assetService.deleteLocal(localIds);
}
toastService.success(message);
// Unlocking is a sensitive action and requires an elevated session
final toast = shouldLock
? null
: ToastOption(onUndo: () => assetService.update(assetIds, visibility: const .some(.locked)));
toastService.success(message, toast: toast);
clearSelection();
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to update the locked folder for assets");
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/presentation/actions/restore.action.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class RestoreAction extends AssetActionBuilder {

try {
await assetService.restoreTrash(assetIds);
toastService.success(message);
toastService.success(message, toast: .new(onUndo: () => assetService.trash(assetIds)));
clearSelection();
} catch (error, stack) {
handleError(error, stack: stack, description: "Failed to restore assets");
Expand Down
35 changes: 23 additions & 12 deletions mobile/lib/repositories/upload.repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,23 +95,34 @@ class UploadRepository {
required Completer<void>? cancelToken,
void Function(int bytes, int totalBytes)? onProgress,
required String logContext,
Client? httpClient,
}) async {
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
final baseRequest = ProgressMultipartRequest(
'POST',
Uri.parse('$savedEndpoint/assets'),
abortTrigger: cancelToken?.future,
onProgress: onProgress,
);

try {
final fileStream = file.openRead();
final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName);
ProgressMultipartRequest buildRequest() {
final request = ProgressMultipartRequest(
'POST',
Uri.parse('$savedEndpoint/assets'),
abortTrigger: cancelToken?.future,
onProgress: onProgress,
);
request.fields.addAll(fields);
request.files.add(MultipartFile("assetData", file.openRead(), file.lengthSync(), filename: originalFileName));
return request;
}

baseRequest.fields.addAll(fields);
baseRequest.files.add(assetRawUploadData);
try {
final client = httpClient ?? NetworkRepository.client;
StreamedResponse response;
try {
response = await client.send(buildRequest());
} on RequestAbortedException {
rethrow;
} on ClientException catch (error) {
logger.warning("Upload $logContext failed before a response, resending once: $error");
response = await client.send(buildRequest());
}

final response = await NetworkRepository.client.send(baseRequest);
final responseBodyString = await response.stream.bytesToString();

if (![200, 201].contains(response.statusCode)) {
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/services/deep_link.service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class DeepLinkService {
final queryParams = link.uri.queryParameters;

return switch (intent) {
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
"memory" => await _buildMemoryDeepLink(queryParams['id']),
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref),
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
"people" => await _buildPeopleDeepLink(queryParams['id'] ?? ''),
Expand Down
17 changes: 14 additions & 3 deletions mobile/lib/services/toast.service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,25 @@ class ToastService {
const ToastService();

FutureOr<void> success(String message, {ToastOption? toast}) {
snackbar.success(message, duration: toast?.timeout);
snackbar.success(message, duration: toast?.timeout, action: toast?.action);
}

FutureOr<void> info(String message, {ToastOption? toast}) {
snackbar.info(message, duration: toast?.timeout);
snackbar.info(message, duration: toast?.timeout, action: toast?.action);
}

FutureOr<void> error(String message, {ToastOption? toast}) {
snackbar.error(message, duration: toast?.timeout);
snackbar.error(message, duration: toast?.timeout, action: toast?.action);
}
}

extension on ToastOption {
SnackbarAction? get action {
final onUndo = this.onUndo;
if (onUndo == null) {
return null;
}

return SnackbarAction(onPressed: onUndo);
}
}
49 changes: 41 additions & 8 deletions mobile/packages/ui/lib/src/snackbar.dart
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:immich_ui/src/internal.dart';

final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();

class SnackbarAction {
// Optional label for the action button. Falls back to the localized "Undo" label when null
final String? label;
final FutureOr<void> Function() onPressed;

const SnackbarAction({this.label, required this.onPressed});
}

class SnackbarManager {
const SnackbarManager();

ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(
String message,
SnackbarType type, {
Duration? duration,
SnackbarAction? action,
}) {
final messenger = scaffoldMessengerKey.currentState;
final context = scaffoldMessengerKey.currentContext;
Expand All @@ -19,10 +31,10 @@ class SnackbarManager {

duration ??= const .new(seconds: 4);
messenger.hideCurrentSnackBar();
return messenger.showSnackBar(_build(context, message, type, duration));
return messenger.showSnackBar(_build(context, message, type, duration, action));
}

SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) {
SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration, SnackbarAction? action) {
final theme = Theme.of(context);
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
final (IconData icon, Color background, Color foreground) = switch (type) {
Expand All @@ -31,11 +43,22 @@ class SnackbarManager {
.error => (Icons.warning_rounded, colors.error, colors.onError),
};

SnackBarAction? snackAction;
if (action != null) {
snackAction = .new(
label: action.label ?? context.translations.undo,
onPressed: action.onPressed,
textColor: foreground,
);
}

return SnackBar(
behavior: .floating,
backgroundColor: background,
duration: duration,
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
// A snackbar carrying an action stays up until it is tapped by default; our actions are optional
persist: false,
content: Row(
children: [
Icon(icon, color: foreground, size: ImmichIconSize.sm),
Expand All @@ -50,17 +73,27 @@ class SnackbarManager {
),
],
),
action: snackAction,
);
}

ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message, {Duration? duration}) =>
show(message, .info, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(
String message, {
Duration? duration,
SnackbarAction? action,
}) => show(message, .info, duration: duration, action: action);

ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message, {Duration? duration}) =>
show(message, .success, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(
String message, {
Duration? duration,
SnackbarAction? action,
}) => show(message, .success, duration: duration, action: action);

ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message, {Duration? duration}) =>
show(message, .error, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(
String message, {
Duration? duration,
SnackbarAction? action,
}) => show(message, .error, duration: duration, action: action);
}

const snackbar = SnackbarManager();
21 changes: 9 additions & 12 deletions mobile/packages/ui/lib/src/translation.dart
Original file line number Diff line number Diff line change
@@ -1,27 +1,24 @@
import 'package:flutter/material.dart';

class ImmichTranslations {
late String submit;
late String password;
final String submit;
final String password;
final String undo;

ImmichTranslations({String? submit, String? password}) {
this.submit = submit ?? 'Submit';
this.password = password ?? 'Password';
}
const ImmichTranslations({String? submit, String? password, String? undo})
: submit = submit ?? 'Submit',
password = password ?? 'Password',
undo = undo ?? 'Undo';
}

class ImmichTranslationProvider extends InheritedWidget {
final ImmichTranslations? translations;

const ImmichTranslationProvider({
super.key,
this.translations,
required super.child,
});
const ImmichTranslationProvider({super.key, this.translations, required super.child});

static ImmichTranslations of(BuildContext context) {
final provider = context.dependOnInheritedWidgetOfExactType<ImmichTranslationProvider>();
return provider?.translations ?? ImmichTranslations();
return provider?.translations ?? const .new();
}

@override
Expand Down
Loading
Loading