diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml
index e828760789..7e314e8d81 100644
--- a/mobile/analysis_options.yaml
+++ b/mobile/analysis_options.yaml
@@ -110,3 +110,17 @@ dart_code_metrics:
- prefer-const-border-radius
- prefer-declaring-const-constructor
- prefer-switch-expression
+
+ # Riverpod
+ - avoid-calling-notifier-members-inside-build
+ - avoid-notifier-constructors
+ - avoid-nullable-async-value-pattern
+ - avoid-public-notifier-properties
+ - avoid-ref-inside-state-dispose
+ - avoid-ref-read-inside-build
+ - avoid-ref-watch-outside-build
+ - avoid-unnecessary-consumer-widgets
+ - dispose-provided-instances
+ - prefer-immutable-provider-arguments
+ - use-ref-and-state-synchronously
+ - use-ref-read-synchronously
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index 1b8d2a97fb..5f4de8284a 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -152,12 +152,6 @@
This is used by the Flutter tool to
generate GeneratedPluginRegistrant.java -->
-
-
-
()
@@ -108,11 +109,16 @@ class LocalImageApiImpl: LocalImageApi {
]))
}
+ let isOriginal = !(width > 0 && height > 0)
+ let targetSize = isOriginal
+ ? CGSize(width: Self.maxPixelSize, height: Self.maxPixelSize)
+ : CGSize(width: Double(width), height: Double(height))
+ let contentMode: PHImageContentMode = isOriginal ? .aspectFit : .aspectFill
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
- targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
- contentMode: .aspectFill,
+ targetSize: targetSize,
+ contentMode: contentMode,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in
image = _image
@@ -123,12 +129,38 @@ class LocalImageApiImpl: LocalImageApi {
return request.completion(ImageProcessing.cancelledResult)
}
- guard let image = image,
- let cgImage = image.cgImage else {
+ guard let fastImage = image,
+ var cgImage = fastImage.cgImage else {
Self.registry.remove(requestId: requestId)
return request.completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
+ // .fast can return larger than the target, so retry with .exact to guarantee the bound.
+ if max(cgImage.width, cgImage.height) > Int(Self.maxPixelSize) {
+ let exactOptions = Self.requestOptions.copy() as! PHImageRequestOptions
+ exactOptions.resizeMode = .exact
+ image = nil
+ Self.imageManager.requestImage(
+ for: asset,
+ targetSize: targetSize,
+ contentMode: contentMode,
+ options: exactOptions,
+ resultHandler: { (_image, info) -> Void in
+ image = _image
+ }
+ )
+
+ if request.isCancelled {
+ return request.completion(ImageProcessing.cancelledResult)
+ }
+
+ guard let exactImage = image?.cgImage else {
+ Self.registry.remove(requestId: requestId)
+ return request.completion(.failure(PigeonError(code: "", message: "Could not resize image for \(assetId)", details: nil)))
+ }
+ cgImage = exactImage
+ }
+
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
diff --git a/mobile/lib/domain/models/album/album.model.dart b/mobile/lib/domain/models/album/album.model.dart
index b82471a3f4..e63a3774e1 100644
--- a/mobile/lib/domain/models/album/album.model.dart
+++ b/mobile/lib/domain/models/album/album.model.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'album.model.freezed.dart';
diff --git a/mobile/lib/domain/models/album/local_album.model.dart b/mobile/lib/domain/models/album/local_album.model.dart
index b801d25349..cddc1d1953 100644
--- a/mobile/lib/domain/models/album/local_album.model.dart
+++ b/mobile/lib/domain/models/album/local_album.model.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'local_album.model.freezed.dart';
diff --git a/mobile/lib/domain/models/asset_face.model.dart b/mobile/lib/domain/models/asset_face.model.dart
index 9cec561419..c65f159fe7 100644
--- a/mobile/lib/domain/models/asset_face.model.dart
+++ b/mobile/lib/domain/models/asset_face.model.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'asset_face.model.freezed.dart';
diff --git a/mobile/lib/domain/models/config/album_config.dart b/mobile/lib/domain/models/config/album_config.dart
index 95d8d3da51..16deca1b8f 100644
--- a/mobile/lib/domain/models/config/album_config.dart
+++ b/mobile/lib/domain/models/config/album_config.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart
index 400b1a5e37..bc089e621e 100644
--- a/mobile/lib/domain/models/config/app_config.dart
+++ b/mobile/lib/domain/models/config/app_config.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/constants/colors.dart';
diff --git a/mobile/lib/domain/models/config/backup_config.dart b/mobile/lib/domain/models/config/backup_config.dart
index f6844d1e05..4177408fcc 100644
--- a/mobile/lib/domain/models/config/backup_config.dart
+++ b/mobile/lib/domain/models/config/backup_config.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'backup_config.freezed.dart';
diff --git a/mobile/lib/domain/models/config/feature_message_config.dart b/mobile/lib/domain/models/config/feature_message_config.dart
index 8f25f2e165..0fc13fd51f 100644
--- a/mobile/lib/domain/models/config/feature_message_config.dart
+++ b/mobile/lib/domain/models/config/feature_message_config.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/utils/semver.dart';
diff --git a/mobile/lib/domain/models/config/image_config.dart b/mobile/lib/domain/models/config/image_config.dart
index bfee205038..a51193d1d3 100644
--- a/mobile/lib/domain/models/config/image_config.dart
+++ b/mobile/lib/domain/models/config/image_config.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'image_config.freezed.dart';
diff --git a/mobile/lib/domain/models/config/share_config.dart b/mobile/lib/domain/models/config/share_config.dart
index 4c3c11ffac..49530aca30 100644
--- a/mobile/lib/domain/models/config/share_config.dart
+++ b/mobile/lib/domain/models/config/share_config.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/constants/enums.dart';
diff --git a/mobile/lib/domain/models/config/slideshow_config.dart b/mobile/lib/domain/models/config/slideshow_config.dart
index 6dd57903ac..cacba1a89e 100644
--- a/mobile/lib/domain/models/config/slideshow_config.dart
+++ b/mobile/lib/domain/models/config/slideshow_config.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/constants/enums.dart';
diff --git a/mobile/lib/domain/models/config/theme_config.dart b/mobile/lib/domain/models/config/theme_config.dart
index 338219eec0..ac651a8062 100644
--- a/mobile/lib/domain/models/config/theme_config.dart
+++ b/mobile/lib/domain/models/config/theme_config.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/constants/colors.dart';
diff --git a/mobile/lib/domain/models/config/timeline_config.dart b/mobile/lib/domain/models/config/timeline_config.dart
index 6aa7c9b554..c4681b0a62 100644
--- a/mobile/lib/domain/models/config/timeline_config.dart
+++ b/mobile/lib/domain/models/config/timeline_config.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
diff --git a/mobile/lib/domain/models/config/viewer_config.dart b/mobile/lib/domain/models/config/viewer_config.dart
index b6a2a12e6a..e799843034 100644
--- a/mobile/lib/domain/models/config/viewer_config.dart
+++ b/mobile/lib/domain/models/config/viewer_config.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'viewer_config.freezed.dart';
diff --git a/mobile/lib/domain/models/exif.model.dart b/mobile/lib/domain/models/exif.model.dart
index 7d0e402b3c..3536048ecc 100644
--- a/mobile/lib/domain/models/exif.model.dart
+++ b/mobile/lib/domain/models/exif.model.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'exif.model.freezed.dart';
diff --git a/mobile/lib/domain/models/ocr.model.dart b/mobile/lib/domain/models/ocr.model.dart
index 9d4a23695a..bfb398bc79 100644
--- a/mobile/lib/domain/models/ocr.model.dart
+++ b/mobile/lib/domain/models/ocr.model.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'ocr.model.freezed.dart';
diff --git a/mobile/lib/domain/models/person.model.dart b/mobile/lib/domain/models/person.model.dart
index 853dc0af1a..9cc31b252e 100644
--- a/mobile/lib/domain/models/person.model.dart
+++ b/mobile/lib/domain/models/person.model.dart
@@ -2,6 +2,11 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'person.model.freezed.dart';
+import 'package:flutter/foundation.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'person.model.freezed.dart';
+
// TODO: Remove PersonDto once Isar is removed
@freezed
abstract class PersonDto with _$PersonDto {
diff --git a/mobile/lib/domain/models/stack.model.dart b/mobile/lib/domain/models/stack.model.dart
index 8b21ac6560..4e88a02c6c 100644
--- a/mobile/lib/domain/models/stack.model.dart
+++ b/mobile/lib/domain/models/stack.model.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'stack.model.freezed.dart';
diff --git a/mobile/lib/domain/models/user_metadata.model.dart b/mobile/lib/domain/models/user_metadata.model.dart
index 3b97fe1f03..2b1da0a809 100644
--- a/mobile/lib/domain/models/user_metadata.model.dart
+++ b/mobile/lib/domain/models/user_metadata.model.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
diff --git a/mobile/lib/models/activities/activity.model.dart b/mobile/lib/models/activities/activity.model.dart
index bdff17621e..9a589ef729 100644
--- a/mobile/lib/models/activities/activity.model.dart
+++ b/mobile/lib/models/activities/activity.model.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
diff --git a/mobile/lib/models/auth/auth_state.model.dart b/mobile/lib/models/auth/auth_state.model.dart
index 92dbe8ffa3..0a3bf983b2 100644
--- a/mobile/lib/models/auth/auth_state.model.dart
+++ b/mobile/lib/models/auth/auth_state.model.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_state.model.freezed.dart';
diff --git a/mobile/lib/models/map/map_marker.model.dart b/mobile/lib/models/map/map_marker.model.dart
index 6be05000d8..4093747e84 100644
--- a/mobile/lib/models/map/map_marker.model.dart
+++ b/mobile/lib/models/map/map_marker.model.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart';
diff --git a/mobile/lib/models/map/map_state.model.dart b/mobile/lib/models/map/map_state.model.dart
index a354da1724..f7bd435073 100644
--- a/mobile/lib/models/map/map_state.model.dart
+++ b/mobile/lib/models/map/map_state.model.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
diff --git a/mobile/lib/models/server_info/server_disk_info.model.dart b/mobile/lib/models/server_info/server_disk_info.model.dart
index 0111b9ac52..910f06b065 100644
--- a/mobile/lib/models/server_info/server_disk_info.model.dart
+++ b/mobile/lib/models/server_info/server_disk_info.model.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:openapi/api.dart';
diff --git a/mobile/lib/models/server_info/server_features.model.dart b/mobile/lib/models/server_info/server_features.model.dart
index 726cd9cff9..4de7e2e3cd 100644
--- a/mobile/lib/models/server_info/server_features.model.dart
+++ b/mobile/lib/models/server_info/server_features.model.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:openapi/api.dart';
diff --git a/mobile/lib/models/server_info/server_info.model.dart b/mobile/lib/models/server_info/server_info.model.dart
index c417d7fc9d..adc55d3f4e 100644
--- a/mobile/lib/models/server_info/server_info.model.dart
+++ b/mobile/lib/models/server_info/server_info.model.dart
@@ -1,4 +1,5 @@
import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/models/server_info/server_config.model.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
diff --git a/mobile/lib/models/shared_link/shared_link.model.dart b/mobile/lib/models/shared_link/shared_link.model.dart
index b8dc970d9c..cf96609d3c 100644
--- a/mobile/lib/models/shared_link/shared_link.model.dart
+++ b/mobile/lib/models/shared_link/shared_link.model.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:openapi/api.dart';
diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart
index cc618dbe60..e2472f1063 100644
--- a/mobile/lib/pages/backup/drift_backup.page.dart
+++ b/mobile/lib/pages/backup/drift_backup.page.dart
@@ -81,8 +81,8 @@ class _DriftBackupPageState extends ConsumerState {
final error = ref.watch(driftBackupProvider.select((p) => p.error));
- final backupNotifier = ref.read(driftBackupProvider.notifier);
- final backupSyncManager = ref.read(backgroundSyncProvider);
+ final backupNotifier = ref.watch(driftBackupProvider.notifier);
+ final backupSyncManager = ref.watch(backgroundSyncProvider);
Future startBackup() async {
final currentUser = Store.tryGet(StoreKey.currentUser);
@@ -143,7 +143,7 @@ class _DriftBackupPageState extends ConsumerState {
onStart: () async => await startBackup(),
onStop: () {
syncSuccess = null;
- backupNotifier.stopForegroundBackup();
+ backupNotifier.stopForegroundBackup(reason: "backup button toggled off");
},
),
switch (error) {
@@ -321,7 +321,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
Widget buildSelectedAlbumName() {
String text = "backup_controller_page_backup_selected".tr();
final albums = ref
- .watch(backupAlbumProvider)
+ .read(backupAlbumProvider)
.where((album) => album.backupSelection == BackupSelection.selected)
.toList();
@@ -355,7 +355,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
Widget buildExcludedAlbumName() {
String text = "backup_controller_page_excluded".tr();
final albums = ref
- .watch(backupAlbumProvider)
+ .read(backupAlbumProvider)
.where((album) => album.backupSelection == BackupSelection.excluded)
.toList();
@@ -403,6 +403,10 @@ class _BackupAlbumSelectionCard extends ConsumerWidget {
trailing: ElevatedButton(
onPressed: () async {
await context.pushRoute(const DriftBackupAlbumSelectionRoute());
+ if (!context.mounted) {
+ return;
+ }
+
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
return;
@@ -562,6 +566,11 @@ class _PreparingStatusState extends ConsumerState {
final currentUser = ref.read(currentUserProvider);
if (currentUser != null && mounted) {
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
+ if (!context.mounted) {
+ timer.cancel();
+ _pollingTimer = null;
+ return;
+ }
// Stop polling if processing count reaches 0
final updatedProcessingCount = ref.read(driftBackupProvider.select((p) => p.processingCount));
diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
index 7667dbc3f1..de0ad52b5a 100644
--- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
+++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart
@@ -102,6 +102,9 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState p.totalCount));
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
final backupNotifier = ref.read(driftBackupProvider.notifier);
@@ -119,7 +126,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState backgroundSync.hashAssets()));
if (isBackupEnabled) {
- backupNotifier.stopForegroundBackup();
+ backupNotifier.stopForegroundBackup(reason: "backup albums updated");
unawaited(
backgroundSync.syncRemote().then((success) {
if (success) {
diff --git a/mobile/lib/pages/backup/drift_backup_options.page.dart b/mobile/lib/pages/backup/drift_backup_options.page.dart
index ef4974d4dd..ccd486e04d 100644
--- a/mobile/lib/pages/backup/drift_backup_options.page.dart
+++ b/mobile/lib/pages/backup/drift_backup_options.page.dart
@@ -19,7 +19,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
bool hasPopped = false;
- final previousBackup = ref.read(appConfigProvider).backup;
+ final previousBackup = ref.watch(appConfigProvider.select((s) => s.backup));
final previousCellularForVideos = previousBackup.useCellularForVideos;
final previousCellularForPhotos = previousBackup.useCellularForPhotos;
return PopScope(
@@ -59,7 +59,7 @@ class DriftBackupOptionsPage extends ConsumerWidget {
final backupNotifier = ref.read(driftBackupProvider.notifier);
final backgroundSync = ref.read(backgroundSyncProvider);
- backupNotifier.stopForegroundBackup();
+ backupNotifier.stopForegroundBackup(reason: "backup settings updated");
unawaited(
backgroundSync.syncRemote().then((success) {
if (success) {
diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart
index b04d8dc926..370d6a106d 100644
--- a/mobile/lib/pages/common/app_log.page.dart
+++ b/mobile/lib/pages/common/app_log.page.dart
@@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -13,11 +12,11 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/immich_logger.service.dart';
@RoutePage()
-class AppLogPage extends HookConsumerWidget {
+class AppLogPage extends HookWidget {
const AppLogPage({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final immichLogger = LogService.I;
final shouldReload = useState(false);
final logMessages = useFuture(useMemoized(() => immichLogger.getMessages(), [shouldReload.value]));
diff --git a/mobile/lib/pages/common/app_log_detail.page.dart b/mobile/lib/pages/common/app_log_detail.page.dart
index 52f792df9d..bc3d26a019 100644
--- a/mobile/lib/pages/common/app_log_detail.page.dart
+++ b/mobile/lib/pages/common/app_log_detail.page.dart
@@ -4,18 +4,18 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@RoutePage()
-class AppLogDetailPage extends HookConsumerWidget {
+class AppLogDetailPage extends HookWidget {
const AppLogDetailPage({super.key, required this.logMessage});
final LogMessage logMessage;
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
Padding buildTextWithCopyButton(String header, String text) {
return Padding(
padding: const EdgeInsets.all(8.0),
diff --git a/mobile/lib/pages/common/download_panel.dart b/mobile/lib/pages/common/download_panel.dart
index 267d32fc3d..2015948fad 100644
--- a/mobile/lib/pages/common/download_panel.dart
+++ b/mobile/lib/pages/common/download_panel.dart
@@ -17,7 +17,7 @@ class DownloadPanel extends ConsumerWidget {
final tasks = ref.watch(downloadStateProvider.select((state) => state.taskProgress)).entries.toList();
void onCancelDownload(String id) {
- unawaited(ref.watch(downloadStateProvider.notifier).cancelDownload(id));
+ unawaited(ref.read(downloadStateProvider.notifier).cancelDownload(id));
}
return Positioned(
diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart
index f0b3f4b67f..e0228eecac 100644
--- a/mobile/lib/pages/common/headers_settings.page.dart
+++ b/mobile/lib/pages/common/headers_settings.page.dart
@@ -21,7 +21,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
final headers = useState>([]);
final setInitialHeaders = useState(false);
- final storedHeaders = ref.read(appConfigProvider).network.customHeaders;
+ final storedHeaders = ref.watch(appConfigProvider.select((s) => s.network.customHeaders));
if (!setInitialHeaders.value) {
storedHeaders.forEach((k, v) {
final header = SettingsHeader();
@@ -93,8 +93,9 @@ class HeaderSettingsPage extends HookConsumerWidget {
headersMap[key] = value;
}
+ final apiService = ref.read(apiServiceProvider);
await ref.read(settingsProvider).write(.networkCustomHeaders, headersMap);
- await ref.read(apiServiceProvider).updateHeaders();
+ await apiService.updateHeaders();
}
}
diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart
index 2c39883b7e..65a78aed8a 100644
--- a/mobile/lib/pages/common/splash_screen.page.dart
+++ b/mobile/lib/pages/common/splash_screen.page.dart
@@ -353,11 +353,11 @@ class SplashScreenPageState extends ConsumerState {
},
onError: (exception) {
log.severe('Failed to update auth info with access token: $accessToken');
- unawaited(ref.read(authProvider.notifier).logout());
if (!mounted) {
return;
}
+ unawaited(ref.read(authProvider.notifier).logout());
unawaited(context.router.replaceAll([const LoginRoute()]));
},
),
diff --git a/mobile/lib/pages/login/change_password.page.dart b/mobile/lib/pages/login/change_password.page.dart
index 248526df1b..051f7f408c 100644
--- a/mobile/lib/pages/login/change_password.page.dart
+++ b/mobile/lib/pages/login/change_password.page.dart
@@ -1,14 +1,11 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/widgets/forms/change_password_form.dart';
@RoutePage()
-class ChangePasswordPage extends HookConsumerWidget {
+class ChangePasswordPage extends StatelessWidget {
const ChangePasswordPage({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
- return const Scaffold(body: ChangePasswordForm());
- }
+ Widget build(BuildContext context) => const Scaffold(body: ChangePasswordForm());
}
diff --git a/mobile/lib/pages/login/login.page.dart b/mobile/lib/pages/login/login.page.dart
index e225c4c066..228fa1cc30 100644
--- a/mobile/lib/pages/login/login.page.dart
+++ b/mobile/lib/pages/login/login.page.dart
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -11,11 +10,11 @@ import 'package:immich_mobile/widgets/forms/login/login_form.dart';
import 'package:package_info_plus/package_info_plus.dart';
@RoutePage()
-class LoginPage extends HookConsumerWidget {
+class LoginPage extends HookWidget {
const LoginPage({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final appVersion = useState('0.0.0');
Future getAppInfo() async {
diff --git a/mobile/lib/pages/search/map/map_location_picker.page.dart b/mobile/lib/pages/search/map/map_location_picker.page.dart
index bb848b24bc..e8a507bfdc 100644
--- a/mobile/lib/pages/search/map/map_location_picker.page.dart
+++ b/mobile/lib/pages/search/map/map_location_picker.page.dart
@@ -5,7 +5,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
@@ -14,13 +13,13 @@ import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
-class MapLocationPickerPage extends HookConsumerWidget {
+class MapLocationPickerPage extends HookWidget {
final LatLng initialLatLng;
const MapLocationPickerPage({super.key, this.initialLatLng = const LatLng(0, 0)});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final selectedLatLng = useValueNotifier(initialLatLng);
final controller = useRef(null);
final marker = useRef(null);
diff --git a/mobile/lib/presentation/pages/download_info.page.dart b/mobile/lib/presentation/pages/download_info.page.dart
index c2c63c7860..8073d3098c 100644
--- a/mobile/lib/presentation/pages/download_info.page.dart
+++ b/mobile/lib/presentation/pages/download_info.page.dart
@@ -17,7 +17,7 @@ class DownloadInfoPage extends ConsumerWidget {
final tasks = ref.watch(downloadStateProvider.select((state) => state.taskProgress)).entries.toList();
void onCancelDownload(String id) {
- unawaited(ref.watch(downloadStateProvider.notifier).cancelDownload(id));
+ unawaited(ref.read(downloadStateProvider.notifier).cancelDownload(id));
}
return Scaffold(
diff --git a/mobile/lib/presentation/pages/drift_activities.page.dart b/mobile/lib/presentation/pages/drift_activities.page.dart
index ebf7c2efa7..2bb3507e6e 100644
--- a/mobile/lib/presentation/pages/drift_activities.page.dart
+++ b/mobile/lib/presentation/pages/drift_activities.page.dart
@@ -23,7 +23,7 @@ class DriftActivitiesPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final activityNotifier = ref.read(albumActivityProvider((album.id, assetId)).notifier);
+ final activityNotifier = ref.watch(albumActivityProvider((album.id, assetId)).notifier);
final activities = ref.watch(albumActivityProvider((album.id, assetId)));
final listViewScrollController = useScrollController();
diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart
index 9c5161fa8a..7b4c48a6b0 100644
--- a/mobile/lib/presentation/pages/drift_album_options.page.dart
+++ b/mobile/lib/presentation/pages/drift_album_options.page.dart
@@ -75,6 +75,10 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
}
try {
+ if (!context.mounted) {
+ return;
+ }
+
await ref.read(remoteAlbumProvider.notifier).addUsers(album.id, newUsers);
ref.invalidate(remoteAlbumSharedUsersProvider(album.id));
if (!context.mounted) {
@@ -137,7 +141,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
Widget buildOwnerInfo() {
if (isOwner) {
- final owner = ref.watch(currentUserProvider);
+ final owner = ref.read(currentUserProvider);
return ListTile(
leading: owner != null ? UserCircleAvatar(user: owner) : const SizedBox(),
title: Text(album.ownerName, style: const TextStyle(fontWeight: FontWeight.w500)),
@@ -145,7 +149,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context),
);
} else {
- final usersProvider = ref.watch(driftUsersProvider);
+ final usersProvider = ref.read(driftUsersProvider);
return usersProvider.maybeWhen(
data: (users) {
final user = users.firstWhereOrNull((u) => u.id == album.ownerId);
diff --git a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart
index a12ca4932b..d256cc2220 100644
--- a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart
+++ b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart
@@ -7,12 +7,12 @@ import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@RoutePage()
-class DriftAssetSelectionTimelinePage extends ConsumerWidget {
+class DriftAssetSelectionTimelinePage extends StatelessWidget {
final Set lockedSelectionAssets;
const DriftAssetSelectionTimelinePage({super.key, this.lockedSelectionAssets = const {}});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return ProviderScope(
overrides: [
multiSelectProvider.overrideWith(
diff --git a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart
index d50e4b190e..7e98d25cf0 100644
--- a/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart
+++ b/mobile/lib/presentation/pages/drift_asset_troubleshoot.page.dart
@@ -10,13 +10,13 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
@RoutePage()
-class AssetTroubleshootPage extends ConsumerWidget {
+class AssetTroubleshootPage extends StatelessWidget {
final BaseAsset asset;
const AssetTroubleshootPage({super.key, required this.asset});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('asset_troubleshoot'.tr())),
body: SingleChildScrollView(
@@ -29,13 +29,13 @@ class AssetTroubleshootPage extends ConsumerWidget {
}
}
-class _AssetDetailsView extends ConsumerWidget {
+class _AssetDetailsView extends StatelessWidget {
final BaseAsset asset;
const _AssetDetailsView({required this.asset});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart
index b2b4d250f1..740de441e7 100644
--- a/mobile/lib/presentation/pages/drift_library.page.dart
+++ b/mobile/lib/presentation/pages/drift_library.page.dart
@@ -21,11 +21,11 @@ import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
-class DriftLibraryPage extends ConsumerWidget {
+class DriftLibraryPage extends StatelessWidget {
const DriftLibraryPage({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return const Scaffold(
body: CustomScrollView(
slivers: [
diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart
index 5d018561cc..f9e59da0a3 100644
--- a/mobile/lib/presentation/pages/drift_remote_album.page.dart
+++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart
@@ -76,6 +76,10 @@ class _RemoteAlbumPageState extends ConsumerState {
}
try {
+ if (!context.mounted) {
+ return;
+ }
+
await ref.read(remoteAlbumProvider.notifier).addUsers(_album.id, newUsers);
ref.invalidate(remoteAlbumSharedUsersProvider(_album.id));
if (!context.mounted) {
@@ -133,6 +137,10 @@ class _RemoteAlbumPageState extends ConsumerState {
if (confirmed == true) {
try {
+ if (!context.mounted) {
+ return;
+ }
+
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(_album.id);
if (!context.mounted) {
return;
@@ -431,7 +439,7 @@ class _AlbumKebabMenu extends ConsumerWidget {
return FutureBuilder(
future: ref
- .read(remoteAlbumServiceProvider)
+ .watch(remoteAlbumServiceProvider)
.getUserRole(album.id, user?.id ?? '')
.then((role) => role == AlbumUserRole.editor),
builder: (context, snapshot) {
diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart
index 81c09bea67..e6c78796d3 100644
--- a/mobile/lib/presentation/pages/drift_slideshow.page.dart
+++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart
@@ -300,7 +300,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si
borderRadius: BorderRadius.zero,
minHeight: 5,
value:
- ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds /
+ ref.read(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds /
asset.duration.inMilliseconds,
);
}
@@ -374,7 +374,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si
builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)),
);
} else {
- final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status));
+ final status = ref.read(videoPlayerProvider(asset.heroTag).select((s) => s.status));
final position = ref.read(videoPlayerProvider(asset.heroTag)).position;
if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) {
diff --git a/mobile/lib/presentation/pages/drift_user_selection.page.dart b/mobile/lib/presentation/pages/drift_user_selection.page.dart
index 19a450b435..a145b96a70 100644
--- a/mobile/lib/presentation/pages/drift_user_selection.page.dart
+++ b/mobile/lib/presentation/pages/drift_user_selection.page.dart
@@ -137,7 +137,7 @@ class DriftUserSelectionPage extends HookConsumerWidget {
body: suggestedShareUsers.widgetWhen(
onData: (users) {
// Get shared users for this album from the database
- final sharedUsers = ref.watch(remoteAlbumSharedUsersProvider(album.id));
+ final sharedUsers = ref.read(remoteAlbumSharedUsersProvider(album.id));
return sharedUsers.when(
data: (albumSharedUsers) {
diff --git a/mobile/lib/presentation/pages/edit/drift_edit.page.dart b/mobile/lib/presentation/pages/edit/drift_edit.page.dart
index ec7d8ac3d7..0fe5d28850 100644
--- a/mobile/lib/presentation/pages/edit/drift_edit.page.dart
+++ b/mobile/lib/presentation/pages/edit/drift_edit.page.dart
@@ -220,7 +220,7 @@ class _AspectRatioSelector extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final editorState = ref.watch(editorStateProvider);
- final editorNotifier = ref.read(editorStateProvider.notifier);
+ final editorNotifier = ref.watch(editorStateProvider.notifier);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
@@ -245,7 +245,7 @@ class _TransformControls extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final editorNotifier = ref.read(editorStateProvider.notifier);
+ final editorNotifier = ref.watch(editorStateProvider.notifier);
return Column(
mainAxisSize: MainAxisSize.min,
@@ -333,7 +333,7 @@ class _ResetEditsButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final editorState = ref.watch(editorStateProvider);
- final editorNotifier = ref.read(editorStateProvider.notifier);
+ final editorNotifier = ref.watch(editorStateProvider.notifier);
return ImmichTextButton(
labelText: 'reset'.tr(),
@@ -384,7 +384,7 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
@override
Widget build(BuildContext context) {
final editorState = ref.watch(editorStateProvider);
- final editorNotifier = ref.read(editorStateProvider.notifier);
+ final editorNotifier = ref.watch(editorStateProvider.notifier);
ref.listen(editorStateProvider, (previous, current) {
// Only re-apply the aspect ratio when it changes, otherwise the crop rect will shrink on every rotation
diff --git a/mobile/lib/presentation/pages/edit/editor.provider.dart b/mobile/lib/presentation/pages/edit/editor.provider.dart
index e97044853c..39cf4cc3ff 100644
--- a/mobile/lib/presentation/pages/edit/editor.provider.dart
+++ b/mobile/lib/presentation/pages/edit/editor.provider.dart
@@ -1,3 +1,4 @@
+import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
diff --git a/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart
index a987c9ca29..523781b274 100644
--- a/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart
+++ b/mobile/lib/presentation/pages/profile/profile_picture_crop.page.dart
@@ -70,6 +70,10 @@ class _ProfilePictureCropPageState extends ConsumerState
final croppedImage = await _cropController.croppedImage();
final pngBytes = await imageToUint8List(croppedImage);
final xFile = XFile.fromData(pngBytes, mimeType: 'image/png');
+ if (!context.mounted) {
+ return;
+ }
+
final success = await ref
.read(uploadProfileImageProvider.notifier)
.upload(xFile, fileName: 'profile-picture.png');
diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart
index 82f6718f20..63e48e0c33 100644
--- a/mobile/lib/presentation/pages/search/drift_search.page.dart
+++ b/mobile/lib/presentation/pages/search/drift_search.page.dart
@@ -749,7 +749,7 @@ class _SearchResultGrid extends ConsumerWidget {
}
Widget? _bottomWidget(BuildContext context, WidgetRef ref) {
- final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading));
+ final isLoading = ref.read(paginatedSearchProvider.select((s) => s.isLoading));
if (isLoading) {
return const SliverFillRemaining(
@@ -761,7 +761,7 @@ class _SearchResultGrid extends ConsumerWidget {
);
}
- final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null));
+ final hasMore = ref.read(paginatedSearchProvider.select((s) => s.nextPage != null));
if (hasMore) {
return null;
diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart
index ee2dace729..bb25fc4739 100644
--- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart
+++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart
@@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
-class BaseActionButton extends ConsumerWidget {
+class BaseActionButton extends StatelessWidget {
const BaseActionButton({
super.key,
required this.label,
@@ -31,7 +30,7 @@ class BaseActionButton extends ConsumerWidget {
final void Function()? onLongPressed;
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
final iconTheme = IconTheme.of(context);
final iconSize = iconTheme.size ?? 24.0;
diff --git a/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart
index 3bd67978e2..878c589b21 100644
--- a/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart
+++ b/mobile/lib/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart
@@ -17,7 +17,7 @@ class MotionPhotoActionButton extends ConsumerWidget {
return BaseActionButton(
iconData: isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded,
label: "play_motion_photo".t(context: context),
- onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
+ onPressed: ref.watch(isPlayingMotionVideoProvider.notifier).toggle,
iconOnly: iconOnly,
menuItem: menuItem,
);
diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart
index f30796e406..9babdb5b57 100644
--- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart
+++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart
@@ -669,7 +669,7 @@ class _GridAlbumCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
+ final albumThumbnailAsset = ref.watch(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
return GestureDetector(
onTap: () => onAlbumSelected(album),
@@ -746,7 +746,7 @@ class AddToAlbumHeader extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
Future onCreateAlbum() async {
final albumName = await showDialog(context: context, builder: (context) => const NewAlbumNameModal());
- if (albumName == null) {
+ if (albumName == null || !context.mounted) {
return;
}
diff --git a/mobile/lib/presentation/widgets/album/album_tile.dart b/mobile/lib/presentation/widgets/album/album_tile.dart
index bbf7e11e5a..598666cda9 100644
--- a/mobile/lib/presentation/widgets/album/album_tile.dart
+++ b/mobile/lib/presentation/widgets/album/album_tile.dart
@@ -17,7 +17,7 @@ class AlbumTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final albumThumbnailAsset = ref.read(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
+ final albumThumbnailAsset = ref.watch(assetServiceProvider).getRemoteAsset(album.thumbnailAssetId ?? "");
return LargeLeadingTile(
title: Text(
diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart
index e97fa4889b..e92b671562 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/technical_details.widget.dart
@@ -72,7 +72,7 @@ class TechnicalDetails extends ConsumerWidget {
final subtitleStyle = context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary);
if (asset is LocalAsset) {
- final assetMediaRepository = ref.watch(assetMediaRepositoryProvider);
+ final assetMediaRepository = ref.read(assetMediaRepositoryProvider);
return FutureBuilder(
future: assetMediaRepository.getOriginalFilename(asset.id),
builder: (context, snapshot) {
diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart
index 7233356402..c887f8e65a 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart
@@ -401,7 +401,7 @@ class _AssetPageState extends ConsumerState {
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
- final timelineOrigin = ref.read(timelineServiceProvider).origin;
+ final timelineOrigin = ref.watch(timelineServiceProvider).origin;
final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr));
final asset = _asset;
diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart
index 23c473e618..06aed2f09c 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart
@@ -17,7 +17,7 @@ class AssetStackRow extends ConsumerWidget {
return const SizedBox.shrink();
}
- final hideAssetStack = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
+ final hideAssetStack = ref.watch(timelineServiceProvider).origin == TimelineOrigin.trash;
if (hideAssetStack) {
return const SizedBox.shrink();
}
diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
index 3ed2b3a576..3333ac9933 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart
@@ -38,7 +38,7 @@ class ViewerBottomBar extends ConsumerWidget {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isInLockedView = ref.watch(inLockedViewProvider);
- final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
+ final isInTrash = ref.watch(timelineServiceProvider).origin == TimelineOrigin.trash;
final originalTheme = context.themeData;
diff --git a/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart
index 800af23039..08d6b59423 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart
@@ -30,7 +30,7 @@ class MotionPhotoPlayButton extends ConsumerWidget {
child: Center(
child: _MotionButton(
isPlaying: isPlaying,
- onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle,
+ onPressed: ref.watch(isPlayingMotionVideoProvider.notifier).toggle,
),
),
),
diff --git a/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart
index 73c7b2d3e7..cdff727967 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart
@@ -28,7 +28,7 @@ class OcrToggleButton extends ConsumerWidget {
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
- onTap: ref.read(assetViewerProvider.notifier).toggleOcr,
+ onTap: ref.watch(assetViewerProvider.notifier).toggleOcr,
child: const Padding(
padding: EdgeInsets.all(10.0),
child: Icon(Icons.text_fields_rounded, size: 22, color: Colors.white),
diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart
index 63b5663d9d..8b3d8a9978 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart
@@ -150,6 +150,10 @@ class _NativeVideoViewerState extends ConsumerState with Widg
final remoteAsset = videoAsset as RemoteAsset;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
+ if (!context.mounted) {
+ return null;
+ }
+
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String assetId = remoteAsset.livePhotoVideoId ?? remoteAsset.id;
diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart
index da0abad1dd..cbbc851108 100644
--- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart
+++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart
@@ -29,7 +29,7 @@ class ViewerKebabMenu extends ConsumerWidget {
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
- final timelineOrigin = ref.read(timelineServiceProvider).origin;
+ final timelineOrigin = ref.watch(timelineServiceProvider).origin;
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final isInLockedView = ref.watch(inLockedViewProvider);
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
diff --git a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart
index d7334337ae..3064ced2a4 100644
--- a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart
+++ b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/download.action.dart';
@@ -7,11 +6,11 @@ import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/share.action.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
-class LockedFolderBottomSheet extends ConsumerWidget {
+class LockedFolderBottomSheet extends StatelessWidget {
const LockedFolderBottomSheet({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return const BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.4,
diff --git a/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart
index 1d378f1350..b496503dad 100644
--- a/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart
+++ b/mobile/lib/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart
@@ -1,15 +1,14 @@
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/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
-class TrashBottomBar extends ConsumerWidget {
+class TrashBottomBar extends StatelessWidget {
const TrashBottomBar({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return Align(
alignment: Alignment.bottomCenter,
child: Container(
diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart
index 927734ca25..aaf3e0dbaf 100644
--- a/mobile/lib/presentation/widgets/images/image_provider.dart
+++ b/mobile/lib/presentation/widgets/images/image_provider.dart
@@ -162,7 +162,15 @@ ImageProvider getFullImageProvider(
provider = FileImage(File(localFilePath));
} else if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
- provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
+ provider = LocalFullImageProvider(
+ id: id,
+ size: size,
+ assetType: asset.type,
+ isAnimated: asset.isAnimatedImage,
+ width: asset.width,
+ height: asset.height,
+ checksum: asset.checksum,
+ );
} else {
final String assetId;
final String thumbhash;
@@ -190,7 +198,7 @@ ImageProvider getFullImageProvider(
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
- return LocalThumbProvider(id: id, size: size, assetType: asset.type);
+ return LocalThumbProvider(id: id, size: size, assetType: asset.type, checksum: asset.checksum);
}
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart
index eba4f0a1cd..46e8eda85b 100644
--- a/mobile/lib/presentation/widgets/images/local_image_provider.dart
+++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart
@@ -1,3 +1,5 @@
+import 'dart:math' as math;
+
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -8,13 +10,19 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
+// iOS GPU textures max out at 16384px; larger images squish.
+const _kMaxPixelSize = 16384;
+
class LocalThumbProvider extends CancellableImageProvider
with CancellableImageProviderMixin {
final String id;
final Size size;
final AssetType assetType;
- LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution});
+ // an edit on the device keeps the id and changes the bytes, so the checksum is what separates two renders
+ final String? checksum;
+
+ LocalThumbProvider({required this.id, required this.assetType, this.checksum, this.size = kThumbnailResolution});
@override
Future obtainKey(ImageConfiguration configuration) {
@@ -44,13 +52,13 @@ class LocalThumbProvider extends CancellableImageProvider
return true;
}
if (other is LocalThumbProvider) {
- return id == other.id;
+ return id == other.id && checksum == other.checksum;
}
return false;
}
@override
- int get hashCode => id.hashCode;
+ int get hashCode => Object.hash(id, checksum);
}
class LocalFullImageProvider extends CancellableImageProvider
@@ -59,8 +67,38 @@ class LocalFullImageProvider extends CancellableImageProvider
+ previewTargetSize(size.width * dpr, size.height * dpr, width, height, previewIsFinal: previewIsFinal);
+
+ // Use an aspect-correct target when aspectFill would exceed the texture limit.
+ @visibleForTesting
+ static Size previewTargetSize(double boxW, double boxH, int? width, int? height, {required bool previewIsFinal}) {
+ if (width == null || height == null || width <= 0 || height <= 0) {
+ return Size(boxW, boxH);
+ }
+ final imgLong = math.max(width, height).toDouble();
+ final coverLong = imgLong * math.max(boxW / width, boxH / height);
+ if (coverLong <= _kMaxPixelSize) {
+ return Size(boxW, boxH);
+ }
+ final bound = previewIsFinal ? _kMaxPixelSize.toDouble() : math.max(boxW, boxH);
+ final scale = math.min(1.0, bound / imgLong);
+ return Size(math.max(1.0, width * scale), math.max(1.0, height * scale));
+ }
@override
Future obtainKey(ImageConfiguration configuration) {
@@ -73,7 +111,7 @@ class LocalFullImageProvider extends CancellableImageProvider [
DiagnosticsProperty('Image provider', this),
DiagnosticsProperty('Id', key.id),
@@ -86,7 +124,7 @@ class LocalFullImageProvider extends CancellableImageProvider [
DiagnosticsProperty('Image provider', this),
DiagnosticsProperty('Id', key.id),
@@ -108,7 +146,7 @@ class LocalFullImageProvider extends CancellableImageProvider id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
+ int get hashCode => Object.hash(id, size, isAnimated, width, height, checksum);
}
diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart
index b0b7816889..94a91437b0 100644
--- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart
+++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart
@@ -45,13 +45,13 @@ class DriftMemoryLane extends ConsumerWidget {
}
}
-class DriftMemoryCard extends ConsumerWidget {
+class DriftMemoryCard extends StatelessWidget {
const DriftMemoryCard({super.key, required this.memory});
final DriftMemory memory;
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final yearsAgo = DateTime.now().year - memory.data.year;
final title = 'years_ago'.t(context: context, args: {'years': yearsAgo.toString()});
return Center(
diff --git a/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart b/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart
index b0ded02624..ac2d4580c9 100644
--- a/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart
+++ b/mobile/lib/presentation/widgets/people/person_option_sheet.widget.dart
@@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
-class PersonOptionSheet extends ConsumerWidget {
+class PersonOptionSheet extends StatelessWidget {
const PersonOptionSheet({super.key, this.onEditName, this.onEditBirthday, this.birthdayExists = false});
final VoidCallback? onEditName;
@@ -10,7 +9,7 @@ class PersonOptionSheet extends ConsumerWidget {
final bool birthdayExists;
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final TextStyle textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w600);
return SafeArea(
diff --git a/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart b/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart
index 355e1a01a8..cd94ef89a8 100644
--- a/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart
+++ b/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart
@@ -1,10 +1,9 @@
import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
-class DriftRemoteAlbumOption extends ConsumerWidget {
+class DriftRemoteAlbumOption extends StatelessWidget {
const DriftRemoteAlbumOption({
super.key,
this.onAddPhotos,
@@ -31,7 +30,7 @@ class DriftRemoteAlbumOption extends ConsumerWidget {
final List? iconShadows;
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final theme = context.themeData;
final menuChildren = [];
diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart
index 16c947ca5e..b108acde3b 100644
--- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart
+++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart
@@ -107,7 +107,7 @@ class _FixedSegmentRow extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
- final timelineService = ref.read(timelineServiceProvider);
+ final timelineService = ref.watch(timelineServiceProvider);
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
if (timelineService.hasRange(assetIndex, assetCount)) {
@@ -261,7 +261,7 @@ class _AssetTileWidget extends ConsumerWidget {
final lockSelection = _getLockSelectionStatus(ref);
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
- final showStackIndicator = ref.read(timelineServiceProvider).origin != TimelineOrigin.trash;
+ final showStackIndicator = ref.watch(timelineServiceProvider).origin != TimelineOrigin.trash;
return RepaintBoundary(
child: GestureDetector(
diff --git a/mobile/lib/presentation/widgets/timeline/header.widget.dart b/mobile/lib/presentation/widgets/timeline/header.widget.dart
index 76176041fa..433118d13c 100644
--- a/mobile/lib/presentation/widgets/timeline/header.widget.dart
+++ b/mobile/lib/presentation/widgets/timeline/header.widget.dart
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
@@ -12,7 +13,7 @@ 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';
-class TimelineHeader extends HookConsumerWidget {
+class TimelineHeader extends HookWidget {
final Bucket bucket;
final HeaderType header;
final double height;
@@ -39,7 +40,7 @@ class TimelineHeader extends HookConsumerWidget {
}
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
if (bucket is! TimeBucket || header == HeaderType.none) {
return const SizedBox.shrink();
}
diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart
index 6710448eeb..adad06b987 100644
--- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart
+++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart
@@ -1,5 +1,6 @@
import 'dart:math' as math;
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart
index 8678f7c32e..7dc475ab93 100644
--- a/mobile/lib/providers/app_life_cycle.provider.dart
+++ b/mobile/lib/providers/app_life_cycle.provider.dart
@@ -81,6 +81,10 @@ class AppLifeCycleNotifier extends StateNotifier {
await _ref.read(serverInfoProvider.notifier).getServerVersion();
}
+ if (!_shouldContinueOperation()) {
+ _wasPaused = true;
+ return;
+ }
_ref.read(websocketProvider.notifier).connect();
await _handleBetaTimelineResume();
@@ -89,13 +93,13 @@ class AppLifeCycleNotifier extends StateNotifier {
await _ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus();
}
- Future _safeRun(Future action, String debugName) async {
+ Future _safeRun(Future Function() action, String debugName) async {
if (!_shouldContinueOperation()) {
return;
}
try {
- await action;
+ await action();
} catch (e, stackTrace) {
_log.warning("Error during $debugName operation", e, stackTrace);
}
@@ -113,13 +117,15 @@ class AppLifeCycleNotifier extends StateNotifier {
try {
bool syncSuccess = false;
await Future.wait([
- _safeRun(backgroundManager.syncLocal(full: CurrentPlatform.isAndroid ? true : false), "syncLocal"),
- _safeRun(backgroundManager.syncRemote().then((success) => syncSuccess = success), "syncRemote"),
+ _safeRun(() => backgroundManager.syncLocal(full: CurrentPlatform.isAndroid), "syncLocal"),
+ _safeRun(() async {
+ syncSuccess = await backgroundManager.syncRemote();
+ }, "syncRemote"),
]);
_ref.invalidate(driftMemoryFutureProvider);
if (syncSuccess) {
await Future.wait([
- _safeRun(backgroundManager.hashAssets(), "hashAssets").then((_) {
+ _safeRun(backgroundManager.hashAssets, "hashAssets").then((_) {
unawaited(_resumeBackup());
}),
_resumeBackup(),
@@ -127,11 +133,11 @@ class AppLifeCycleNotifier extends StateNotifier {
// _safeRun(backgroundManager.syncCloudIds(), "syncCloudIds"),
]);
} else {
- await _safeRun(backgroundManager.hashAssets(), "hashAssets");
+ await _safeRun(backgroundManager.hashAssets, "hashAssets");
}
if (isAlbumLinkedSyncEnable) {
- await _safeRun(backgroundManager.syncLinkedAlbum(), "syncLinkedAlbum");
+ await _safeRun(backgroundManager.syncLinkedAlbum, "syncLinkedAlbum");
}
} catch (e, stackTrace) {
_log.severe("Error during background sync", e, stackTrace);
@@ -145,7 +151,7 @@ class AppLifeCycleNotifier extends StateNotifier {
final currentUser = Store.tryGet(StoreKey.currentUser);
if (currentUser != null) {
await _safeRun(
- _ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id),
+ () => _ref.read(driftBackupProvider.notifier).startForegroundBackup(currentUser.id),
"handleBackupResume",
);
}
@@ -195,7 +201,7 @@ class AppLifeCycleNotifier extends StateNotifier {
Future _performPause() {
if (_ref.read(authProvider).isAuthenticated) {
- _ref.read(driftBackupProvider.notifier).stopForegroundBackup();
+ _ref.read(driftBackupProvider.notifier).stopForegroundBackup(reason: "the app being sent to the background");
_ref.read(websocketProvider.notifier).disconnect();
}
diff --git a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart
index 3bafdc4aa9..fd2a6aebd9 100644
--- a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart
+++ b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart
@@ -1,5 +1,6 @@
import 'dart:async';
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
diff --git a/mobile/lib/providers/background_sync.provider.dart b/mobile/lib/providers/background_sync.provider.dart
index 37b3145eb4..5873e992b1 100644
--- a/mobile/lib/providers/background_sync.provider.dart
+++ b/mobile/lib/providers/background_sync.provider.dart
@@ -9,6 +9,7 @@ final backgroundSyncProvider = Provider((ref) {
final manager = BackgroundSyncManager(
onRemoteSyncStart: () {
syncStatusNotifier.startRemoteSync();
+ // ignore: dispose-provided-instances
final backupProvider = ref.read(driftBackupProvider.notifier);
if (backupProvider.mounted) {
backupProvider.updateError(BackupError.none);
@@ -16,6 +17,7 @@ final backgroundSyncProvider = Provider((ref) {
},
onRemoteSyncComplete: (isSuccess) {
syncStatusNotifier.completeRemoteSync();
+ // ignore: dispose-provided-instances
final backupProvider = ref.read(driftBackupProvider.notifier);
if (backupProvider.mounted) {
backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed);
diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart
index 08eb7dd3e5..c3e1098e4b 100644
--- a/mobile/lib/providers/backup/drift_backup.provider.dart
+++ b/mobile/lib/providers/backup/drift_backup.provider.dart
@@ -1,5 +1,6 @@
import 'dart:async';
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
@@ -134,19 +135,25 @@ class DriftBackupNotifier extends StateNotifier {
state = state.copyWith(isSyncing: isSyncing);
}
- Future startForegroundBackup(String userId) {
+ Future startForegroundBackup(String userId) async {
// Cancel any existing backup before starting a new one
if (_cancelToken != null) {
- stopForegroundBackup();
+ stopForegroundBackup(reason: "restarting the backup");
}
state = state.copyWith(error: BackupError.none);
- _cancelToken = Completer();
+ // A pause during the recount below nulls _cancelToken, so the run keeps its own reference.
+ final cancelToken = Completer();
+ _cancelToken = cancelToken;
+
+ // Re-baseline the counters against the same DB read that feeds this run's candidate list,
+ // otherwise a resume counts duplicate successes against the old baseline (#26215).
+ await getBackupStatus(userId);
return _foregroundUploadService.uploadCandidates(
userId,
- _cancelToken!,
+ cancelToken,
callbacks: UploadCallbacks(
onProgress: _handleForegroundBackupProgress,
onSuccess: _handleForegroundBackupSuccess,
@@ -156,7 +163,10 @@ class DriftBackupNotifier extends StateNotifier {
);
}
- void stopForegroundBackup() {
+ void stopForegroundBackup({required String reason}) {
+ if (_cancelToken != null) {
+ _logger.info("Foreground backup cancelled: $reason");
+ }
_cancelToken?.complete();
_cancelToken = null;
_uploadSpeedManager.clear();
@@ -212,6 +222,10 @@ class DriftBackupNotifier extends StateNotifier {
}
void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) {
+ if (!mounted) {
+ _logger.warning("Skip _handleForegroundBackupSuccess: notifier disposed");
+ return;
+ }
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
_uploadSpeedManager.removeTask(localAssetId);
diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart
index 919836737f..3fb9f8acbc 100644
--- a/mobile/lib/providers/infrastructure/action.provider.dart
+++ b/mobile/lib/providers/infrastructure/action.provider.dart
@@ -1,3 +1,5 @@
+// ignore_for_file: use-ref-and-state-synchronously
+
import 'dart:async';
import 'package:auto_route/auto_route.dart';
diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart
index 3f757da0e2..b76a8213c6 100644
--- a/mobile/lib/providers/infrastructure/remote_album.provider.dart
+++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart
@@ -1,3 +1,5 @@
+// ignore_for_file: use-ref-and-state-synchronously
+
import 'dart:async';
import 'package:freezed_annotation/freezed_annotation.dart';
diff --git a/mobile/lib/providers/infrastructure/setting.provider.dart b/mobile/lib/providers/infrastructure/setting.provider.dart
index 7d8be72cd0..9e33017cab 100644
--- a/mobile/lib/providers/infrastructure/setting.provider.dart
+++ b/mobile/lib/providers/infrastructure/setting.provider.dart
@@ -1,3 +1,5 @@
+// ignore_for_file: use-ref-and-state-synchronously
+
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
diff --git a/mobile/lib/providers/infrastructure/tag.provider.dart b/mobile/lib/providers/infrastructure/tag.provider.dart
index 2d527768c5..152582aa93 100644
--- a/mobile/lib/providers/infrastructure/tag.provider.dart
+++ b/mobile/lib/providers/infrastructure/tag.provider.dart
@@ -1,3 +1,5 @@
+// ignore_for_file: use-ref-and-state-synchronously
+
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
diff --git a/mobile/lib/providers/permission.provider.dart b/mobile/lib/providers/permission.provider.dart
index dc82285122..bd4dc7b61e 100644
--- a/mobile/lib/providers/permission.provider.dart
+++ b/mobile/lib/providers/permission.provider.dart
@@ -1,3 +1,5 @@
+// ignore_for_file: use-ref-and-state-synchronously
+
import 'dart:async';
import 'dart:io';
diff --git a/mobile/lib/providers/search/search_input_focus.provider.dart b/mobile/lib/providers/search/search_input_focus.provider.dart
index 4f6ed41ee0..4ebbfc6553 100644
--- a/mobile/lib/providers/search/search_input_focus.provider.dart
+++ b/mobile/lib/providers/search/search_input_focus.provider.dart
@@ -2,5 +2,7 @@ import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final searchInputFocusProvider = Provider((ref) {
- return FocusNode();
+ final focusNode = FocusNode();
+ ref.onDispose(focusNode.dispose);
+ return focusNode;
});
diff --git a/mobile/lib/providers/sync_status.provider.dart b/mobile/lib/providers/sync_status.provider.dart
index c6494e75fc..7a70514ca7 100644
--- a/mobile/lib/providers/sync_status.provider.dart
+++ b/mobile/lib/providers/sync_status.provider.dart
@@ -1,4 +1,5 @@
import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
diff --git a/mobile/lib/providers/timeline/multiselect.provider.dart b/mobile/lib/providers/timeline/multiselect.provider.dart
index 8460bddc7d..b680ce4491 100644
--- a/mobile/lib/providers/timeline/multiselect.provider.dart
+++ b/mobile/lib/providers/timeline/multiselect.provider.dart
@@ -1,4 +1,6 @@
-import 'package:freezed_annotation/freezed_annotation.dart';
+// ignore_for_file: use-ref-and-state-synchronously
+
+import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart
index fff219ff21..32531c354b 100644
--- a/mobile/lib/providers/websocket.provider.dart
+++ b/mobile/lib/providers/websocket.provider.dart
@@ -1,5 +1,6 @@
import 'dart:async';
+import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -38,14 +39,16 @@ class WebsocketNotifier extends StateNotifier {
@override
void dispose() {
_batchDebouncer.dispose();
+ state.socket?.dispose();
super.dispose();
}
- /// Connects websocket to server unless already connected
+ /// Connects websocket to server unless an active socket already exists
void connect() {
- if (state.isConnected) {
+ if (state.socket?.active == true) {
return;
}
+ state.socket?.dispose();
final authenticationState = _ref.read(authProvider);
if (authenticationState.isAuthenticated) {
@@ -66,6 +69,8 @@ class WebsocketNotifier extends StateNotifier {
.build(),
);
+ state = WebsocketState(isConnected: false, socket: socket);
+
socket.onConnect((_) {
dPrint(() => "Established Websocket Connection");
state = WebsocketState(isConnected: true, socket: socket);
@@ -73,12 +78,12 @@ class WebsocketNotifier extends StateNotifier {
socket.onDisconnect((_) {
dPrint(() => "Disconnect to Websocket Connection");
- state = const WebsocketState(isConnected: false, socket: null);
+ state = WebsocketState(isConnected: false, socket: socket);
});
socket.on('error', (errorMessage) {
_log.severe("Websocket Error - $errorMessage");
- state = const WebsocketState(isConnected: false, socket: null);
+ state = WebsocketState(isConnected: false, socket: socket);
});
socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReadyV1);
@@ -87,6 +92,11 @@ class WebsocketNotifier extends StateNotifier {
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
socket.on('on_album_update', _handleRemoteChange);
socket.on('on_asset_stack_update', _handleRemoteChange);
+ socket.on('on_asset_delete', _handleRemoteChange);
+ socket.on('on_asset_trash', _handleRemoteChange);
+ socket.on('on_asset_restore', _handleRemoteChange);
+ socket.on('on_asset_hidden', _handleRemoteChange);
+ socket.on('on_asset_update', _handleRemoteChange);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_new_release', _handleReleaseUpdates);
} catch (e) {
diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart
index ed1ffd9e3d..855b8302a9 100644
--- a/mobile/lib/repositories/download.repository.dart
+++ b/mobile/lib/repositories/download.repository.dart
@@ -10,6 +10,7 @@ import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
+// ignore: dispose-provided-instances
final downloadRepositoryProvider = Provider((ref) => DownloadRepository());
class DownloadRepository {
diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart
index ea33e4ba25..a4c5bab4fb 100644
--- a/mobile/lib/routing/router.dart
+++ b/mobile/lib/routing/router.dart
@@ -84,6 +84,7 @@ import 'package:maplibre_gl/maplibre_gl.dart';
part 'router.gr.dart';
final appRouterProvider = Provider(
+ // ignore: dispose-provided-instances
(ref) => AppRouter(
ref.watch(apiServiceProvider),
ref.watch(authServiceProvider),
diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart
index 36d3975a26..11cbd03017 100644
--- a/mobile/lib/services/foreground_upload.service.dart
+++ b/mobile/lib/services/foreground_upload.service.dart
@@ -35,6 +35,7 @@ class UploadCallbacks {
}
final foregroundUploadServiceProvider = Provider((ref) {
+ // ignore: dispose-provided-instances
return ForegroundUploadService(
ref.watch(uploadRepositoryProvider),
ref.watch(storageRepositoryProvider),
@@ -383,7 +384,6 @@ class ForegroundUploadService {
if (result.isSuccess && result.remoteAssetId != null) {
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
} else if (result.isCancelled) {
- _logger.warning(() => "Backup was cancelled by the user");
shouldAbortUpload = true;
} else if (result.errorMessage != null) {
_logger.severe(
diff --git a/mobile/lib/widgets/activities/comment_bubble.dart b/mobile/lib/widgets/activities/comment_bubble.dart
index fcaff8bfc3..5431420741 100644
--- a/mobile/lib/widgets/activities/comment_bubble.dart
+++ b/mobile/lib/widgets/activities/comment_bubble.dart
@@ -28,7 +28,7 @@ class CommentBubble extends ConsumerWidget {
final isLike = activity.type == ActivityType.like;
final bgColor = isOwn ? context.colorScheme.primaryContainer : context.colorScheme.surfaceContainer;
- final activityNotifier = ref.read(
+ final activityNotifier = ref.watch(
albumActivityProvider((album.id, isAssetActivity ? activity.assetId : null)).notifier,
);
diff --git a/mobile/lib/widgets/asset_viewer/cast_dialog.dart b/mobile/lib/widgets/asset_viewer/cast_dialog.dart
index d406f29a22..f91d8f5b27 100644
--- a/mobile/lib/widgets/asset_viewer/cast_dialog.dart
+++ b/mobile/lib/widgets/asset_viewer/cast_dialog.dart
@@ -28,7 +28,7 @@ class CastDialog extends ConsumerWidget {
width: 250,
height: 250,
child: FutureBuilder>(
- future: ref.read(castProvider.notifier).getDevices(),
+ future: ref.watch(castProvider.notifier).getDevices(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Text('error_saving_image'.tr(args: [snapshot.error.toString()]));
diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart
index f39077a522..14c4daf5d8 100644
--- a/mobile/lib/widgets/asset_viewer/video_controls.dart
+++ b/mobile/lib/widgets/asset_viewer/video_controls.dart
@@ -99,7 +99,7 @@ class _VideoControlsState extends ConsumerState {
});
ref.listen(_provider.select((v) => v.status), (_, __) => _hideTimer.reset());
- final notifier = ref.read(_provider.notifier);
+ final notifier = ref.watch(_provider.notifier);
final isLoaded = duration != Duration.zero;
return Padding(
diff --git a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart
index 999b64e9de..41a215fb99 100644
--- a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart
+++ b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart
@@ -53,7 +53,7 @@ class DriftAlbumInfoListTile extends HookConsumerWidget {
return GestureDetector(
onDoubleTap: () {
- ref.watch(hapticFeedbackProvider.notifier).selectionClick();
+ ref.read(hapticFeedbackProvider.notifier).selectionClick();
if (isExcluded) {
unawaited(ref.read(backupAlbumProvider.notifier).deselectAlbum(album));
diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart
index 53c1eb1af9..b390d2a423 100644
--- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart
+++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart
@@ -124,6 +124,9 @@ class ImmichAppBarDialog extends HookConsumerWidget {
onOk: () async {
isLoggingOut.value = true;
await ref.read(authProvider.notifier).logout().whenComplete(() => isLoggingOut.value = false);
+ if (!context.mounted) {
+ return;
+ }
ref.read(websocketProvider.notifier).disconnect();
if (!context.mounted) {
diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart
index c963564c69..51cdd7c5a3 100644
--- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart
+++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart
@@ -45,12 +45,12 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
Future pickUserProfileImage() async {
final XFile? image = await ImagePicker().pickImage(source: ImageSource.gallery, maxHeight: 1024, maxWidth: 1024);
- if (image != null) {
- final success = await ref.watch(uploadProfileImageProvider.notifier).upload(image);
+ if (image != null && context.mounted) {
+ final success = await ref.read(uploadProfileImageProvider.notifier).upload(image);
- if (success) {
+ if (success && context.mounted) {
final profileImagePath = ref.read(uploadProfileImageProvider).profileImagePath;
- ref.watch(authProvider.notifier).updateUserProfileImagePath(profileImagePath);
+ ref.read(authProvider.notifier).updateUserProfileImagePath(profileImagePath);
if (user != null) {
unawaited(ref.read(currentUserProvider.notifier).refresh());
}
@@ -61,7 +61,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
}
void toggleReadonlyMode() {
- final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
+ final isReadonlyModeEnabled = ref.read(readonlyModeProvider);
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
context.scaffoldMessenger.showSnackBar(
diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart
index c5bce92cff..54495365db 100644
--- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart
+++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart
@@ -113,7 +113,7 @@ class _ProfileIndicator extends ConsumerWidget {
final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile;
void toggleReadonlyMode() {
- final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
+ final isReadonlyModeEnabled = ref.read(readonlyModeProvider);
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
context.scaffoldMessenger.showSnackBar(
@@ -193,11 +193,11 @@ class _BackupIndicator extends ConsumerWidget {
}
Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) {
- final backupEnabled = ref.watch(appConfigProvider.select((c) => c.backup.enabled));
- final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none));
+ final backupEnabled = ref.read(appConfigProvider.select((c) => c.backup.enabled));
+ final hasError = ref.read(driftBackupProvider.select((state) => state.error != BackupError.none));
final isDarkTheme = context.isDarkTheme;
final iconColor = isDarkTheme ? Colors.white : Colors.black;
- final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty));
+ final isUploading = ref.read(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty));
if (!backupEnabled) {
return _BadgeLabel(
diff --git a/mobile/lib/widgets/common/tag_picker.dart b/mobile/lib/widgets/common/tag_picker.dart
index a9a68fe044..82228f5b0b 100644
--- a/mobile/lib/widgets/common/tag_picker.dart
+++ b/mobile/lib/widgets/common/tag_picker.dart
@@ -17,13 +17,13 @@ Future<(Set, Set)?> showTagPickerModal({required BuildContext co
);
}
-class _TagPickerModal extends HookConsumerWidget {
+class _TagPickerModal extends HookWidget {
final Set? initialSelection;
const _TagPickerModal({this.initialSelection});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final selectedTagIds = useState>(initialSelection ?? {});
final newTagValues = useState>({});
diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart
index c6e4f4719e..11257da77b 100644
--- a/mobile/lib/widgets/common/user_circle_avatar.dart
+++ b/mobile/lib/widgets/common/user_circle_avatar.dart
@@ -1,21 +1,19 @@
import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
-// ignore: must_be_immutable
-class UserCircleAvatar extends ConsumerWidget {
+class UserCircleAvatar extends StatelessWidget {
final UserDto user;
- double size;
- bool hasBorder;
- double opacity;
+ final double size;
+ final bool hasBorder;
+ final double opacity;
- UserCircleAvatar({super.key, this.size = 44, this.hasBorder = false, this.opacity = 1, required this.user});
+ const UserCircleAvatar({super.key, this.size = 44, this.hasBorder = false, this.opacity = 1, required this.user});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final userAvatarColor = user.avatarColor.toColor().withValues(alpha: opacity);
final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}';
diff --git a/mobile/lib/widgets/forms/change_password_form.dart b/mobile/lib/widgets/forms/change_password_form.dart
index dd307f4ede..229eaa7ec6 100644
--- a/mobile/lib/widgets/forms/change_password_form.dart
+++ b/mobile/lib/widgets/forms/change_password_form.dart
@@ -69,12 +69,16 @@ class ChangePasswordForm extends HookConsumerWidget {
return;
}
- await ref.read(authProvider.notifier).logout();
- ref.read(websocketProvider.notifier).disconnect();
if (!context.mounted) {
return;
}
+ await ref.read(authProvider.notifier).logout();
+ if (!context.mounted) {
+ return;
+ }
+
+ ref.read(websocketProvider.notifier).disconnect();
AutoRouter.of(context).back();
ImmichToast.show(
context: context,
@@ -149,13 +153,13 @@ class ConfirmPasswordInput extends StatelessWidget {
}
}
-class ChangePasswordButton extends ConsumerWidget {
+class ChangePasswordButton extends StatelessWidget {
final TextEditingController passwordController;
final VoidCallback onPressed;
const ChangePasswordButton({super.key, required this.passwordController, required this.onPressed});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return ElevatedButton(
style: ElevatedButton.styleFrom(
visualDensity: VisualDensity.standard,
diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart
index 18963f0cfd..e4c7516c43 100644
--- a/mobile/lib/widgets/forms/login/login_form.dart
+++ b/mobile/lib/widgets/forms/login/login_form.dart
@@ -101,9 +101,15 @@ class LoginForm extends HookConsumerWidget {
try {
final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl);
+ if (!context.mounted) {
+ return;
+ }
// Fetch and load server config and features
await ref.read(serverInfoProvider.notifier).getServerInfo();
+ if (!context.mounted) {
+ return;
+ }
final serverInfo = ref.read(serverInfoProvider);
final features = serverInfo.serverFeatures;
@@ -264,6 +270,10 @@ class LoginForm extends HookConsumerWidget {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
+ if (!context.mounted) {
+ return;
+ }
+
ref.read(websocketProvider.notifier).connect();
unawaited(ref.read(featureMessageServiceProvider).markSeen());
if (!context.mounted) {
@@ -312,7 +322,7 @@ class LoginForm extends HookConsumerWidget {
}
Future oAuthLogin() async {
- final oAuthService = ref.watch(oAuthServiceProvider);
+ final oAuthService = ref.read(oAuthServiceProvider);
String? oAuthServerUrl;
final state = generateRandomString(32);
@@ -349,27 +359,27 @@ class LoginForm extends HookConsumerWidget {
try {
final loginResponseDto = await oAuthService.oAuthLogin(oAuthServerUrl, state, codeVerifier);
- if (loginResponseDto == null) {
+ if (loginResponseDto == null || !context.mounted) {
return;
}
log.info("Finished OAuth login with response: ${loginResponseDto.userEmail}");
final isSuccess = await ref
- .watch(authProvider.notifier)
+ .read(authProvider.notifier)
.saveAuthInfo(accessToken: loginResponseDto.accessToken);
- if (isSuccess) {
+ if (isSuccess && context.mounted) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
- unawaited(ref.read(featureMessageServiceProvider).markSeen());
if (!context.mounted) {
return;
}
+ unawaited(ref.read(featureMessageServiceProvider).markSeen());
unawaited(context.router.replaceAll([const TabShellRoute()]));
return;
}
diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart
index eb4653e17e..84c2ac0100 100644
--- a/mobile/lib/widgets/map/map_thumbnail.dart
+++ b/mobile/lib/widgets/map/map_thumbnail.dart
@@ -2,7 +2,6 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
@@ -15,7 +14,7 @@ import 'package:maplibre_gl/maplibre_gl.dart';
/// User can provide either a [assetMarkerRemoteId] to display the asset's thumbnail or set
/// [showMarkerPin] to true which would display a marker pin instead. If both are provided,
/// [assetMarkerRemoteId] will take precedence
-class MapThumbnail extends HookConsumerWidget {
+class MapThumbnail extends HookWidget {
final Function(Point, LatLng)? onTap;
final LatLng centre;
final String? assetMarkerRemoteId;
@@ -44,7 +43,7 @@ class MapThumbnail extends HookConsumerWidget {
});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final controller = useRef(null);
final styleLoaded = useState(false);
diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart
index bf8673ccc5..cb035a22e9 100644
--- a/mobile/lib/widgets/settings/advanced_settings.dart
+++ b/mobile/lib/widgets/settings/advanced_settings.dart
@@ -32,8 +32,8 @@ class AdvancedSettings extends HookConsumerWidget {
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
- final levelId = useState(ref.read(appConfigProvider).logLevel.index);
- final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote);
+ final levelId = useState(ref.watch(appConfigProvider).logLevel.index);
+ final preferRemote = useState(ref.watch(appConfigProvider).image.preferRemote);
useValueChanged(
preferRemote.value,
(_, __) => unawaited(ref.read(settingsProvider).write(.imagePreferRemote, preferRemote.value)),
@@ -60,7 +60,7 @@ class AdvancedSettings extends HookConsumerWidget {
useEffect(() {
unawaited(() async {
isManageMediaSupported.value = await checkAndroidVersion();
- if (isManageMediaSupported.value) {
+ if (isManageMediaSupported.value && context.mounted) {
manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
}
}());
diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart
index eda0d819d5..5b4c4bb89f 100644
--- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart
+++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart
@@ -15,7 +15,7 @@ class LayoutSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final tilesPerRow = useState(ref.read(appConfigProvider.select((s) => s.timeline.tilesPerRow)));
+ final tilesPerRow = useState(ref.watch(appConfigProvider.select((s) => s.timeline.tilesPerRow)));
useValueChanged(tilesPerRow.value, (_, __) {
unawaited(ref.read(settingsProvider).write(.timelineTilesPerRow, tilesPerRow.value));
});
diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart
index e3173dcdc7..82a2d804a3 100644
--- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart
+++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart
@@ -14,7 +14,7 @@ class ImageViewerQualitySetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final isOriginal = useState(ref.read(appConfigProvider).image.loadOriginal);
+ final isOriginal = useState(ref.watch(appConfigProvider).image.loadOriginal);
useValueChanged(isOriginal.value, (_, __) {
unawaited(ref.read(settingsProvider).write(.imageLoadOriginal, isOriginal.value));
});
diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart
index c785e8fed4..fc3466c3c6 100644
--- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart
+++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart
@@ -13,7 +13,7 @@ class ImageViewerTapToNavigateSetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final tapToNavigate = useState(ref.read(appConfigProvider).viewer.tapToNavigate);
+ final tapToNavigate = useState(ref.watch(appConfigProvider).viewer.tapToNavigate);
useValueChanged(tapToNavigate.value, (_, __) {
unawaited(ref.read(settingsProvider).write(.viewerTapToNavigate, tapToNavigate.value));
});
diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart
index ec52d23ca8..d8ccd4f0ed 100644
--- a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart
+++ b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart
@@ -17,7 +17,7 @@ class SlideshowSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final slideshow = ref.read(appConfigProvider).slideshow;
+ final slideshow = ref.watch(appConfigProvider).slideshow;
final useRepeat = useState(slideshow.repeat);
final useDuration = useState(slideshow.duration);
final useLook = useState(slideshow.look);
diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart
index 2b302e0427..7bea439f0c 100644
--- a/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart
+++ b/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart
@@ -13,7 +13,7 @@ class VideoViewerSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final viewer = ref.read(appConfigProvider).viewer;
+ final viewer = ref.watch(appConfigProvider).viewer;
final useAutoPlayVideo = useState(viewer.autoPlayVideo);
final useLoopVideo = useState(viewer.loopVideo);
final useOriginalVideo = useState(viewer.loadOriginalVideo);
diff --git a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart
index f0f6435f90..e7d56ed66a 100644
--- a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart
+++ b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart
@@ -19,11 +19,11 @@ import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
-class DriftBackupSettings extends ConsumerWidget {
+class DriftBackupSettings extends StatelessWidget {
const DriftBackupSettings({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
return SettingsSubPageScaffold(
settings: [
SettingGroupTitle(
@@ -68,8 +68,9 @@ class _AlbumSyncActionButtonState extends ConsumerState<_AlbumSyncActionButton>
});
try {
- await ref.read(backgroundSyncProvider).syncLinkedAlbum();
- await ref.read(backgroundSyncProvider).syncRemote();
+ final backgroundSync = ref.read(backgroundSyncProvider);
+ await backgroundSync.syncLinkedAlbum();
+ await backgroundSync.syncRemote();
} catch (_) {
} finally {
Future.delayed(const Duration(seconds: 1), () {
@@ -225,7 +226,7 @@ class _BackupOnlyWhenChargingButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final fgService = ref.read(backgroundWorkerFgServiceProvider);
+ final fgService = ref.watch(backgroundWorkerFgServiceProvider);
return _BackupSwitchTile(
metadataKey: SettingsKey.backupRequireCharging,
selector: (c) => c.backup.requireCharging,
diff --git a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart
index 58a044cea4..097f72e1e1 100644
--- a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart
+++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart
@@ -163,7 +163,7 @@ class SyncStatusAndActions extends HookConsumerWidget {
leading: const Icon(Icons.cloud_circle_rounded),
subtitle: "tap_to_run_job".t(context: context),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).cloudIdSyncStatus),
- onTap: ref.read(backgroundSyncProvider).syncCloudIds,
+ onTap: ref.watch(backgroundSyncProvider).syncCloudIds,
),
SettingListTile(
title: "hash_asset".t(context: context),
diff --git a/mobile/lib/widgets/settings/free_up_space_settings.dart b/mobile/lib/widgets/settings/free_up_space_settings.dart
index 72e85ac821..8d4785d664 100644
--- a/mobile/lib/widgets/settings/free_up_space_settings.dart
+++ b/mobile/lib/widgets/settings/free_up_space_settings.dart
@@ -39,11 +39,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState {
}
Future _initializeAlbumDefaults() async {
+ final notifier = ref.read(cleanupProvider.notifier);
final albums = await ref.read(localAlbumProvider.future);
final existingAlbumIds = albums.map((a) => a.id).toSet();
final albumsWithNames = albums.map((a) => (a.id, a.name)).toList();
- final notifier = ref.read(cleanupProvider.notifier);
notifier.applyDefaultAlbumSelections(albumsWithNames);
notifier.cleanupStaleAlbumIds(existingAlbumIds);
}
@@ -105,7 +105,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState {
lastDate: DateTime.now(),
);
- if (picked != null) {
+ if (picked != null && context.mounted) {
ref.read(cleanupProvider.notifier).setSelectedDate(picked);
setState(() => _hasScanned = false);
}
@@ -122,6 +122,10 @@ class _FreeUpSpaceSettingsState extends ConsumerState {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
await ref.read(cleanupProvider.notifier).scanAssets();
+ if (!mounted) {
+ return;
+ }
+
final state = ref.read(cleanupProvider);
setState(() {
@@ -146,7 +150,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState {
_DeleteConfirmationDialog(assetCount: state.assetsToDelete.length, cutoffDate: state.selectedDate!),
);
- if (confirmed != true) {
+ if (confirmed != true || !context.mounted) {
return;
}
diff --git a/mobile/lib/widgets/settings/language_settings.dart b/mobile/lib/widgets/settings/language_settings.dart
index b12d1476f5..e8478da453 100644
--- a/mobile/lib/widgets/settings/language_settings.dart
+++ b/mobile/lib/widgets/settings/language_settings.dart
@@ -3,14 +3,13 @@ import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
-class LanguageSettings extends HookConsumerWidget {
+class LanguageSettings extends HookWidget {
const LanguageSettings({super.key});
Future _applyLanguageChange(
@@ -33,7 +32,7 @@ class LanguageSettings extends HookConsumerWidget {
}
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final localeEntries = useMemoized(() => locales.entries.toList(), const []);
final currentLocale = context.locale;
final filteredLocaleEntries = useState>>(localeEntries);
diff --git a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart
index 68278e0e1d..81ffb4b19b 100644
--- a/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart
+++ b/mobile/lib/widgets/settings/networking_settings/external_network_preference.dart
@@ -36,7 +36,7 @@ class ExternalNetworkPreference extends HookConsumerWidget {
entries.value[index] = entries.value[index].copyWith(url: url, status: status);
await saveEndpointList();
- if (status == AuxCheckStatus.valid) {
+ if (status == AuxCheckStatus.valid && context.mounted) {
await ref.read(apiServiceProvider).updateHeaders();
}
}
diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart
index 513648f030..dad827230a 100644
--- a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart
+++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart
@@ -21,7 +21,7 @@ class NetworkingSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentEndpoint = getServerUrl();
- final featureEnabled = useState(ref.read(appConfigProvider).network.autoEndpointSwitching);
+ final featureEnabled = useState(ref.watch(appConfigProvider).network.autoEndpointSwitching);
useValueChanged(featureEnabled.value, (_, __) {
unawaited(ref.read(settingsProvider).write(.networkAutoEndpointSwitching, featureEnabled.value));
});
diff --git a/mobile/lib/widgets/settings/notification_setting.dart b/mobile/lib/widgets/settings/notification_setting.dart
index 8c858a231b..0bab4e051a 100644
--- a/mobile/lib/widgets/settings/notification_setting.dart
+++ b/mobile/lib/widgets/settings/notification_setting.dart
@@ -47,7 +47,7 @@ class NotificationSetting extends HookConsumerWidget {
subtileText: 'notification_permission_list_tile_content'.tr(),
buttonText: 'notification_permission_list_tile_enable_button'.tr(),
onButtonTap: () =>
- ref.watch(notificationPermissionProvider.notifier).requestNotificationPermission().then((permission) {
+ ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((permission) {
if (permission == PermissionStatus.permanentlyDenied) {
showPermissionsDialog();
}
diff --git a/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart b/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart
index bfdb0f065c..c5003a9ced 100644
--- a/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart
+++ b/mobile/lib/widgets/settings/preference_settings/haptic_setting.dart
@@ -1,17 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
-class HapticSetting extends HookConsumerWidget {
+class HapticSetting extends HookWidget {
const HapticSetting({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ Widget build(BuildContext context) {
final hapticFeedbackSetting = useAppSettingsState(AppSettingsEnum.enableHapticFeedback);
final isHapticFeedbackEnabled = useValueNotifier(hapticFeedbackSetting.value);
diff --git a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart
index b1564b4145..7964ec3861 100644
--- a/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart
+++ b/mobile/lib/widgets/settings/preference_settings/primary_color_setting.dart
@@ -16,7 +16,7 @@ class PrimaryColorSetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final themeProvider = ref.read(immichThemeProvider);
+ final themeProvider = ref.watch(immichThemeProvider);
final themeConfig = ref.watch(appConfigProvider.select((config) => config.theme));
const tileSize = 55.0;
diff --git a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart
index ec84d7be01..8281b2262a 100644
--- a/mobile/lib/widgets/settings/preference_settings/theme_setting.dart
+++ b/mobile/lib/widgets/settings/preference_settings/theme_setting.dart
@@ -15,7 +15,7 @@ class ThemeSetting extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final currentTheme = useState(ref.read(appConfigProvider.select((config) => config.theme.mode)));
+ final currentTheme = useState(ref.watch(appConfigProvider.select((config) => config.theme.mode)));
final isDarkTheme = useValueNotifier(currentTheme.value == ThemeMode.dark);
final isSystemTheme = useValueNotifier(currentTheme.value == ThemeMode.system);
final colorfulInterface = useValueNotifier(
diff --git a/mobile/lib/widgets/shared_link/shared_link_item.dart b/mobile/lib/widgets/shared_link/shared_link_item.dart
index d419d6ead0..d98925bdf8 100644
--- a/mobile/lib/widgets/shared_link/shared_link_item.dart
+++ b/mobile/lib/widgets/shared_link/shared_link_item.dart
@@ -179,7 +179,7 @@ class SharedLinkItem extends ConsumerWidget {
),
);
- if (confirmed == true) {
+ if (confirmed == true && context.mounted) {
await ref.read(sharedLinksStateProvider.notifier).deleteLink(sharedLink.id);
return true;
}
diff --git a/mobile/test/presentation/widgets/images/local_image_provider_test.dart b/mobile/test/presentation/widgets/images/local_image_provider_test.dart
new file mode 100644
index 0000000000..5b556f8a41
--- /dev/null
+++ b/mobile/test/presentation/widgets/images/local_image_provider_test.dart
@@ -0,0 +1,141 @@
+import 'package:flutter/painting.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
+import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
+import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
+
+import '../../../unit/factories/local_asset_factory.dart';
+
+class _StubCompleter extends ImageStreamCompleter {}
+
+void main() {
+ late ImageCache cache;
+ late int loads;
+
+ ImageStreamCompleter load() {
+ loads++;
+ return _StubCompleter();
+ }
+
+ setUp(() {
+ cache = ImageCache();
+ loads = 0;
+ });
+
+ group('LocalFullImageProvider.previewTargetSize', () {
+ const box = Size(1179, 2556);
+ const cases = <(String, Size, int?, int?, bool, Size)>[
+ ('normal', box, 4032, 3024, true, box),
+ ('missing dimensions', box, null, null, true, box),
+ ('invalid dimensions', box, 1000, 0, true, box),
+ ('long final preview', box, 1000, 30000, true, Size(16384 / 30, 16384)),
+ ('long first preview', box, 30000, 1000, false, Size(2556, 2556 / 30)),
+ ('ultra-thin preview', box, 10, 50000, false, Size(1, 2556)),
+ ('small source', box, 50, 2000, true, Size(50, 2000)),
+ ('at limit', Size(16384, 100), 1000, 1000, true, Size(16384, 100)),
+ ('over limit', Size(16385, 100), 1000, 1000, true, Size(1000, 1000)),
+ ];
+
+ for (final (name, box, width, height, previewIsFinal, expected) in cases) {
+ test(name, () {
+ final actual = LocalFullImageProvider.previewTargetSize(
+ box.width,
+ box.height,
+ width,
+ height,
+ previewIsFinal: previewIsFinal,
+ );
+ expect(actual.width, closeTo(expected.width, 1e-6));
+ expect(actual.height, closeTo(expected.height, 1e-6));
+ });
+ }
+ });
+
+ group('LocalFullImageProvider equality', () {
+ LocalFullImageProvider make({int? width, int? height}) => LocalFullImageProvider(
+ id: 'a',
+ assetType: AssetType.image,
+ size: const Size(100, 200),
+ isAnimated: false,
+ width: width,
+ height: height,
+ );
+
+ test('uses dimensions in the cache key', () {
+ final a = make(width: 100, height: 200);
+ final b = make(width: 100, height: 200);
+ expect(a, b);
+ expect(a.hashCode, b.hashCode);
+ expect(a == make(width: 200, height: 100), isFalse);
+ });
+ });
+
+ group('LocalThumbProvider caching', () {
+ test('editing on device re-renders the thumbnail', () {
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load);
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'after'), load);
+
+ expect(loads, 2);
+ });
+
+ test('an unchanged thumbnail still comes from the cache', () {
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'same'), load);
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'same'), load);
+
+ expect(loads, 1);
+ });
+
+ // The rehash clears the checksum before writing the new one, so the tile has to
+ // follow that step too or it waits for the hash to land before showing the edit.
+ test('re-renders while the checksum is still being recomputed', () {
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image, checksum: 'before'), load);
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load);
+
+ expect(loads, 2);
+ });
+
+ test('stays cached while the checksum is missing', () {
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load);
+ cache.putIfAbsent(LocalThumbProvider(id: 'asset-1', assetType: AssetType.image), load);
+
+ expect(loads, 1);
+ });
+ });
+
+ group('factories', () {
+ test('thumbnails are keyed by the asset checksum', () {
+ final asset = LocalAssetFactory.create().copyWith(checksum: 'abc');
+
+ final provider = getThumbnailImageProvider(asset)! as LocalThumbProvider;
+
+ expect(provider.checksum, 'abc');
+ });
+ });
+
+ group('LocalFullImageProvider caching', () {
+ test('editing on device re-renders the full image', () {
+ cache.putIfAbsent(
+ LocalFullImageProvider(
+ id: 'asset-1',
+ assetType: AssetType.image,
+ size: const Size(100, 100),
+ isAnimated: false,
+ checksum: 'before',
+ ),
+ load,
+ );
+ cache.putIfAbsent(
+ LocalFullImageProvider(
+ id: 'asset-1',
+ assetType: AssetType.image,
+ size: const Size(100, 100),
+ isAnimated: false,
+ checksum: 'after',
+ ),
+ load,
+ );
+
+ expect(loads, 2);
+ });
+ });
+}
diff --git a/mobile/test/providers/app_life_cycle_provider_test.dart b/mobile/test/providers/app_life_cycle_provider_test.dart
new file mode 100644
index 0000000000..d4489a58ba
--- /dev/null
+++ b/mobile/test/providers/app_life_cycle_provider_test.dart
@@ -0,0 +1,180 @@
+import 'dart:async';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/domain/models/config/app_config.dart';
+import 'package:immich_mobile/domain/models/log.model.dart';
+import 'package:immich_mobile/domain/services/log.service.dart';
+import 'package:immich_mobile/models/auth/auth_state.model.dart';
+import 'package:immich_mobile/models/server_info/server_version.model.dart';
+import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
+import 'package:immich_mobile/providers/auth.provider.dart';
+import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
+import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
+import 'package:immich_mobile/providers/server_info.provider.dart';
+import 'package:immich_mobile/providers/websocket.provider.dart';
+import 'package:immich_mobile/utils/upload_speed_calculator.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../infrastructure/repository.mock.dart';
+import '../service.mocks.dart';
+
+class FakeLogMessage extends Fake implements LogMessage {}
+
+class TestAuthNotifier extends AuthNotifier {
+ TestAuthNotifier(Ref ref)
+ : super(
+ MockAuthService(),
+ MockApiService(),
+ MockUserService(),
+ MockSecureStorageService(),
+ MockWidgetService(),
+ ref,
+ ) {
+ state = const AuthState(
+ deviceId: 'device-1',
+ userId: 'user-1',
+ userEmail: 'user@example.com',
+ name: 'User',
+ profileImagePath: '',
+ isAdmin: false,
+ isAuthenticated: true,
+ );
+ }
+
+ @override
+ Future setOpenApiServiceEndpoint() async => 'http://test-server.com';
+}
+
+class TestWebsocketNotifier extends WebsocketNotifier {
+ TestWebsocketNotifier(super.ref);
+
+ int connectCount = 0;
+ int disconnectCount = 0;
+ final connectCalled = Completer();
+
+ @override
+ void connect() {
+ connectCount++;
+ if (!connectCalled.isCompleted) {
+ connectCalled.complete();
+ }
+ throw StateError('unexpected websocket connection');
+ }
+
+ @override
+ void disconnect() => disconnectCount++;
+}
+
+class TestDriftBackupNotifier extends DriftBackupNotifier {
+ TestDriftBackupNotifier() : super(MockForegroundUploadService(), MockBackgroundUploadService(), UploadSpeedManager());
+}
+
+void main() {
+ late LogService logService;
+ late Completer serverVersion;
+ late MockServerInfoService serverInfoService;
+ late MockBackgroundWorkerLockService lockService;
+ late ProviderContainer container;
+ late TestWebsocketNotifier websocket;
+ late AppLifeCycleNotifier lifeCycle;
+ late int serverVersionCount;
+
+ setUpAll(() async {
+ final logRepository = MockLogRepository();
+ final settingsRepository = MockSettingsRepository();
+ registerFallbackValue(FakeLogMessage());
+ when(() => logRepository.truncate(limit: any(named: 'limit'))).thenAnswer((_) async {});
+ when(() => logRepository.insert(any())).thenAnswer((_) async => true);
+ when(() => settingsRepository.appConfig).thenReturn(const AppConfig(logLevel: LogLevel.info));
+ logService = await LogService.init(
+ logRepository: logRepository,
+ settingsRepository: settingsRepository,
+ shouldBuffer: false,
+ );
+ });
+
+ tearDownAll(() => logService.dispose());
+
+ setUp(() {
+ serverVersion = Completer();
+ serverInfoService = MockServerInfoService();
+ lockService = MockBackgroundWorkerLockService();
+ serverVersionCount = 0;
+
+ when(() => serverInfoService.getServerVersion()).thenAnswer((_) {
+ serverVersionCount++;
+ return serverVersionCount == 1 ? serverVersion.future : Future.value();
+ });
+ when(() => lockService.lock()).thenAnswer((_) async {});
+ when(() => lockService.unlock()).thenAnswer((_) async {});
+
+ container = ProviderContainer(
+ overrides: [
+ authProvider.overrideWith(TestAuthNotifier.new),
+ serverInfoProvider.overrideWith((_) => ServerInfoNotifier(serverInfoService)),
+ websocketProvider.overrideWith((ref) {
+ return websocket = TestWebsocketNotifier(ref);
+ }),
+ driftBackupProvider.overrideWith((_) => TestDriftBackupNotifier()),
+ backgroundWorkerLockServiceProvider.overrideWithValue(lockService),
+ ],
+ );
+ lifeCycle = container.read(appStateProvider.notifier);
+ });
+
+ tearDown(() => container.dispose());
+
+ Future startResume() async {
+ await lifeCycle.handleAppPause();
+ unawaited(lifeCycle.handleAppResume());
+ await untilCalled(() => serverInfoService.getServerVersion());
+ }
+
+ Future releaseResume() async {
+ serverVersion.complete();
+ await Future.delayed(Duration.zero);
+ }
+
+ test('pause during resume does not reconnect websocket', () async {
+ await startResume();
+ await lifeCycle.handleAppPause();
+ await releaseResume();
+
+ expect(lifeCycle.getAppState(), AppLifeCycleEnum.paused);
+ expect(serverVersionCount, 1);
+ expect(websocket.disconnectCount, 2);
+ expect(websocket.connectCount, 0);
+ });
+
+ test('inactive resume retries when the app resumes again', () async {
+ await startResume();
+ lifeCycle.handleAppInactivity();
+ await releaseResume();
+
+ unawaited(lifeCycle.handleAppResume());
+ await websocket.connectCalled.future;
+
+ expect(lifeCycle.getAppState(), AppLifeCycleEnum.resumed);
+ expect(serverVersionCount, 2);
+ expect(websocket.disconnectCount, 1);
+ expect(websocket.connectCount, 1);
+ });
+
+ test('pause after an inactive abort resumes once', () async {
+ await startResume();
+ lifeCycle.handleAppInactivity();
+ await releaseResume();
+ await lifeCycle.handleAppPause();
+
+ unawaited(lifeCycle.handleAppResume());
+ await websocket.connectCalled.future;
+ unawaited(lifeCycle.handleAppResume());
+ await Future.delayed(Duration.zero);
+
+ expect(lifeCycle.getAppState(), AppLifeCycleEnum.resumed);
+ expect(serverVersionCount, 2);
+ expect(websocket.disconnectCount, 2);
+ expect(websocket.connectCount, 1);
+ });
+}
diff --git a/mobile/test/providers/backup/drift_backup_provider_test.dart b/mobile/test/providers/backup/drift_backup_provider_test.dart
new file mode 100644
index 0000000000..205f564cd9
--- /dev/null
+++ b/mobile/test/providers/backup/drift_backup_provider_test.dart
@@ -0,0 +1,109 @@
+import 'dart:async';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
+import 'package:immich_mobile/services/background_upload.service.dart';
+import 'package:immich_mobile/services/foreground_upload.service.dart';
+import 'package:immich_mobile/utils/upload_speed_calculator.dart';
+import 'package:mocktail/mocktail.dart';
+
+class MockForegroundUploadService extends Mock implements ForegroundUploadService {}
+
+class MockBackgroundUploadService extends Mock implements BackgroundUploadService {}
+
+void main() {
+ late MockForegroundUploadService foregroundUploadService;
+ late MockBackgroundUploadService backgroundUploadService;
+ late DriftBackupNotifier notifier;
+
+ setUpAll(() {
+ registerFallbackValue(Completer());
+ registerFallbackValue(const UploadCallbacks());
+ });
+
+ setUp(() {
+ foregroundUploadService = MockForegroundUploadService();
+ backgroundUploadService = MockBackgroundUploadService();
+ notifier = DriftBackupNotifier(foregroundUploadService, backgroundUploadService, UploadSpeedManager());
+ addTearDown(() {
+ if (notifier.mounted) {
+ notifier.dispose();
+ }
+ });
+ });
+
+ void mockCounts({required int total, required int remainder, int processing = 0}) {
+ when(
+ () => foregroundUploadService.getBackupCounts('user-1'),
+ ).thenAnswer((_) async => (total: total, remainder: remainder, processing: processing));
+ }
+
+ // Drives a backup run so we can grab the onSuccess callback the notifier wires up.
+ Future startAndCaptureOnSuccess() async {
+ void Function(String, String)? onSuccess;
+ when(() => foregroundUploadService.uploadCandidates(any(), any(), callbacks: any(named: 'callbacks'))).thenAnswer((
+ invocation,
+ ) async {
+ onSuccess = (invocation.namedArguments[#callbacks] as UploadCallbacks).onSuccess;
+ });
+ await notifier.startForegroundBackup('user-1');
+ return onSuccess!;
+ }
+
+ group('foreground backup counts', () {
+ test('successes move one asset from remainder to backup', () async {
+ mockCounts(total: 25, remainder: 25);
+ final onSuccess = await startAndCaptureOnSuccess();
+
+ for (var i = 0; i < 10; i++) {
+ onSuccess('asset-$i', 'remote-$i');
+ }
+
+ expect(notifier.state.remainderCount, 15);
+ expect(notifier.state.backupCount, 10);
+ expect(notifier.state.backupCount + notifier.state.remainderCount, notifier.state.totalCount);
+ });
+
+ test('a duplicate success after pause and resume cannot go below zero', () async {
+ // #26215: app pauses mid-backup, sync has not recorded the upload yet, so the
+ // resumed run re-uploads the same asset and the server answers 200 duplicate.
+ // The start of each run re-baselines the counters from the DB, so the duplicate
+ // success is counted against a baseline that includes the asset again.
+ mockCounts(total: 1, remainder: 1);
+
+ final firstRun = await startAndCaptureOnSuccess();
+ expect(notifier.state.remainderCount, 1);
+ firstRun('asset-1', 'remote-1');
+ expect(notifier.state.remainderCount, 0);
+
+ notifier.stopForegroundBackup(reason: "test");
+
+ final resumedRun = await startAndCaptureOnSuccess();
+ expect(notifier.state.remainderCount, 1);
+ verify(() => foregroundUploadService.getBackupCounts('user-1')).called(2);
+
+ resumedRun('asset-1', 'remote-1');
+ expect(notifier.state.remainderCount, 0);
+ expect(notifier.state.backupCount, 1);
+ });
+
+ test('a drifted counter state heals at run start', () async {
+ mockCounts(total: 91, remainder: 7);
+ notifier.state = notifier.state.copyWith(totalCount: 91, backupCount: 103, remainderCount: -12);
+
+ await startAndCaptureOnSuccess();
+
+ expect(notifier.state.totalCount, 91);
+ expect(notifier.state.remainderCount, 7);
+ expect(notifier.state.backupCount, 84);
+ });
+
+ test('a late success after dispose does not throw', () async {
+ mockCounts(total: 2, remainder: 2);
+ final onSuccess = await startAndCaptureOnSuccess();
+ notifier.dispose();
+
+ onSuccess('asset-1', 'remote-1');
+ });
+ });
+}
diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart
index 785567de56..ecf9c771bc 100644
--- a/mobile/test/service.mocks.dart
+++ b/mobile/test/service.mocks.dart
@@ -1,4 +1,5 @@
import 'package:immich_mobile/domain/services/asset.service.dart';
+import 'package:immich_mobile/domain/services/background_worker.service.dart';
import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
@@ -7,12 +8,16 @@ import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
+import 'package:immich_mobile/services/auth.service.dart';
+import 'package:immich_mobile/services/background_upload.service.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/gcast.service.dart';
import 'package:immich_mobile/services/network.service.dart';
+import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
import 'package:immich_mobile/services/toast.service.dart';
+import 'package:immich_mobile/services/widget.service.dart';
import 'package:mocktail/mocktail.dart';
class MockApiService extends Mock implements ApiService {}
@@ -46,3 +51,13 @@ class MockCleanupService extends Mock implements CleanupService {}
class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
class MockToastService extends Mock implements ToastService {}
+
+class MockAuthService extends Mock implements AuthService {}
+
+class MockSecureStorageService extends Mock implements SecureStorageService {}
+
+class MockWidgetService extends Mock implements WidgetService {}
+
+class MockBackgroundUploadService extends Mock implements BackgroundUploadService {}
+
+class MockBackgroundWorkerLockService extends Mock implements BackgroundWorkerLockService {}
diff --git a/server/Dockerfile b/server/Dockerfile
index df9d8ad7ba..802877421d 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -56,7 +56,7 @@ FROM builder AS plugins
ARG TARGETPLATFORM
-COPY --from=ghcr.io/jdx/mise:2026.7.15@sha256:e62097049bfc980de5d9a25fbe16431e24c1e7713b3f77b18c04f81c25239953 /usr/local/bin/mise /usr/local/bin/mise
+COPY --from=ghcr.io/jdx/mise:2026.8.1@sha256:b2297770273f71e685b8056e3b07bfda4ffc35f0fb62e0339b3cdc1e5766e2fe /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /app
COPY ./mise.toml ./mise.toml
diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev
index 178f4440df..23a565b4bb 100644
--- a/server/Dockerfile.dev
+++ b/server/Dockerfile.dev
@@ -2,7 +2,7 @@
FROM ghcr.io/immich-app/base-server-dev:202607211135@sha256:83c9ff3f7390111596a2dcd24a746c3f8618d58259dbc7aae36618d153462f18 AS dev
-COPY --from=ghcr.io/jdx/mise:2026.7.15@sha256:e62097049bfc980de5d9a25fbe16431e24c1e7713b3f77b18c04f81c25239953 /usr/local/bin/mise /usr/local/bin/mise
+COPY --from=ghcr.io/jdx/mise:2026.8.1@sha256:b2297770273f71e685b8056e3b07bfda4ffc35f0fb62e0339b3cdc1e5766e2fe /usr/local/bin/mise /usr/local/bin/mise
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \