mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(mobile): iOS background sync notifications (#1811)
* adds notification handling logic * notification on background updates for iOS * fixed regression where i accidentally removed load translations from the background sync * fixed ios translations --------- Co-authored-by: Marty Fuhry <marty@fuhry.farm> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
		@@ -314,10 +314,9 @@ class BackgroundService {
 | 
			
		||||
            return false;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // Notifications aren't enabled in iOS yet, and this line
 | 
			
		||||
          // below crashes the iOS background service
 | 
			
		||||
          if (Platform.isAndroid) {
 | 
			
		||||
            await loadTranslations();
 | 
			
		||||
          final translationsOk = await loadTranslations();
 | 
			
		||||
          if (!translationsOk) {
 | 
			
		||||
            debugPrint("[_callHandler] could not load translations");
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          final bool ok = await _onAssetsChanged();
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,44 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:permission_handler/permission_handler.dart';
 | 
			
		||||
 | 
			
		||||
class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
 | 
			
		||||
  NotificationPermissionNotifier() :
 | 
			
		||||
    super(Platform.isAndroid
 | 
			
		||||
      ? PermissionStatus.granted
 | 
			
		||||
      : PermissionStatus.restricted,
 | 
			
		||||
    ) {
 | 
			
		||||
    // Sets the initial state
 | 
			
		||||
    getNotificationPermission().then((p) => state = p);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Requests the notification permission
 | 
			
		||||
  /// Note: In Android, this is always granted
 | 
			
		||||
  Future<PermissionStatus> requestNotificationPermission() async {
 | 
			
		||||
     final permission = await Permission.notification.request();
 | 
			
		||||
     state = permission;
 | 
			
		||||
     return permission;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Whether the user has the permission or not
 | 
			
		||||
  /// Note: In Android, this is always true
 | 
			
		||||
  Future<bool> hasNotificationPermission() {
 | 
			
		||||
    return Permission.notification.isGranted;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<PermissionStatus> getNotificationPermission() async {
 | 
			
		||||
    final status = await Permission.notification.status;
 | 
			
		||||
    state = status;
 | 
			
		||||
    return status;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Either the permission was granted already or else ask for the permission
 | 
			
		||||
  Future<bool> hasOrAskForNotificationPermission() {
 | 
			
		||||
    return requestNotificationPermission().then((p) => p.isGranted);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
final notificationPermissionProvider
 | 
			
		||||
  = StateNotifierProvider<NotificationPermissionNotifier, PermissionStatus>
 | 
			
		||||
    ((ref) => NotificationPermissionNotifier());
 | 
			
		||||
							
								
								
									
										21
									
								
								mobile/lib/modules/settings/services/permission.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								mobile/lib/modules/settings/services/permission.service.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
import 'package:permission_handler/permission_handler.dart';
 | 
			
		||||
 | 
			
		||||
/// This class is for requesting permissions in the app
 | 
			
		||||
class PermissionService {
 | 
			
		||||
  /// Requests the notification permission
 | 
			
		||||
  /// Note: In Android, this is always granted
 | 
			
		||||
  Future<PermissionStatus> requestNotificationPermission() {
 | 
			
		||||
    return Permission.notification.request();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Whether the user has the permission or not
 | 
			
		||||
  /// Note: In Android, this is always true
 | 
			
		||||
  Future<bool> hasNotificationPermission() {
 | 
			
		||||
    return Permission.notification.isGranted;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Either the permission was granted already or else ask for the permission
 | 
			
		||||
  Future<bool> hasOrAskForNotificationPermission() {
 | 
			
		||||
    return requestNotificationPermission().then((p) => p.isGranted);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,24 +0,0 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 | 
			
		||||
 | 
			
		||||
SwitchListTile buildSwitchListTile(
 | 
			
		||||
  BuildContext context,
 | 
			
		||||
  AppSettingsService appSettingService,
 | 
			
		||||
  ValueNotifier<bool> valueNotifier,
 | 
			
		||||
  AppSettingsEnum settingsEnum, {
 | 
			
		||||
  required String title,
 | 
			
		||||
  String? subtitle,
 | 
			
		||||
}) {
 | 
			
		||||
  return SwitchListTile.adaptive(
 | 
			
		||||
    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,
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -4,7 +4,7 @@ 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';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/ui/common.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
 | 
			
		||||
 | 
			
		||||
class ImageViewerQualitySetting extends HookConsumerWidget {
 | 
			
		||||
  const ImageViewerQualitySetting({
 | 
			
		||||
@@ -44,19 +44,17 @@ class ImageViewerQualitySetting extends HookConsumerWidget {
 | 
			
		||||
          title: const Text('setting_image_viewer_help').tr(),
 | 
			
		||||
          dense: true,
 | 
			
		||||
        ),
 | 
			
		||||
        buildSwitchListTile(
 | 
			
		||||
          context,
 | 
			
		||||
          settings,
 | 
			
		||||
          isPreview,
 | 
			
		||||
          AppSettingsEnum.loadPreview,
 | 
			
		||||
        SettingsSwitchListTile(
 | 
			
		||||
          appSettingService: settings,
 | 
			
		||||
          valueNotifier: isPreview,
 | 
			
		||||
          settingsEnum: AppSettingsEnum.loadPreview,
 | 
			
		||||
          title: "setting_image_viewer_preview_title".tr(),
 | 
			
		||||
          subtitle: "setting_image_viewer_preview_subtitle".tr(),
 | 
			
		||||
        ),
 | 
			
		||||
        buildSwitchListTile(
 | 
			
		||||
          context,
 | 
			
		||||
          settings,
 | 
			
		||||
          isOriginal,
 | 
			
		||||
          AppSettingsEnum.loadOriginal,
 | 
			
		||||
        SettingsSwitchListTile(
 | 
			
		||||
          appSettingService: settings,
 | 
			
		||||
          valueNotifier: isOriginal,
 | 
			
		||||
          settingsEnum: AppSettingsEnum.loadOriginal,
 | 
			
		||||
          title: "setting_image_viewer_original_title".tr(),
 | 
			
		||||
          subtitle: "setting_image_viewer_original_subtitle".tr(),
 | 
			
		||||
        ),
 | 
			
		||||
 
 | 
			
		||||
@@ -3,8 +3,10 @@ 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/providers/permission.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/ui/common.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
 | 
			
		||||
import 'package:permission_handler/permission_handler.dart';
 | 
			
		||||
 | 
			
		||||
class NotificationSetting extends HookConsumerWidget {
 | 
			
		||||
  const NotificationSetting({
 | 
			
		||||
@@ -14,12 +16,14 @@ class NotificationSetting extends HookConsumerWidget {
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final appSettingService = ref.watch(appSettingsServiceProvider);
 | 
			
		||||
    final permissionService = ref.watch(notificationPermissionProvider);
 | 
			
		||||
 | 
			
		||||
    final sliderValue = useState(0.0);
 | 
			
		||||
    final totalProgressValue =
 | 
			
		||||
        useState(AppSettingsEnum.backgroundBackupTotalProgress.defaultValue);
 | 
			
		||||
    final singleProgressValue =
 | 
			
		||||
        useState(AppSettingsEnum.backgroundBackupSingleProgress.defaultValue);
 | 
			
		||||
    final hasPermission = permissionService == PermissionStatus.granted;
 | 
			
		||||
 | 
			
		||||
    useEffect(
 | 
			
		||||
      () {
 | 
			
		||||
@@ -35,6 +39,30 @@ class NotificationSetting extends HookConsumerWidget {
 | 
			
		||||
      [],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // When permissions are permanently denied, you need to go to settings to
 | 
			
		||||
    // allow them
 | 
			
		||||
    showPermissionsDialog() {
 | 
			
		||||
      showDialog(
 | 
			
		||||
        context: context,
 | 
			
		||||
        builder: (context) => AlertDialog(
 | 
			
		||||
          content: const Text('notification_permission_dialog_content').tr(),
 | 
			
		||||
          actions: [
 | 
			
		||||
            TextButton(
 | 
			
		||||
              child: const Text('notification_permission_dialog_cancel').tr(),
 | 
			
		||||
              onPressed: () => Navigator.of(context).pop(),
 | 
			
		||||
            ),
 | 
			
		||||
            TextButton(
 | 
			
		||||
              child: const Text('notification_permission_dialog_settings').tr(),
 | 
			
		||||
              onPressed: () {
 | 
			
		||||
                Navigator.of(context).pop();
 | 
			
		||||
                openAppSettings();
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final String formattedValue = _formatSliderValue(sliderValue.value);
 | 
			
		||||
    return ExpansionTile(
 | 
			
		||||
      textColor: Theme.of(context).primaryColor,
 | 
			
		||||
@@ -51,23 +79,49 @@ class NotificationSetting extends HookConsumerWidget {
 | 
			
		||||
        ),
 | 
			
		||||
      ).tr(),
 | 
			
		||||
      children: [
 | 
			
		||||
        buildSwitchListTile(
 | 
			
		||||
          context,
 | 
			
		||||
          appSettingService,
 | 
			
		||||
          totalProgressValue,
 | 
			
		||||
          AppSettingsEnum.backgroundBackupTotalProgress,
 | 
			
		||||
        if (!hasPermission)
 | 
			
		||||
          ListTile(
 | 
			
		||||
            leading: const Icon(Icons.notifications_outlined),
 | 
			
		||||
            title: const Text('notification_permission_list_tile_title').tr(),
 | 
			
		||||
            subtitle: Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                const Text('notification_permission_list_tile_content').tr(),
 | 
			
		||||
                const SizedBox(height: 8),
 | 
			
		||||
                ElevatedButton(
 | 
			
		||||
                  onPressed: ()
 | 
			
		||||
                  => ref.watch(notificationPermissionProvider.notifier)
 | 
			
		||||
                    .requestNotificationPermission().then((permission) {
 | 
			
		||||
                      if (permission == PermissionStatus.permanentlyDenied) {
 | 
			
		||||
                        showPermissionsDialog();
 | 
			
		||||
                      }
 | 
			
		||||
                  }),
 | 
			
		||||
                  child:
 | 
			
		||||
                  const Text('notification_permission_list_tile_enable_button')
 | 
			
		||||
                    .tr(),
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
            isThreeLine: true,
 | 
			
		||||
          ),
 | 
			
		||||
        SettingsSwitchListTile(
 | 
			
		||||
          enabled: hasPermission,
 | 
			
		||||
          appSettingService: appSettingService,
 | 
			
		||||
          valueNotifier: totalProgressValue,
 | 
			
		||||
          settingsEnum: AppSettingsEnum.backgroundBackupTotalProgress,
 | 
			
		||||
          title: 'setting_notifications_total_progress_title'.tr(),
 | 
			
		||||
          subtitle: 'setting_notifications_total_progress_subtitle'.tr(),
 | 
			
		||||
        ),
 | 
			
		||||
        buildSwitchListTile(
 | 
			
		||||
          context,
 | 
			
		||||
          appSettingService,
 | 
			
		||||
          singleProgressValue,
 | 
			
		||||
          AppSettingsEnum.backgroundBackupSingleProgress,
 | 
			
		||||
        SettingsSwitchListTile(
 | 
			
		||||
          enabled: hasPermission,
 | 
			
		||||
          appSettingService: appSettingService,
 | 
			
		||||
          valueNotifier: singleProgressValue,
 | 
			
		||||
          settingsEnum: AppSettingsEnum.backgroundBackupSingleProgress,
 | 
			
		||||
          title: 'setting_notifications_single_progress_title'.tr(),
 | 
			
		||||
          subtitle: 'setting_notifications_single_progress_subtitle'.tr(),
 | 
			
		||||
        ),
 | 
			
		||||
        ListTile(
 | 
			
		||||
          enabled: hasPermission,
 | 
			
		||||
          isThreeLine: false,
 | 
			
		||||
          dense: true,
 | 
			
		||||
          title: const Text(
 | 
			
		||||
@@ -76,7 +130,7 @@ class NotificationSetting extends HookConsumerWidget {
 | 
			
		||||
          ).tr(args: [formattedValue]),
 | 
			
		||||
          subtitle: Slider(
 | 
			
		||||
            value: sliderValue.value,
 | 
			
		||||
            onChanged: (double v) => sliderValue.value = v,
 | 
			
		||||
            onChanged: !hasPermission ? null : (double v) => sliderValue.value = v,
 | 
			
		||||
            onChangeEnd: (double v) => appSettingService.setSetting(
 | 
			
		||||
              AppSettingsEnum.uploadErrorNotificationGracePeriod,
 | 
			
		||||
              v.toInt(),
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,37 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 | 
			
		||||
 | 
			
		||||
class SettingsSwitchListTile extends StatelessWidget {
 | 
			
		||||
  final AppSettingsService appSettingService;
 | 
			
		||||
  final ValueNotifier<bool> valueNotifier;
 | 
			
		||||
  final AppSettingsEnum settingsEnum;
 | 
			
		||||
  final String title;
 | 
			
		||||
  final bool enabled;
 | 
			
		||||
  final String? subtitle;
 | 
			
		||||
 | 
			
		||||
  SettingsSwitchListTile({
 | 
			
		||||
    required this.appSettingService,
 | 
			
		||||
    required this.valueNotifier,
 | 
			
		||||
    required this.settingsEnum,
 | 
			
		||||
    required this.title,
 | 
			
		||||
    this.subtitle,
 | 
			
		||||
    this.enabled = true,
 | 
			
		||||
  }) : super(key: Key(settingsEnum.name));
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return SwitchListTile.adaptive(
 | 
			
		||||
      value: valueNotifier.value,
 | 
			
		||||
      onChanged: !enabled ? null : (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,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,5 +1,3 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
@@ -41,7 +39,7 @@ class SettingsPage extends HookConsumerWidget {
 | 
			
		||||
              const ImageViewerQualitySetting(),
 | 
			
		||||
              const ThemeSetting(),
 | 
			
		||||
              const AssetListSettings(),
 | 
			
		||||
              if (Platform.isAndroid) const NotificationSetting(),
 | 
			
		||||
              const NotificationSetting(),
 | 
			
		||||
              //const ExperimentalSettings(),
 | 
			
		||||
            ],
 | 
			
		||||
          ).toList(),
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user