Merge branch 'main' of https://github.com/immich-app/immich into chore/freezed-moderate-difficulty

This commit is contained in:
Adam Gastineau 2026-08-05 12:13:02 -07:00
commit 425c09a64f
127 changed files with 854 additions and 197 deletions

View file

@ -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

View file

@ -152,12 +152,6 @@
This is used by the Flutter tool to
generate GeneratedPluginRegistrant.java -->
<meta-data android:name="flutterEmbedding" android:value="2" />
<!-- Disables default WorkManager initialization to use our custom initialization -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<!-- Widgets -->
<receiver

View file

@ -3,17 +3,12 @@ package app.alextran.immich
import android.app.Application
import android.os.Handler
import android.os.Looper
import androidx.work.Configuration
import androidx.work.WorkManager
import app.alextran.immich.background.BackgroundEngineLock
import app.alextran.immich.background.BackgroundWorkerApiImpl
class ImmichApp : Application() {
override fun onCreate() {
super.onCreate()
val config = Configuration.Builder().build()
WorkManager.initialize(this, config)
// always start BackupWorker after WorkManager init; this fixes the following bug:
// After the process is killed (by user or system), the first trigger (taking a new picture) is lost.
// Thus, the BackupWorker is not started. If the system kills the process after each initialization
// (because of low memory etc.), the backup is never performed.

View file

@ -20,6 +20,7 @@ class LocalImageApiImpl: LocalImageApi {
requestOptions.version = .current
return requestOptions
}()
private static let maxPixelSize: CGFloat = 16384
private static let registry = RequestRegistry<ImageRequest>()
@ -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)
}

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'album.model.freezed.dart';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'local_album.model.freezed.dart';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'asset_face.model.freezed.dart';

View file

@ -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';

View file

@ -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';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'backup_config.freezed.dart';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/utils/semver.dart';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'image_config.freezed.dart';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/constants/enums.dart';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:immich_mobile/constants/enums.dart';

View file

@ -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';

View file

@ -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';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'viewer_config.freezed.dart';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'exif.model.freezed.dart';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'ocr.model.freezed.dart';

View file

@ -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 {

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'stack.model.freezed.dart';

View file

@ -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';

View file

@ -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';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_state.model.freezed.dart';

View file

@ -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';

View file

@ -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';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:openapi/api.dart';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:openapi/api.dart';

View file

@ -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';

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:openapi/api.dart';

View file

@ -81,8 +81,8 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
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<void> startBackup() async {
final currentUser = Store.tryGet(StoreKey.currentUser);
@ -143,7 +143,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
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));

View file

@ -102,6 +102,9 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
onPopInvokedWithResult: (didPop, _) async {
if (!didPop) {
await _handlePagePopped();
if (!mounted) {
return;
}
final user = ref.read(currentUserProvider);
if (user == null) {
@ -110,6 +113,10 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
final isBackupEnabled = SettingsRepository.instance.appConfig.backup.enabled;
await ref.read(driftBackupProvider.notifier).getBackupStatus(user.id);
if (!mounted) {
return;
}
final currentTotalAssetCount = ref.read(driftBackupProvider.select((p) => p.totalCount));
final totalChanged = currentTotalAssetCount != _initialTotalAssetCount;
final backupNotifier = ref.read(driftBackupProvider.notifier);
@ -119,7 +126,7 @@ class _DriftBackupAlbumSelectionPageState extends ConsumerState<DriftBackupAlbum
// Waits for hashing to be cancelled before starting a new one
unawaited(nativeSync.cancelHashing().whenComplete(() => backgroundSync.hashAssets()));
if (isBackupEnabled) {
backupNotifier.stopForegroundBackup();
backupNotifier.stopForegroundBackup(reason: "backup albums updated");
unawaited(
backgroundSync.syncRemote().then((success) {
if (success) {

View file

@ -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) {

View file

@ -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]));

View file

@ -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),

View file

@ -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(

View file

@ -21,7 +21,7 @@ class HeaderSettingsPage extends HookConsumerWidget {
final headers = useState<List<SettingsHeader>>([]);
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();
}
}

View file

@ -353,11 +353,11 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
},
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()]));
},
),

View file

@ -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());
}

View file

@ -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<void> getAppInfo() async {

View file

@ -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<LatLng>(initialLatLng);
final controller = useRef<MapLibreMapController?>(null);
final marker = useRef<Symbol?>(null);

View file

@ -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(

View file

@ -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();

View file

@ -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);

View file

@ -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<BaseAsset> lockedSelectionAssets;
const DriftAssetSelectionTimelinePage({super.key, this.lockedSelectionAssets = const {}});
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
multiSelectProvider.overrideWith(

View file

@ -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: [

View file

@ -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: [

View file

@ -76,6 +76,10 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
}
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<RemoteAlbumPage> {
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<bool>(
future: ref
.read(remoteAlbumServiceProvider)
.watch(remoteAlbumServiceProvider)
.getUserRole(album.id, user?.id ?? '')
.then((role) => role == AlbumUserRole.editor),
builder: (context, snapshot) {

View file

@ -300,7 +300,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> 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<DriftSlideshowPage> 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) {

View file

@ -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) {

View file

@ -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

View file

@ -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';

View file

@ -70,6 +70,10 @@ class _ProfilePictureCropPageState extends ConsumerState<ProfilePictureCropPage>
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');

View file

@ -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;

View file

@ -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;

View file

@ -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,
);

View file

@ -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<void> onCreateAlbum() async {
final albumName = await showDialog<String?>(context: context, builder: (context) => const NewAlbumNameModal());
if (albumName == null) {
if (albumName == null || !context.mounted) {
return;
}

View file

@ -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(

View file

@ -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<String?>(
future: assetMediaRepository.getOriginalFilename(asset.id),
builder: (context, snapshot) {

View file

@ -401,7 +401,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_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;

View file

@ -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();
}

View file

@ -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;

View file

@ -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,
),
),
),

View file

@ -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),

View file

@ -150,6 +150,10 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> 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;

View file

@ -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);

View file

@ -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,

View file

@ -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(

View file

@ -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;

View file

@ -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<LocalThumbProvider>
with CancellableImageProviderMixin<LocalThumbProvider> {
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<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
@ -44,13 +52,13 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
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<LocalFullImageProvider>
@ -59,8 +67,38 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final Size size;
final AssetType assetType;
final bool isAnimated;
final int? width;
final int? height;
final String? checksum;
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated});
LocalFullImageProvider({
required this.id,
required this.assetType,
required this.size,
required this.isAnimated,
this.width,
this.height,
this.checksum,
});
Size _previewTarget(double dpr, bool previewIsFinal) =>
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<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@ -73,7 +111,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode),
scale: 1.0,
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
@ -86,7 +124,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType, checksum: key.checksum)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
@ -108,7 +146,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
var request = this.request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
size: _previewTarget(devicePixelRatio, !loadOriginal),
assetType: key.assetType,
);
yield* loadRequest(request, decode, isFinal: !loadOriginal);
@ -136,7 +174,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final previewRequest = request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
size: _previewTarget(devicePixelRatio, false),
assetType: key.assetType,
);
yield* loadRequest(previewRequest, decode, isFinal: false);
@ -163,11 +201,16 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return true;
}
if (other is LocalFullImageProvider) {
return id == other.id && size == other.size && isAnimated == other.isAnimated;
return id == other.id &&
size == other.size &&
isAnimated == other.isAnimated &&
width == other.width &&
height == other.height &&
checksum == other.checksum;
}
return false;
}
@override
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
int get hashCode => Object.hash(id, size, isAnimated, width, height, checksum);
}

View file

@ -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(

View file

@ -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(

View file

@ -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<Shadow>? iconShadows;
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final theme = context.themeData;
final menuChildren = <Widget>[];

View file

@ -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(

View file

@ -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();
}

View file

@ -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';

View file

@ -81,6 +81,10 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
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<AppLifeCycleEnum> {
await _ref.read(galleryPermissionNotifier.notifier).getGalleryPermissionStatus();
}
Future<void> _safeRun(Future<void> action, String debugName) async {
Future<void> _safeRun(Future<void> 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<AppLifeCycleEnum> {
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<AppLifeCycleEnum> {
// _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<AppLifeCycleEnum> {
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<AppLifeCycleEnum> {
Future<void> _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();
}

View file

@ -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';

View file

@ -9,6 +9,7 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((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<BackgroundSyncManager>((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);

View file

@ -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<DriftBackupState> {
state = state.copyWith(isSyncing: isSyncing);
}
Future<void> startForegroundBackup(String userId) {
Future<void> 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<void>();
// A pause during the recount below nulls _cancelToken, so the run keeps its own reference.
final cancelToken = Completer<void>();
_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<DriftBackupState> {
);
}
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<DriftBackupState> {
}
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);

View file

@ -1,3 +1,5 @@
// ignore_for_file: use-ref-and-state-synchronously
import 'dart:async';
import 'package:auto_route/auto_route.dart';

View file

@ -1,3 +1,5 @@
// ignore_for_file: use-ref-and-state-synchronously
import 'dart:async';
import 'package:freezed_annotation/freezed_annotation.dart';

View file

@ -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';

View file

@ -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';

View file

@ -1,3 +1,5 @@
// ignore_for_file: use-ref-and-state-synchronously
import 'dart:async';
import 'dart:io';

View file

@ -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;
});

View file

@ -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';

View file

@ -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';

View file

@ -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<WebsocketState> {
@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<WebsocketState> {
.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<WebsocketState> {
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<WebsocketState> {
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) {

View file

@ -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 {

View file

@ -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),

View file

@ -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(

View file

@ -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,
);

View file

@ -28,7 +28,7 @@ class CastDialog extends ConsumerWidget {
width: 250,
height: 250,
child: FutureBuilder<List<(String, CastDestinationType, dynamic)>>(
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()]));

View file

@ -99,7 +99,7 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
});
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(

View file

@ -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));

View file

@ -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) {

View file

@ -45,12 +45,12 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
Future<void> 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(

View file

@ -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(

View file

@ -17,13 +17,13 @@ Future<(Set<String>, Set<String>)?> showTagPickerModal({required BuildContext co
);
}
class _TagPickerModal extends HookConsumerWidget {
class _TagPickerModal extends HookWidget {
final Set<String>? initialSelection;
const _TagPickerModal({this.initialSelection});
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final selectedTagIds = useState<Set<String>>(initialSelection ?? {});
final newTagValues = useState<Set<String>>({});

Some files were not shown because too many files have changed in this diff Show more