- 장시간 측정 알림: 측정이 설정한 시간(1/2/3/6/12시간, 기본 끔)을 넘기면 로컬 알림으로 종료를 잊었음을 알려준다. LiveActivityManager.sync 길목에서 진행 세션과 예약을 동기화하므로 앱·위젯·시리·워치 어느 경로로 시작/종료/수정해도 예약이 따라가고, 세션이 끝나거나 설정이 바뀌면 기존 예약이 제거된다 (식별자에 행동·시작시각·설정시간 포함). 설정 탭 '측정' 섹션에서 켜는 순간 권한 요청. - Live Activity 타이머 상한 30일 → 1년 (상한 도달로 타이머가 멈춰 보이던 엣지 제거) - 메모 시트(측정 종료/횟수 기록) 저장 시 명시적 context.save — autosave 의존 제거 - 검증용 -longSessionAlertTestSeconds(시간 대신 N초 발화)·-alertDump (예약 상태를 Documents/session-alerts.txt로) 추가. 시뮬레이터에서 예약 생성(진행 세션 2건)과 설정 해제 시 제거를 덤프로 확인 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
126 lines
5.9 KiB
Swift
126 lines
5.9 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|
|
}
|