// // SessionAlertManager.swift // Haru_Danim // // 장시간 측정 알림 — 측정을 켜 두고 잊는 것 방지. // 세션이 설정한 시간(settings.longSessionAlertHours)을 넘기면 로컬 알림을 보낸다. // LiveActivityManager.sync가 진행 중 세션을 조회하는 길목에서 함께 호출되므로 // 앱·위젯 버튼·시리·워치 어느 경로로 시작/종료해도 예약이 따라간다 // (LiveActivityIntent 채택으로 모든 세션 변경이 앱 프로세스에서 실행됨). // import Foundation import UserNotifications @MainActor enum SessionAlertManager { private static let idPrefix = "longSession-" /// 설정값(시간). 0 = 사용 안 함 static var alertHours: Int { if let group = AppGroup.defaults.object(forKey: SettingsKeys.longSessionAlertHours) as? Int { return group } // standard 폴백은 integer(forKey:)로 — 런치 인자(-longSessionAlertHours 3)의 문자열 값도 읽힌다 return UserDefaults.standard.integer(forKey: SettingsKeys.longSessionAlertHours) } /// 설정에서 켤 때 1회 호출 — 미결정 상태면 시스템 권한 팝업 static func requestAuthorizationIfNeeded() { Task { let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() if settings.authorizationStatus == .notDetermined { _ = try? await center.requestAuthorization(options: [.alert, .sound]) } } } /// 진행 중 세션 목록에 맞춰 알림 예약을 동기화 (없어진 세션 예약은 제거). /// 시작 시각이 바뀌거나 설정 시간이 바뀌면 식별자가 달라져 자연히 갈아끼워진다. static func sync(running: [TimeSession]) { let hours = alertHours #if DEBUG // 검증용: -longSessionAlertTestSeconds N → 시간 대신 N초 후 발화 let testSeconds = UserDefaults.standard.integer(forKey: "longSessionAlertTestSeconds") let interval: TimeInterval = testSeconds > 0 ? TimeInterval(testSeconds) : TimeInterval(hours) * 3600 #else let interval = TimeInterval(hours) * 3600 #endif struct Planned { let id: String let name: String let fireDate: Date } var planned: [Planned] = [] if hours > 0 { for session in running { guard let action = session.action else { continue } let fireDate = session.startAt.addingTimeInterval(interval) // 이미 지난 예약은 다시 만들지 않는다 (한 세션당 1회 알림) guard fireDate.timeIntervalSinceNow > 1 else { continue } planned.append(Planned( id: idPrefix + action.uuid.uuidString + "-\(Int(session.startAt.timeIntervalSince1970))-h\(hours)", name: action.name, fireDate: fireDate )) } } Task { let center = UNUserNotificationCenter.current() #if DEBUG // 검증 모드: 시뮬레이터에서는 권한 팝업을 누를 수 없어 provisional(무팝업)로 허용 if testSeconds > 0 { _ = try? await center.requestAuthorization(options: [.alert, .sound, .provisional]) } var addErrors: [String] = [] #endif let pending = await center.pendingNotificationRequests() let existing = Set(pending.map(\.identifier).filter { $0.hasPrefix(idPrefix) }) let desired = Set(planned.map(\.id)) let stale = existing.subtracting(desired) if !stale.isEmpty { center.removePendingNotificationRequests(withIdentifiers: Array(stale)) } for plan in planned where !existing.contains(plan.id) { let content = UNMutableNotificationContent() content.title = String(localized: "측정이 계속되고 있어요") content.body = String(localized: "'\(plan.name)' 측정이 \(hours)시간을 넘겼어요. 종료를 잊으셨다면 열어서 꺼 주세요.") content.sound = .default let trigger = UNTimeIntervalNotificationTrigger( timeInterval: max(plan.fireDate.timeIntervalSinceNow, 1), repeats: false ) do { try await center.add( UNNotificationRequest(identifier: plan.id, content: content, trigger: trigger) ) } catch { #if DEBUG addErrors.append("\(plan.id): \(error.localizedDescription)") #endif } } #if DEBUG // 검증용: -alertDump YES → 예약 상태를 Documents/session-alerts.txt로 남긴다 if UserDefaults.standard.bool(forKey: "alertDump"), let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { let after = await center.pendingNotificationRequests() .filter { $0.identifier.hasPrefix(idPrefix) } .map { request -> String in let seconds = (request.trigger as? UNTimeIntervalNotificationTrigger)?.timeInterval ?? -1 return "\(request.identifier) | \(request.content.body) | +\(Int(seconds))s" } let diag = "hours=\(hours) interval=\(Int(interval)) running=\(running.count) planned=\(planned.count)" let report = [diag] + (after.isEmpty ? ["예약 없음"] : after) + addErrors.map { "ERROR \($0)" } try? report.joined(separator: "\n") .write(to: docs.appendingPathComponent("session-alerts.txt"), atomically: true, encoding: .utf8) } #endif } } }