diff --git a/Skinia/Models/ReminderFrequency.swift b/Skinia/Models/ReminderFrequency.swift new file mode 100644 index 0000000..2621f86 --- /dev/null +++ b/Skinia/Models/ReminderFrequency.swift @@ -0,0 +1,34 @@ +import Foundation + +enum ReminderFrequency: String, CaseIterable { + case weekly = "weekly" + case monthly = "monthly" + case quarterly = "quarterly" + + var title: String { + switch self { + case .weekly: return "Semanal" + case .monthly: return "Mensal" + case .quarterly: return "Trimestral" + } + } + + var description: String { + switch self { + case .weekly: return "A cada 7 dias" + case .monthly: return "A cada 30 dias" + case .quarterly: return "A cada 3 meses" + } + } + + var timeInterval: TimeInterval { + switch self { + case .weekly: + return 7 * 24 * 60 * 60 + case .monthly: + return 30 * 24 * 60 * 60 + case .quarterly: + return 90 * 24 * 60 * 60 + } + } +} diff --git a/Skinia/Services/Notifications/NotificationScheduler.swift b/Skinia/Services/Notifications/NotificationScheduler.swift new file mode 100644 index 0000000..bb850e5 --- /dev/null +++ b/Skinia/Services/Notifications/NotificationScheduler.swift @@ -0,0 +1,119 @@ +import Foundation +import UserNotifications + +protocol UserNotificationCenterProtocol { + func add(_ request: UNNotificationRequest) async throws + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) + func removeAllPendingNotificationRequests() + func removeAllDeliveredNotifications() +} + +extension UNUserNotificationCenter: UserNotificationCenterProtocol { + func add(_ request: UNNotificationRequest) async throws { + try await withCheckedThrowingContinuation { continuation in + self.add(request) { error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } +} + +protocol NotificationSchedulerProtocol { + func updateAnalysisResultNotifications(isEnabled: Bool) async + func updateUrgentResultNotifications(isEnabled: Bool) async + func updateReminderNotifications(isEnabled: Bool, frequency: ReminderFrequency) async + func cancelAllNotifications() async +} + +final class NotificationScheduler: NotificationSchedulerProtocol { + private enum Identifiers { + static let analysisResult = "com.skinia.notifications.analysis-result" + static let urgentResult = "com.skinia.notifications.urgent-result" + static let reminder = "com.skinia.notifications.reminder" + } + + private let notificationCenter: UserNotificationCenterProtocol + + init(notificationCenter: UserNotificationCenterProtocol = UNUserNotificationCenter.current()) { + self.notificationCenter = notificationCenter + } + + func updateAnalysisResultNotifications(isEnabled: Bool) async { + cancelNotifications(with: [Identifiers.analysisResult]) + + guard isEnabled else { return } + + let content = UNMutableNotificationContent() + content.title = "Análise concluída" + content.body = "Avisaremos sempre que um novo resultado estiver disponível." + content.sound = .default + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5 * 60, repeats: false) + let request = UNNotificationRequest( + identifier: Identifiers.analysisResult, + content: content, + trigger: trigger + ) + + try? await notificationCenter.add(request) + } + + func updateUrgentResultNotifications(isEnabled: Bool) async { + cancelNotifications(with: [Identifiers.urgentResult]) + + guard isEnabled else { return } + + let content = UNMutableNotificationContent() + content.title = "Alerta de resultado urgente" + content.body = "Você receberá avisos imediatos quando detectarmos possíveis sinais de risco." + if #available(iOS 15.0, *) { + content.sound = .defaultCritical + } else { + content.sound = .default + } + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10 * 60, repeats: false) + let request = UNNotificationRequest( + identifier: Identifiers.urgentResult, + content: content, + trigger: trigger + ) + + try? await notificationCenter.add(request) + } + + func updateReminderNotifications(isEnabled: Bool, frequency: ReminderFrequency) async { + cancelNotifications(with: [Identifiers.reminder]) + + guard isEnabled else { return } + + let content = UNMutableNotificationContent() + content.title = "Hora de monitorar sua pele" + content.body = "Crie novas análises periódicas para acompanhar possíveis alterações." + content.sound = .default + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: max(frequency.timeInterval, 60), repeats: true) + let request = UNNotificationRequest( + identifier: Identifiers.reminder, + content: content, + trigger: trigger + ) + + try? await notificationCenter.add(request) + } + + func cancelAllNotifications() async { + notificationCenter.removeAllPendingNotificationRequests() + notificationCenter.removeAllDeliveredNotifications() + } + + private func cancelNotifications(with identifiers: [String]) { + notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers) + notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers) + } +} diff --git a/Skinia/Utilities/DependencyContainer.swift b/Skinia/Utilities/DependencyContainer.swift index 0cf1657..e88d532 100644 --- a/Skinia/Utilities/DependencyContainer.swift +++ b/Skinia/Utilities/DependencyContainer.swift @@ -10,6 +10,7 @@ protocol DependencyContainerProtocol { var networkService: any NetworkServiceProtocol { get } var analysisExportService: any AnalysisExportServiceProtocol { get } var shareSheetPresenter: ShareSheetPresenter { get } + var notificationScheduler: any NotificationSchedulerProtocol { get } // Storage var modelContainer: ModelContainer { get } @@ -21,6 +22,7 @@ final class DependencyContainer: DependencyContainerProtocol { // MARK: - Managers let notificationManager = NotificationManager() let shareSheetPresenter = ShareSheetPresenter() + lazy var notificationScheduler: any NotificationSchedulerProtocol = NotificationScheduler() // MARK: - Storage lazy var modelContainer: ModelContainer = { diff --git a/Skinia/Views/Screens/NotificationSettingsView.swift b/Skinia/Views/Screens/NotificationSettingsView.swift index 9f5ff16..056dfee 100644 --- a/Skinia/Views/Screens/NotificationSettingsView.swift +++ b/Skinia/Views/Screens/NotificationSettingsView.swift @@ -8,9 +8,15 @@ struct NotificationSettingsView: View { @AppStorage("reminderNotifications") private var reminderNotifications = false @AppStorage("urgentResultNotifications") private var urgentResultNotifications = true @AppStorage("reminderFrequency") private var reminderFrequency = ReminderFrequency.monthly.rawValue - + @State private var notificationAuthorizationStatus: UNAuthorizationStatus = .notDetermined @State private var showingPermissionAlert = false + + private let notificationScheduler: any NotificationSchedulerProtocol + + init(notificationScheduler: any NotificationSchedulerProtocol = DependencyContainer.shared.notificationScheduler) { + self.notificationScheduler = notificationScheduler + } var body: some View { NavigationView { @@ -39,50 +45,81 @@ struct NotificationSettingsView: View { .toggleStyle(SwitchToggleStyle(tint: DesignSystem.Colors.primary)) .disabled(notificationAuthorizationStatus == .denied) .onChange(of: notificationsEnabled) { _, newValue in - if newValue && notificationAuthorizationStatus == .notDetermined { - requestNotificationPermission() - } + handleGeneralNotificationToggleChange(isEnabled: newValue) + } + + if !notificationsEnabled && notificationAuthorizationStatus == .authorized { + Text("As notificações específicas permanecerão desativadas até você reativar esta opção.") + .font(DesignSystem.Typography.caption) + .foregroundStyle(DesignSystem.Colors.textSecondary) + .padding(.top, DesignSystem.Spacing.xs) } } - + // Analysis Notifications Section("Notificações de Análise") { NotificationToggleRow( isOn: $analysisCompleteNotifications, title: "Análise Concluída", - subtitle: "Quando o resultado da análise estiver pronto", + subtitle: notificationsEnabled + ? "Quando o resultado da análise estiver pronto" + : "Ative as notificações gerais para receber este alerta", icon: "checkmark.circle", iconColor: DesignSystem.Colors.success, isEnabled: notificationsEnabled && notificationAuthorizationStatus == .authorized ) - + .onChange(of: analysisCompleteNotifications) { _, newValue in + Task { + await handleAnalysisToggleChange(isEnabled: newValue) + } + } + NotificationToggleRow( isOn: $urgentResultNotifications, title: "Resultados Urgentes", - subtitle: "Para análises que indicam alto risco", + subtitle: notificationsEnabled + ? "Para análises que indicam alto risco" + : "Ative as notificações gerais para receber alertas críticos", icon: "exclamationmark.triangle", iconColor: DesignSystem.Colors.error, isEnabled: notificationsEnabled && notificationAuthorizationStatus == .authorized ) + .onChange(of: urgentResultNotifications) { _, newValue in + Task { + await handleUrgentToggleChange(isEnabled: newValue) + } + } } - + // Reminder Notifications Section("Lembretes") { NotificationToggleRow( isOn: $reminderNotifications, title: "Lembretes de Monitoramento", - subtitle: "Para acompanhar lesões regularmente", + subtitle: notificationsEnabled + ? "Para acompanhar lesões regularmente" + : "Ative as notificações gerais para receber lembretes", icon: "bell.badge", iconColor: DesignSystem.Colors.warning, isEnabled: notificationsEnabled && notificationAuthorizationStatus == .authorized ) - + .onChange(of: reminderNotifications) { _, newValue in + Task { + await handleReminderToggleChange(isEnabled: newValue) + } + } + if reminderNotifications { ReminderFrequencyPicker(selectedFrequency: Binding( get: { ReminderFrequency(rawValue: reminderFrequency) ?? .monthly }, set: { reminderFrequency = $0.rawValue } )) .disabled(!notificationsEnabled || notificationAuthorizationStatus != .authorized) + .onChange(of: reminderFrequency) { _, _ in + Task { + await rescheduleReminderIfNeeded() + } + } } } @@ -130,19 +167,28 @@ struct NotificationSettingsView: View { UNUserNotificationCenter.current().getNotificationSettings { settings in DispatchQueue.main.async { self.notificationAuthorizationStatus = settings.authorizationStatus + Task { + await rescheduleNotificationsIfNeeded() + } } } } - + private func requestNotificationPermission() { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in DispatchQueue.main.async { if granted { self.notificationAuthorizationStatus = .authorized + Task { + await rescheduleNotificationsIfNeeded() + } } else { self.notificationAuthorizationStatus = .denied self.notificationsEnabled = false self.showingPermissionAlert = true + Task { + await notificationScheduler.cancelAllNotifications() + } } } } @@ -157,30 +203,85 @@ struct NotificationSettingsView: View { UIApplication.shared.open(settingsUrl) } } -} -// MARK: - Reminder Frequency + @MainActor + private func handleGeneralNotificationToggleChange(isEnabled: Bool) { + if isEnabled { + if notificationAuthorizationStatus == .notDetermined { + requestNotificationPermission() + } else { + Task { + await rescheduleNotificationsIfNeeded() + } + } + } else { + Task { + await notificationScheduler.cancelAllNotifications() + } + } + } -enum ReminderFrequency: String, CaseIterable { - case weekly = "weekly" - case monthly = "monthly" - case quarterly = "quarterly" - - var title: String { - switch self { - case .weekly: return "Semanal" - case .monthly: return "Mensal" - case .quarterly: return "Trimestral" + @MainActor + private func handleAnalysisToggleChange(isEnabled: Bool) async { + guard notificationAuthorizationStatus == .authorized else { + await notificationScheduler.updateAnalysisResultNotifications(isEnabled: false) + return + } + + if notificationsEnabled { + await notificationScheduler.updateAnalysisResultNotifications(isEnabled: isEnabled) + } else { + await notificationScheduler.updateAnalysisResultNotifications(isEnabled: false) } } - - var description: String { - switch self { - case .weekly: return "A cada 7 dias" - case .monthly: return "A cada 30 dias" - case .quarterly: return "A cada 3 meses" + + @MainActor + private func handleUrgentToggleChange(isEnabled: Bool) async { + guard notificationAuthorizationStatus == .authorized else { + await notificationScheduler.updateUrgentResultNotifications(isEnabled: false) + return + } + + if notificationsEnabled { + await notificationScheduler.updateUrgentResultNotifications(isEnabled: isEnabled) + } else { + await notificationScheduler.updateUrgentResultNotifications(isEnabled: false) + } + } + + @MainActor + private func handleReminderToggleChange(isEnabled: Bool) async { + guard notificationAuthorizationStatus == .authorized else { + await notificationScheduler.updateReminderNotifications(isEnabled: false, frequency: currentReminderFrequency()) + return + } + + if notificationsEnabled { + await notificationScheduler.updateReminderNotifications(isEnabled: isEnabled, frequency: currentReminderFrequency()) + } else { + await notificationScheduler.updateReminderNotifications(isEnabled: false, frequency: currentReminderFrequency()) } } + + @MainActor + private func rescheduleReminderIfNeeded() async { + guard notificationsEnabled, reminderNotifications, notificationAuthorizationStatus == .authorized else { return } + await notificationScheduler.updateReminderNotifications(isEnabled: true, frequency: currentReminderFrequency()) + } + + @MainActor + private func rescheduleNotificationsIfNeeded() async { + guard notificationsEnabled, notificationAuthorizationStatus == .authorized else { return } + + await notificationScheduler.updateAnalysisResultNotifications(isEnabled: analysisCompleteNotifications) + await notificationScheduler.updateUrgentResultNotifications(isEnabled: urgentResultNotifications) + await notificationScheduler.updateReminderNotifications(isEnabled: reminderNotifications, frequency: currentReminderFrequency()) + } + + @MainActor + private func currentReminderFrequency() -> ReminderFrequency { + ReminderFrequency(rawValue: reminderFrequency) ?? .monthly + } } // MARK: - Notification Status Card diff --git a/SkiniaTests/NotificationSchedulerTests.swift b/SkiniaTests/NotificationSchedulerTests.swift new file mode 100644 index 0000000..cfa1a28 --- /dev/null +++ b/SkiniaTests/NotificationSchedulerTests.swift @@ -0,0 +1,83 @@ +import XCTest +@testable import Skinia +import UserNotifications + +final class NotificationSchedulerTests: XCTestCase { + func testEnablingAnalysisNotificationsSchedulesRequest() async { + let center = FakeNotificationCenter() + let scheduler = NotificationScheduler(notificationCenter: center) + + await scheduler.updateAnalysisResultNotifications(isEnabled: true) + + XCTAssertEqual(center.addedRequests.count, 1) + XCTAssertEqual(center.addedRequests.first?.identifier, "com.skinia.notifications.analysis-result") + } + + func testDisablingAnalysisNotificationsClearsRequests() async { + let center = FakeNotificationCenter() + let scheduler = NotificationScheduler(notificationCenter: center) + + await scheduler.updateAnalysisResultNotifications(isEnabled: false) + + XCTAssertEqual(center.removedPendingIdentifiers, [["com.skinia.notifications.analysis-result"]]) + } + + func testEnablingReminderSchedulesRepeatingTrigger() async { + let center = FakeNotificationCenter() + let scheduler = NotificationScheduler(notificationCenter: center) + + await scheduler.updateReminderNotifications(isEnabled: true, frequency: .weekly) + + let trigger = center.addedRequests.first?.trigger as? UNTimeIntervalNotificationTrigger + XCTAssertNotNil(trigger) + XCTAssertTrue(trigger?.repeats ?? false) + XCTAssertEqual(trigger?.timeInterval, max(ReminderFrequency.weekly.timeInterval, 60)) + } + + func testDisablingReminderRemovesRequests() async { + let center = FakeNotificationCenter() + let scheduler = NotificationScheduler(notificationCenter: center) + + await scheduler.updateReminderNotifications(isEnabled: false, frequency: .monthly) + + XCTAssertEqual(center.removedPendingIdentifiers, [["com.skinia.notifications.reminder"]]) + } + + func testCancelAllNotificationsClearsPendingAndDelivered() async { + let center = FakeNotificationCenter() + let scheduler = NotificationScheduler(notificationCenter: center) + + await scheduler.cancelAllNotifications() + + XCTAssertTrue(center.didRemoveAllPending) + XCTAssertTrue(center.didRemoveAllDelivered) + } +} + +private final class FakeNotificationCenter: UserNotificationCenterProtocol { + private(set) var addedRequests: [UNNotificationRequest] = [] + private(set) var removedPendingIdentifiers: [[String]] = [] + private(set) var removedDeliveredIdentifiers: [[String]] = [] + private(set) var didRemoveAllPending = false + private(set) var didRemoveAllDelivered = false + + func add(_ request: UNNotificationRequest) async throws { + addedRequests.append(request) + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { + removedPendingIdentifiers.append(identifiers) + } + + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) { + removedDeliveredIdentifiers.append(identifiers) + } + + func removeAllPendingNotificationRequests() { + didRemoveAllPending = true + } + + func removeAllDeliveredNotifications() { + didRemoveAllDelivered = true + } +}