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 && \