From e4c5fa73569c33fda177b971db9e2960d2c18cde Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 3 Dec 2025 14:48:21 +0900 Subject: [PATCH] fix(notification): harden local alerts --- ios/Runner/AppDelegate.swift | 10 +++ lib/core/services/notification_service.dart | 70 ++++++++++++++----- .../pages/settings/settings_screen.dart | 37 ++++++++++ 3 files changed, 100 insertions(+), 17 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6266644..665fd68 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,5 +1,6 @@ import Flutter import UIKit +import UserNotifications @main @objc class AppDelegate: FlutterAppDelegate { @@ -7,7 +8,16 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + UNUserNotificationCenter.current().delegate = self GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound, .badge]) + } } diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart index 8b411da..b5cc2e5 100644 --- a/lib/core/services/notification_service.dart +++ b/lib/core/services/notification_service.dart @@ -117,11 +117,15 @@ class NotificationService { enableVibration: true, ); - await _notifications - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >() - ?.createNotificationChannel(androidChannel); + try { + await _notifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.createNotificationChannel(androidChannel); + } catch (e) { + AppLogger.debug('안드로이드 채널 생성 실패: $e'); + } } /// 알림 권한 요청 @@ -214,6 +218,46 @@ class NotificationService { return true; } + /// 정확 알람 권한 가능 여부 확인 (Android 12+) + Future canScheduleExactAlarms() async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { + return true; + } + + try { + final androidImplementation = _notifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + final canExact = await androidImplementation + ?.canScheduleExactNotifications(); + return canExact ?? true; + } catch (e) { + AppLogger.debug('정확 알람 권한 확인 실패: $e'); + return false; + } + } + + /// 정확 알람 권한 요청 (Android 12+ 설정 화면 이동) + Future requestExactAlarmsPermission() async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) { + return true; + } + + try { + final androidImplementation = _notifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + final granted = await androidImplementation + ?.requestExactAlarmsPermission(); + return granted ?? false; + } catch (e) { + AppLogger.debug('정확 알람 권한 요청 실패: $e'); + return false; + } + } + // 알림 탭 콜백 static void Function(NotificationResponse)? onNotificationTap; @@ -238,7 +282,7 @@ class NotificationService { } final location = await _ensureLocalTimezone(); - final minutesToWait = delayMinutes ?? 90 + Random().nextInt(31); + final minutesToWait = max(delayMinutes ?? 90 + Random().nextInt(31), 1); final scheduledTime = tz.TZDateTime.now( location, ).add(Duration(minutes: minutesToWait)); @@ -343,17 +387,9 @@ class NotificationService { return AndroidScheduleMode.exactAllowWhileIdle; } - try { - final androidImplementation = _notifications - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(); - final canExact = await androidImplementation - ?.canScheduleExactNotifications(); - if (canExact == false) { - return AndroidScheduleMode.inexactAllowWhileIdle; - } - } catch (_) { + final canExact = await canScheduleExactAlarms(); + if (!canExact) { + AppLogger.debug('정확 알람 권한 없음 → 근사 모드로 예약'); return AndroidScheduleMode.inexactAllowWhileIdle; } diff --git a/lib/presentation/pages/settings/settings_screen.dart b/lib/presentation/pages/settings/settings_screen.dart index fc34aab..1c5a1c7 100644 --- a/lib/presentation/pages/settings/settings_screen.dart +++ b/lib/presentation/pages/settings/settings_screen.dart @@ -6,6 +6,7 @@ import 'package:permission_handler/permission_handler.dart'; import '../../../core/constants/app_colors.dart'; import '../../../core/constants/app_typography.dart'; import '../../providers/settings_provider.dart'; +import '../../providers/notification_provider.dart'; class SettingsScreen extends ConsumerStatefulWidget { const SettingsScreen({super.key}); @@ -174,6 +175,29 @@ class _SettingsScreenState extends ConsumerState { ); }, ), + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + FutureBuilder( + future: ref + .read(notificationServiceProvider) + .canScheduleExactAlarms(), + builder: (context, snapshot) { + final canExact = snapshot.data; + + // 권한이 이미 허용된 경우 UI 생략 + if (canExact == true) { + return const SizedBox.shrink(); + } + + return _buildPermissionTile( + icon: Icons.alarm, + title: '정확 알람 권한', + subtitle: '정확한 예약 알림을 위해 필요합니다', + isGranted: canExact ?? false, + onRequest: _requestExactAlarmPermission, + isDark: isDark, + ); + }, + ), ], isDark), // 알림 설정 @@ -407,6 +431,19 @@ class _SettingsScreenState extends ConsumerState { } } + Future _requestExactAlarmPermission() async { + final notificationService = ref.read(notificationServiceProvider); + final granted = await notificationService.requestExactAlarmsPermission(); + + if (!mounted) return; + + setState(() {}); + + if (!granted) { + _showPermissionDialog('정확 알람'); + } + } + void _showPermissionDialog(String permissionName) { final isDark = Theme.of(context).brightness == Brightness.dark;