mycode/myApp/HaruDanim/IOS/Core/SessionAlertManager.swift
songyc macbook 59d6105d21 feat(tracking): long-running session alerts, timer cap fix, memo save
- 장시간 측정 알림: 측정이 설정한 시간(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
2026-07-14 00:04:08 +09:00

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
}
}
}