show notifications on background backup errors (#496)

* show notifications on background backup errors

* settings page to configure (background backup error) notifications

* persist time since failed background backup

* fix darkmode slider color
This commit is contained in:
Fynn Petersen-Frey 2022-08-21 18:29:24 +02:00 committed by GitHub
parent c436c57cc9
commit 3125d04f32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 221 additions and 44 deletions

View File

@ -138,6 +138,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
immediate = true, immediate = true,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true), requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false), requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
initialDelayInMs = ONE_MINUTE,
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1) retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
} }
engine?.destroy() engine?.destroy()
@ -169,6 +170,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
immediate = true, immediate = true,
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true), requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false), requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
initialDelayInMs = ONE_MINUTE,
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1) retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
} }
} }
@ -186,22 +188,27 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
val args = call.arguments<ArrayList<*>>()!! val args = call.arguments<ArrayList<*>>()!!
val title = args.get(0) as String val title = args.get(0) as String
val content = args.get(1) as String val content = args.get(1) as String
showError(title, content) val individualTag = args.get(2) as String?
showError(title, content, individualTag)
} }
"clearErrorNotifications" -> clearErrorNotifications()
else -> r.notImplemented() else -> r.notImplemented()
} }
} }
private fun showError(title: String, content: String) { private fun showError(title: String, content: String, individualTag: String?) {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID) val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
.setContentTitle(title) .setContentTitle(title)
.setTicker(title) .setTicker(title)
.setContentText(content) .setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setAutoCancel(true) .setOnlyAlertOnce(true)
.build() .build()
val notificationId = SystemClock.uptimeMillis() as Int notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
notificationManager.notify(notificationId, notification) }
private fun clearErrorNotifications() {
notificationManager.cancel(NOTIFICATION_ERROR_ID)
} }
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo { private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
@ -212,14 +219,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true) .setOngoing(true)
.build() .build()
return ForegroundInfo(1, notification) return ForegroundInfo(NOTIFICATION_ID, notification)
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun createChannel() { private fun createChannel() {
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW) val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
notificationManager.createNotificationChannel(foreground) notificationManager.createNotificationChannel(foreground)
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH) val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(error) notificationManager.createNotificationChannel(error)
} }
@ -236,6 +243,9 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService" private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError" private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
private const val NOTIFICATION_DEFAULT_TITLE = "Immich" private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_ERROR_ID = 2
private const val ONE_MINUTE: Long = 60000
/** /**
* Enqueues the `BackupWorker` to run when all constraints are met. * Enqueues the `BackupWorker` to run when all constraints are met.
@ -262,6 +272,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
keepExisting: Boolean = false, keepExisting: Boolean = false,
requireUnmeteredNetwork: Boolean = false, requireUnmeteredNetwork: Boolean = false,
requireCharging: Boolean = false, requireCharging: Boolean = false,
initialDelayInMs: Long = 0,
retries: Int = 0) { retries: Int = 0) {
if (!isEnabled(context)) { if (!isEnabled(context)) {
return return
@ -287,9 +298,10 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java) val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java)
.setConstraints(constraints.build()) .setConstraints(constraints.build())
.setInputData(inputData) .setInputData(inputData)
.setInitialDelay(initialDelayInMs, TimeUnit.MILLISECONDS)
.setBackoffCriteria( .setBackoffCriteria(
BackoffPolicy.EXPONENTIAL, BackoffPolicy.EXPONENTIAL,
OneTimeWorkRequest.MIN_BACKOFF_MILLIS, ONE_MINUTE,
TimeUnit.MILLISECONDS) TimeUnit.MILLISECONDS)
.build() .build()
val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE) val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE)

View File

@ -21,6 +21,9 @@
"backup_background_service_upload_failure_notification": "Failed to upload {}", "backup_background_service_upload_failure_notification": "Failed to upload {}",
"backup_background_service_in_progress_notification": "Backing up your assets…", "backup_background_service_in_progress_notification": "Backing up your assets…",
"backup_background_service_current_upload_notification": "Uploading {}", "backup_background_service_current_upload_notification": "Uploading {}",
"backup_background_service_error_title": "Backup error",
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
"backup_controller_page_albums": "Backup Albums", "backup_controller_page_albums": "Backup Albums",
"backup_controller_page_backup": "Backup", "backup_controller_page_backup": "Backup",
"backup_controller_page_backup_selected": "Selected: ", "backup_controller_page_backup_selected": "Selected: ",
@ -139,5 +142,12 @@
"asset_list_settings_title": "Photo Grid", "asset_list_settings_title": "Photo Grid",
"asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_subtitle": "Photo grid layout settings",
"theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})" "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})",
"setting_notifications_title": "Notifications",
"setting_notifications_subtitle": "Adjust your notification preferences",
"setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}",
"setting_notifications_notify_immediately": "immediately",
"setting_notifications_notify_minutes": "{} minutes",
"setting_notifications_notify_hours": "{} hours",
"setting_notifications_notify_never": "never"
} }

View File

@ -19,3 +19,7 @@ const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; // Key 1
// User Setting Info // User Setting Info
const String userSettingInfoBox = "immichUserSettingInfoBox"; const String userSettingInfoBox = "immichUserSettingInfoBox";
// Background backup Info
const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box
const String backupFailedSince = "immichBackupFailedSince"; // Key 1

View File

@ -4,6 +4,7 @@ import 'dart:io';
import 'dart:isolate'; import 'dart:isolate';
import 'dart:ui' show IsolateNameServer, PluginUtilities; import 'dart:ui' show IsolateNameServer, PluginUtilities;
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -16,6 +17,7 @@ import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dar
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
@ -39,6 +41,7 @@ class BackgroundService {
bool _hasLock = false; bool _hasLock = false;
SendPort? _waitingIsolate; SendPort? _waitingIsolate;
ReceivePort? _rp; ReceivePort? _rp;
bool _errorGracePeriodExceeded = true;
bool get isForegroundInitialized { bool get isForegroundInitialized {
return _isForegroundInitialized; return _isForegroundInitialized;
@ -140,8 +143,8 @@ class BackgroundService {
} }
/// Updates the notification shown by the background service /// Updates the notification shown by the background service
Future<bool> updateNotification({ Future<bool> _updateNotification({
String title = "Immich", required String title,
String? content, String? content,
}) async { }) async {
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
@ -153,28 +156,44 @@ class BackgroundService {
.invokeMethod('updateNotification', [title, content]); .invokeMethod('updateNotification', [title, content]);
} }
} catch (error) { } catch (error) {
debugPrint("[updateNotification] failed to communicate with plugin"); debugPrint("[_updateNotification] failed to communicate with plugin");
} }
return Future.value(false); return Future.value(false);
} }
/// Shows a new priority notification /// Shows a new priority notification
Future<bool> showErrorNotification( Future<bool> _showErrorNotification({
String title, required String title,
String content, String? content,
) async { String? individualTag,
}) async {
if (!Platform.isAndroid) {
return true;
}
try {
if (_isBackgroundInitialized && _errorGracePeriodExceeded) {
return await _backgroundChannel
.invokeMethod('showError', [title, content, individualTag]);
}
} catch (error) {
debugPrint("[_showErrorNotification] failed to communicate with plugin");
}
return false;
}
Future<bool> _clearErrorNotifications() async {
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
return true; return true;
} }
try { try {
if (_isBackgroundInitialized) { if (_isBackgroundInitialized) {
return await _backgroundChannel return await _backgroundChannel.invokeMethod('clearErrorNotifications');
.invokeMethod('showError', [title, content]);
} }
} catch (error) { } catch (error) {
debugPrint("[showErrorNotification] failed to communicate with plugin"); debugPrint(
"[_clearErrorNotifications] failed to communicate with plugin");
} }
return Future.value(false); return false;
} }
/// await to ensure this thread (foreground or background) has exclusive access /// await to ensure this thread (foreground or background) has exclusive access
@ -278,7 +297,15 @@ class BackgroundService {
return false; return false;
} }
await translationsLoaded; await translationsLoaded;
return await _onAssetsChanged(); final bool ok = await _onAssetsChanged();
if (ok) {
Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
null) {
Hive.box(backgroundBackupInfoBox)
.put(backupFailedSince, DateTime.now());
}
return ok;
} catch (error) { } catch (error) {
debugPrint(error.toString()); debugPrint(error.toString());
return false; return false;
@ -303,6 +330,8 @@ class BackgroundService {
Hive.registerAdapter(HiveBackupAlbumsAdapter()); Hive.registerAdapter(HiveBackupAlbumsAdapter());
await Hive.openBox(userInfoBox); await Hive.openBox(userInfoBox);
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox); await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
await Hive.openBox(userSettingInfoBox);
await Hive.openBox(backgroundBackupInfoBox);
ApiService apiService = ApiService(); ApiService apiService = ApiService();
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey)); apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
@ -313,23 +342,36 @@ class BackgroundService {
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox); await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey); final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
if (backupAlbumInfo == null) { if (backupAlbumInfo == null) {
_clearErrorNotifications();
return true; return true;
} }
await PhotoManager.setIgnorePermissionCheck(true); await PhotoManager.setIgnorePermissionCheck(true);
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
if (_canceledBySystem) { if (_canceledBySystem) {
return false; return false;
} }
final List<AssetEntity> toUpload = List<AssetEntity> toUpload =
await backupService.getAssetsToBackup(backupAlbumInfo); await backupService.buildUploadCandidates(backupAlbumInfo);
try {
toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
} catch (e) {
_showErrorNotification(
title: "backup_background_service_error_title".tr(),
content: "backup_background_service_connection_failed_message".tr(),
);
return false;
}
if (_canceledBySystem) { if (_canceledBySystem) {
return false; return false;
} }
if (toUpload.isEmpty) { if (toUpload.isEmpty) {
_clearErrorNotifications();
return true; return true;
} }
@ -343,10 +385,16 @@ class BackgroundService {
_onBackupError, _onBackupError,
); );
if (ok) { if (ok) {
_clearErrorNotifications();
await box.put( await box.put(
backupInfoKey, backupInfoKey,
backupAlbumInfo, backupAlbumInfo,
); );
} else {
_showErrorNotification(
title: "backup_background_service_error_title".tr(),
content: "backup_background_service_backup_failed_message".tr(),
);
} }
return ok; return ok;
} }
@ -358,20 +406,48 @@ class BackgroundService {
void _onProgress(int sent, int total) {} void _onProgress(int sent, int total) {}
void _onBackupError(ErrorUploadAsset errorAssetInfo) { void _onBackupError(ErrorUploadAsset errorAssetInfo) {
showErrorNotification( _showErrorNotification(
"backup_background_service_upload_failure_notification" title: "Upload failed",
content: "backup_background_service_upload_failure_notification"
.tr(args: [errorAssetInfo.fileName]), .tr(args: [errorAssetInfo.fileName]),
errorAssetInfo.errorMessage, individualTag: errorAssetInfo.id,
); );
} }
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) { void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
updateNotification( _updateNotification(
title: "backup_background_service_in_progress_notification".tr(), title: "backup_background_service_in_progress_notification".tr(),
content: "backup_background_service_current_upload_notification" content: "backup_background_service_current_upload_notification"
.tr(args: [currentUploadAsset.fileName]), .tr(args: [currentUploadAsset.fileName]),
); );
} }
bool _isErrorGracePeriodExceeded() {
final int value = AppSettingsService()
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
if (value == 0) {
return true;
} else if (value == 5) {
return false;
}
final DateTime? failedSince =
Hive.box(backgroundBackupInfoBox).get(backupFailedSince);
if (failedSince == null) {
return false;
}
final Duration duration = DateTime.now().difference(failedSince);
if (value == 1) {
return duration > const Duration(minutes: 30);
} else if (value == 2) {
return duration > const Duration(hours: 2);
} else if (value == 3) {
return duration > const Duration(hours: 8);
} else if (value == 4) {
return duration > const Duration(hours: 24);
}
assert(false, "Invalid value");
return true;
}
} }
/// entry point called by Kotlin/Java code; needs to be a top-level function /// entry point called by Kotlin/Java code; needs to be a top-level function

View File

@ -41,21 +41,8 @@ class BackupService {
} }
} }
/// Returns all assets to backup from the backup info taking into account the /// Returns all assets newer than the last successful backup per album
/// time of the last successfull backup per album Future<List<AssetEntity>> buildUploadCandidates(
Future<List<AssetEntity>> getAssetsToBackup(
HiveBackupAlbums backupAlbumInfo,
) async {
final List<AssetEntity> candidates =
await _buildUploadCandidates(backupAlbumInfo);
final List<AssetEntity> toUpload = candidates.isEmpty
? []
: await _removeAlreadyUploadedAssets(candidates);
return toUpload;
}
Future<List<AssetEntity>> _buildUploadCandidates(
HiveBackupAlbums backupAlbums, HiveBackupAlbums backupAlbums,
) async { ) async {
final filter = FilterOptionGroup( final filter = FilterOptionGroup(
@ -147,7 +134,8 @@ class BackupService {
return result; return result;
} }
Future<List<AssetEntity>> _removeAlreadyUploadedAssets( /// Returns a new list of assets not yet uploaded
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
List<AssetEntity> candidates, List<AssetEntity> candidates,
) async { ) async {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey); final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);

View File

@ -5,6 +5,8 @@ enum AppSettingsEnum<T> {
threeStageLoading<bool>("threeStageLoading", false), threeStageLoading<bool>("threeStageLoading", false),
themeMode<String>("themeMode", "system"), // "light","dark","system" themeMode<String>("themeMode", "system"), // "light","dark","system"
tilesPerRow<int>("tilesPerRow", 4), tilesPerRow<int>("tilesPerRow", 4),
uploadErrorNotificationGracePeriod<int>(
"uploadErrorNotificationGracePeriod", 2),
storageIndicator<bool>("storageIndicator", true); storageIndicator<bool>("storageIndicator", true);
const AppSettingsEnum(this.hiveKey, this.defaultValue); const AppSettingsEnum(this.hiveKey, this.defaultValue);

View File

@ -56,6 +56,7 @@ class TilesPerRow extends HookConsumerWidget {
max: 6, max: 6,
divisions: 4, divisions: 4,
label: "${itemsValue.value.toInt()}", label: "${itemsValue.value.toInt()}",
activeColor: Theme.of(context).primaryColor,
), ),
], ],
); );

View File

@ -0,0 +1,82 @@
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/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
class NotificationSetting extends HookConsumerWidget {
const NotificationSetting({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final sliderValue = useState(0.0);
useEffect(
() {
sliderValue.value = appSettingService
.getSetting<int>(AppSettingsEnum.uploadErrorNotificationGracePeriod)
.toDouble();
return null;
},
[],
);
final String formattedValue = _formatSliderValue(sliderValue.value);
return ExpansionTile(
textColor: Theme.of(context).primaryColor,
title: const Text(
'setting_notifications_title',
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
subtitle: const Text(
'setting_notifications_subtitle',
style: TextStyle(
fontSize: 13,
),
).tr(),
children: [
ListTile(
isThreeLine: false,
dense: true,
title: const Text(
'setting_notifications_notify_failures_grace_period',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(args: [formattedValue]),
subtitle: Slider(
value: sliderValue.value,
onChanged: (double v) => sliderValue.value = v,
onChangeEnd: (double v) => appSettingService.setSetting(
AppSettingsEnum.uploadErrorNotificationGracePeriod, v.toInt()),
max: 5.0,
divisions: 5,
label: formattedValue,
activeColor: Theme.of(context).primaryColor,
),
),
],
);
}
}
String _formatSliderValue(double v) {
if (v == 0.0) {
return 'setting_notifications_notify_immediately'.tr();
} else if (v == 1.0) {
return 'setting_notifications_notify_minutes'.tr(args: const ['30']);
} else if (v == 2.0) {
return 'setting_notifications_notify_hours'.tr(args: const ['2']);
} else if (v == 3.0) {
return 'setting_notifications_notify_hours'.tr(args: const ['8']);
} else if (v == 4.0) {
return 'setting_notifications_notify_hours'.tr(args: const ['24']);
} else {
return 'setting_notifications_notify_never'.tr();
}
}

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart'; import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart'; import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart'; import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
class SettingsPage extends HookConsumerWidget { class SettingsPage extends HookConsumerWidget {
@ -37,7 +38,8 @@ class SettingsPage extends HookConsumerWidget {
tiles: [ tiles: [
const ImageViewerQualitySetting(), const ImageViewerQualitySetting(),
const ThemeSetting(), const ThemeSetting(),
const AssetListSettings() const AssetListSettings(),
const NotificationSetting(),
], ],
).toList(), ).toList(),
], ],