1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-01 08:31:59 +00:00

feat(mobile): background backup progress notifications (#781)

* settings to configure upload progress notifications (none/standard/detailed)
* use native Android notifications to show progress information
* e.g. 50% (30/60) assets
* e.g. Uploading asset XYZ - 25% (2/8MB)
* no longer show errors if canceled by system (losing network)
This commit is contained in:
Fynn Petersen-Frey 2022-10-05 16:59:35 +02:00 committed by GitHub
parent 95467fa3c1
commit 5dfce4db34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 218 additions and 64 deletions

View file

@ -1,5 +1,6 @@
package app.alextran.immich package app.alextran.immich
import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
@ -47,6 +48,8 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext) private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
private var timeBackupStarted: Long = 0L private var timeBackupStarted: Long = 0L
private var notificationBuilder: NotificationCompat.Builder? = null
private var notificationDetailBuilder: NotificationCompat.Builder? = null
override fun startWork(): ListenableFuture<ListenableWorker.Result> { override fun startWork(): ListenableFuture<ListenableWorker.Result> {
@ -61,16 +64,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
// Create a Notification channel if necessary // Create a Notification channel if necessary
createChannel() createChannel()
} }
val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
.getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
if (isIgnoringBatteryOptimizations) { if (isIgnoringBatteryOptimizations) {
// normal background services can only up to 10 minutes // normal background services can only up to 10 minutes
// foreground services are allowed to run indefinitely // foreground services are allowed to run indefinitely
// requires battery optimizations to be disabled (either manually by the user // requires battery optimizations to be disabled (either manually by the user
// or by the system learning that immich is important to the user) // or by the system learning that immich is important to the user)
setForegroundAsync(createForegroundInfo(title)) val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
} else { .getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
showBackgroundInfo(title) showInfo(getInfoBuilder(title, indeterminate=true).build())
} }
engine = FlutterEngine(ctx) engine = FlutterEngine(ctx)
@ -154,18 +155,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
} }
"updateNotification" -> { "updateNotification" -> {
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?
if (isIgnoringBatteryOptimizations) { val progress = args.get(2) as Int
setForegroundAsync(createForegroundInfo(title, content)) val max = args.get(3) as Int
} else { val indeterminate = args.get(4) as Boolean
showBackgroundInfo(title, content) val isDetail = args.get(5) as Boolean
val onlyIfFG = args.get(6) as Boolean
if (!onlyIfFG || isIgnoringBatteryOptimizations) {
showInfo(getInfoBuilder(title, content, isDetail, progress, max, indeterminate).build(), isDetail)
} }
} }
"showError" -> { "showError" -> {
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?
val individualTag = args.get(2) as String? val individualTag = args.get(2) as String?
showError(title, content, individualTag) showError(title, content, individualTag)
} }
@ -182,13 +186,12 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
} }
} }
private fun showError(title: String, content: String, individualTag: 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)
.setOnlyAlertOnce(true)
.build() .build()
notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification) notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
} }
@ -197,38 +200,54 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
notificationManager.cancel(NOTIFICATION_ERROR_ID) notificationManager.cancel(NOTIFICATION_ERROR_ID)
} }
private fun showBackgroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null) {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setTicker(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setOnlyAlertOnce(true)
.setOngoing(true)
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}
private fun clearBackgroundNotification() { private fun clearBackgroundNotification() {
notificationManager.cancel(NOTIFICATION_ID) notificationManager.cancel(NOTIFICATION_ID)
notificationManager.cancel(NOTIFICATION_DETAIL_ID)
} }
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo { private fun showInfo(notification: Notification, isDetail: Boolean = false) {
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) val id = if(isDetail) NOTIFICATION_DETAIL_ID else NOTIFICATION_ID
.setContentTitle(title) if (isIgnoringBatteryOptimizations) {
.setTicker(title) setForegroundAsync(ForegroundInfo(id, notification))
.setContentText(content) } else {
.setSmallIcon(R.mipmap.ic_launcher) notificationManager.notify(id, notification)
.setOngoing(true) }
.build() }
return ForegroundInfo(NOTIFICATION_ID, notification)
} private fun getInfoBuilder(
title: String? = null,
content: String? = null,
isDetail: Boolean = false,
progress: Int = 0,
max: Int = 0,
indeterminate: Boolean = false,
): NotificationCompat.Builder {
var builder = if(isDetail) notificationDetailBuilder else notificationBuilder
if (builder == null) {
builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setOnlyAlertOnce(true)
.setOngoing(true)
if (isDetail) {
notificationDetailBuilder = builder
} else {
notificationBuilder = builder
}
}
if (title != null) {
builder.setTicker(title).setContentTitle(title)
}
if (content != null) {
builder.setContentText(content)
}
return builder.setProgress(max, progress, indeterminate)
}
@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_DEFAULT) val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH)
notificationManager.createNotificationChannel(error) notificationManager.createNotificationChannel(error)
} }
@ -244,6 +263,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
private const val NOTIFICATION_DEFAULT_TITLE = "Immich" private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_ERROR_ID = 2 private const val NOTIFICATION_ERROR_ID = 2
private const val NOTIFICATION_DETAIL_ID = 3
private const val ONE_MINUTE = 60000L private const val ONE_MINUTE = 60000L
/** /**

View file

@ -134,6 +134,10 @@
"setting_notifications_notify_never": "never", "setting_notifications_notify_never": "never",
"setting_notifications_subtitle": "Adjust your notification preferences", "setting_notifications_subtitle": "Adjust your notification preferences",
"setting_notifications_title": "Notifications", "setting_notifications_title": "Notifications",
"setting_notifications_total_progress_title": "Show background backup total progress",
"setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)",
"setting_notifications_single_progress_title": "Show background backup detail progress",
"setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset",
"setting_pages_app_bar_settings": "Settings", "setting_pages_app_bar_settings": "Settings",
"share_add": "Add", "share_add": "Add",
"share_add_photos": "Add photos", "share_add_photos": "Add photos",

View file

@ -27,11 +27,11 @@ final backgroundServiceProvider = Provider(
/// Background backup service /// Background backup service
class BackgroundService { class BackgroundService {
static const String _portNameLock = "immichLock"; static const String _portNameLock = "immichLock";
BackgroundService();
static const MethodChannel _foregroundChannel = static const MethodChannel _foregroundChannel =
MethodChannel('immich/foregroundChannel'); MethodChannel('immich/foregroundChannel');
static const MethodChannel _backgroundChannel = static const MethodChannel _backgroundChannel =
MethodChannel('immich/backgroundChannel'); MethodChannel('immich/backgroundChannel');
static final NumberFormat numberFormat = NumberFormat("###0.##");
bool _isBackgroundInitialized = false; bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken; CancellationToken? _cancellationToken;
bool _canceledBySystem = false; bool _canceledBySystem = false;
@ -40,6 +40,10 @@ class BackgroundService {
SendPort? _waitingIsolate; SendPort? _waitingIsolate;
ReceivePort? _rp; ReceivePort? _rp;
bool _errorGracePeriodExceeded = true; bool _errorGracePeriodExceeded = true;
int _uploadedAssetsCount = 0;
int _assetsToUploadCount = 0;
int _lastDetailProgressUpdate = 0;
String _lastPrintedProgress = "";
bool get isBackgroundInitialized { bool get isBackgroundInitialized {
return _isBackgroundInitialized; return _isBackgroundInitialized;
@ -125,22 +129,29 @@ class BackgroundService {
} }
/// Updates the notification shown by the background service /// Updates the notification shown by the background service
Future<bool> _updateNotification({ Future<bool?> _updateNotification({
required String title, String? title,
String? content, String? content,
int progress = 0,
int max = 0,
bool indeterminate = false,
bool isDetail = false,
bool onlyIfFG = false,
}) async { }) async {
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
return true; return true;
} }
try { try {
if (_isBackgroundInitialized) { if (_isBackgroundInitialized) {
return await _backgroundChannel return _backgroundChannel.invokeMethod<bool>(
.invokeMethod('updateNotification', [title, content]); 'updateNotification',
[title, content, progress, max, indeterminate, isDetail, onlyIfFG],
);
} }
} catch (error) { } catch (error) {
debugPrint("[_updateNotification] failed to communicate with plugin"); debugPrint("[_updateNotification] failed to communicate with plugin");
} }
return Future.value(false); return false;
} }
/// Shows a new priority notification /// Shows a new priority notification
@ -274,6 +285,7 @@ class BackgroundService {
case "onAssetsChanged": case "onAssetsChanged":
final Future<bool> translationsLoaded = loadTranslations(); final Future<bool> translationsLoaded = loadTranslations();
try { try {
_clearErrorNotifications();
final bool hasAccess = await acquireLock(); final bool hasAccess = await acquireLock();
if (!hasAccess) { if (!hasAccess) {
debugPrint("[_callHandler] could not acquire lock, exiting"); debugPrint("[_callHandler] could not acquire lock, exiting");
@ -313,19 +325,23 @@ class BackgroundService {
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey)); apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey)); apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
BackupService backupService = BackupService(apiService); BackupService backupService = BackupService(apiService);
AppSettingsService settingsService = AppSettingsService();
final Box<HiveBackupAlbums> box = final Box<HiveBackupAlbums> box =
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);
do { do {
final bool backupOk = await _runBackup(backupService, backupAlbumInfo); final bool backupOk = await _runBackup(
backupService,
settingsService,
backupAlbumInfo,
);
if (backupOk) { if (backupOk) {
await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince); await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
await box.put( await box.put(
@ -346,9 +362,14 @@ class BackgroundService {
Future<bool> _runBackup( Future<bool> _runBackup(
BackupService backupService, BackupService backupService,
AppSettingsService settingsService,
HiveBackupAlbums backupAlbumInfo, HiveBackupAlbums backupAlbumInfo,
) async { ) async {
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded(); _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
final bool notifyTotalProgress = settingsService
.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
final bool notifySingleProgress = settingsService
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
if (_canceledBySystem) { if (_canceledBySystem) {
return false; return false;
@ -372,22 +393,29 @@ class BackgroundService {
} }
if (toUpload.isEmpty) { if (toUpload.isEmpty) {
_clearErrorNotifications();
return true; return true;
} }
_assetsToUploadCount = toUpload.length;
_uploadedAssetsCount = 0;
_updateNotification(
title: "backup_background_service_in_progress_notification".tr(),
content: notifyTotalProgress ? _formatAssetBackupProgress() : null,
progress: 0,
max: notifyTotalProgress ? _assetsToUploadCount : 0,
indeterminate: !notifyTotalProgress,
onlyIfFG: !notifyTotalProgress,
);
_cancellationToken = CancellationToken(); _cancellationToken = CancellationToken();
final bool ok = await backupService.backupAsset( final bool ok = await backupService.backupAsset(
toUpload, toUpload,
_cancellationToken!, _cancellationToken!,
_onAssetUploaded, notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId) {},
_onProgress, notifySingleProgress ? _onProgress : (sent, total) {},
_onSetCurrentBackupAsset, notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
_onBackupError, _onBackupError,
); );
if (ok) { if (!ok && !_cancellationToken!.isCancelled) {
_clearErrorNotifications();
} else {
_showErrorNotification( _showErrorNotification(
title: "backup_background_service_error_title".tr(), title: "backup_background_service_error_title".tr(),
content: "backup_background_service_backup_failed_message".tr(), content: "backup_background_service_backup_failed_message".tr(),
@ -396,16 +424,43 @@ class BackgroundService {
return ok; return ok;
} }
void _onAssetUploaded(String deviceAssetId, String deviceId) { String _formatAssetBackupProgress() {
debugPrint("Uploaded $deviceAssetId from $deviceId"); final int percent = (_uploadedAssetsCount * 100) ~/ _assetsToUploadCount;
return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)";
} }
void _onProgress(int sent, int total) {} void _onAssetUploaded(String deviceAssetId, String deviceId) {
debugPrint("Uploaded $deviceAssetId from $deviceId");
_uploadedAssetsCount++;
_updateNotification(
progress: _uploadedAssetsCount,
max: _assetsToUploadCount,
content: _formatAssetBackupProgress(),
);
}
void _onProgress(int sent, int total) {
final int now = Timeline.now;
// limit updates to 10 per second (or Android drops important notifications)
if (now > _lastDetailProgressUpdate + 100000) {
final String msg = _humanReadableBytesProgress(sent, total);
// only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
if (msg != _lastPrintedProgress) {
_lastDetailProgressUpdate = now;
_lastPrintedProgress = msg;
_updateNotification(
progress: sent,
max: total,
isDetail: true,
content: msg,
);
}
}
}
void _onBackupError(ErrorUploadAsset errorAssetInfo) { void _onBackupError(ErrorUploadAsset errorAssetInfo) {
_showErrorNotification( _showErrorNotification(
title: "Upload failed", title: "backup_background_service_upload_failure_notification"
content: "backup_background_service_upload_failure_notification"
.tr(args: [errorAssetInfo.fileName]), .tr(args: [errorAssetInfo.fileName]),
individualTag: errorAssetInfo.id, individualTag: errorAssetInfo.id,
); );
@ -413,14 +468,17 @@ class BackgroundService {
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) { void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
_updateNotification( _updateNotification(
title: "backup_background_service_in_progress_notification".tr(), title: "backup_background_service_current_upload_notification"
content: "backup_background_service_current_upload_notification"
.tr(args: [currentUploadAsset.fileName]), .tr(args: [currentUploadAsset.fileName]),
content: "",
isDetail: true,
progress: 0,
max: 0,
); );
} }
bool _isErrorGracePeriodExceeded() { bool _isErrorGracePeriodExceeded(AppSettingsService appSettingsService) {
final int value = AppSettingsService() final int value = appSettingsService
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod); .getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
if (value == 0) { if (value == 0) {
return true; return true;
@ -445,6 +503,26 @@ class BackgroundService {
assert(false, "Invalid value"); assert(false, "Invalid value");
return true; return true;
} }
/// prints percentage and absolute progress in useful (kilo/mega/giga)bytes
static String _humanReadableBytesProgress(int bytes, int bytesTotal) {
String unit = "KB"; // Kilobyte
if (bytesTotal >= 0x40000000) {
unit = "GB"; // Gigabyte
bytes >>= 20;
bytesTotal >>= 20;
} else if (bytesTotal >= 0x100000) {
unit = "MB"; // Megabyte
bytes >>= 10;
bytesTotal >>= 10;
} else if (bytesTotal < 0x400) {
return "$bytes / $bytesTotal B";
}
final int percent = (bytes * 100) ~/ bytesTotal;
final String done = numberFormat.format(bytes / 1024.0);
final String total = numberFormat.format(bytesTotal / 1024.0);
return "$percent% ($done/$total$unit)";
}
} }
/// 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

@ -6,7 +6,11 @@ enum AppSettingsEnum<T> {
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<int>(
"uploadErrorNotificationGracePeriod", 2), "uploadErrorNotificationGracePeriod",
2,
),
backgroundBackupTotalProgress<bool>("backgroundBackupTotalProgress", true),
backgroundBackupSingleProgress<bool>("backgroundBackupSingleProgress", false),
storageIndicator<bool>("storageIndicator", true), storageIndicator<bool>("storageIndicator", true),
thumbnailCacheSize<int>("thumbnailCacheSize", 10000), thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
imageCacheSize<int>("imageCacheSize", 350), imageCacheSize<int>("imageCacheSize", 350),

View file

@ -15,12 +15,20 @@ class NotificationSetting extends HookConsumerWidget {
final appSettingService = ref.watch(appSettingsServiceProvider); final appSettingService = ref.watch(appSettingsServiceProvider);
final sliderValue = useState(0.0); final sliderValue = useState(0.0);
final totalProgressValue =
useState(AppSettingsEnum.backgroundBackupTotalProgress.defaultValue);
final singleProgressValue =
useState(AppSettingsEnum.backgroundBackupSingleProgress.defaultValue);
useEffect( useEffect(
() { () {
sliderValue.value = appSettingService sliderValue.value = appSettingService
.getSetting<int>(AppSettingsEnum.uploadErrorNotificationGracePeriod) .getSetting<int>(AppSettingsEnum.uploadErrorNotificationGracePeriod)
.toDouble(); .toDouble();
totalProgressValue.value = appSettingService
.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
singleProgressValue.value = appSettingService
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
return null; return null;
}, },
[], [],
@ -42,6 +50,22 @@ class NotificationSetting extends HookConsumerWidget {
), ),
).tr(), ).tr(),
children: [ children: [
_buildSwitchListTile(
context,
appSettingService,
totalProgressValue,
AppSettingsEnum.backgroundBackupTotalProgress,
title: 'setting_notifications_total_progress_title'.tr(),
subtitle: 'setting_notifications_total_progress_subtitle'.tr(),
),
_buildSwitchListTile(
context,
appSettingService,
singleProgressValue,
AppSettingsEnum.backgroundBackupSingleProgress,
title: 'setting_notifications_single_progress_title'.tr(),
subtitle: 'setting_notifications_single_progress_subtitle'.tr(),
),
ListTile( ListTile(
isThreeLine: false, isThreeLine: false,
dense: true, dense: true,
@ -53,7 +77,9 @@ class NotificationSetting extends HookConsumerWidget {
value: sliderValue.value, value: sliderValue.value,
onChanged: (double v) => sliderValue.value = v, onChanged: (double v) => sliderValue.value = v,
onChangeEnd: (double v) => appSettingService.setSetting( onChangeEnd: (double v) => appSettingService.setSetting(
AppSettingsEnum.uploadErrorNotificationGracePeriod, v.toInt()), AppSettingsEnum.uploadErrorNotificationGracePeriod,
v.toInt(),
),
max: 5.0, max: 5.0,
divisions: 5, divisions: 5,
label: formattedValue, label: formattedValue,
@ -65,6 +91,28 @@ class NotificationSetting extends HookConsumerWidget {
} }
} }
SwitchListTile _buildSwitchListTile(
BuildContext context,
AppSettingsService appSettingService,
ValueNotifier<bool> valueNotifier,
AppSettingsEnum settingsEnum, {
required String title,
String? subtitle,
}) {
return SwitchListTile(
key: Key(settingsEnum.name),
value: valueNotifier.value,
onChanged: (value) {
valueNotifier.value = value;
appSettingService.setSetting(settingsEnum, value);
},
activeColor: Theme.of(context).primaryColor,
dense: true,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: subtitle != null ? Text(subtitle) : null,
);
}
String _formatSliderValue(double v) { String _formatSliderValue(double v) {
if (v == 0.0) { if (v == 0.0) {
return 'setting_notifications_notify_immediately'.tr(); return 'setting_notifications_notify_immediately'.tr();