mycode/myApp/HaruDanim/IOS/Core/SessionAlertManager.swift
songyc macbook c18e3c7c6f fix(review): 전체 코드 리뷰 1차 — 데이터 복구·통계 표시·기록 편집 결함 수정
'처음 보는 리뷰어' 관점 전체 검증(핵심 계산·데이터 계층·기록/통계 완료,
나머지 영역 진행 중)에서 확인된 결함 수정. 핵심 계산부(DayMath·
QuestProgress·Models)는 발견 0건.

[M] DataStore: 스토어 손상 백업 복구 직후 legacy 이관 가드(타깃 없음)가
    다시 열려 구 샌드박스 스토어(영구 잔존)가 부활 — 수개월 전 데이터로
    조용한 롤백. App Group 플래그(migration.legacyStoreImported)로 평생
    1회 보장 (§5.1 문서화)
[M] 통계 시리즈가 행동/꼬리표 이름 키 — 동명 2개면 차트가 한 선으로 합쳐
    지고 Identifiable id 충돌, 동명 꼬리표는 합산. Format.disambiguated
    (이름 (2) 형식)로 통계 탭·통계 내보내기·⑤ 위젯 3표면 통일, 꼬리표
    집계는 identity 키로 재작성 (수치는 기존과 동일, 표시·범례만 구분)
[L] SessionAlertManager: 시작→즉시 종료 연타 시 조회(await)~추가 사이
    경합으로 종료된 세션의 장시간 알림이 잔존 — 세대 카운터로 마지막
    호출만 확정
[L] 세션 편집기: 분 절사 값을 무조건 덮어써 1분 미만 세션이 메모만
    고쳐도 0초로 파괴, 시작=종료 0길이 기록은 저장돼도 어디에도 안 보임
    — 안 움직인 필드는 원본 시각(초) 보존 + 0길이 저장 차단(문구 갱신)
[L] 타임테이블 시간축: 하루 시작이 정시가 아니면(06:30) 라벨이 시만
    표기해 최대 59분 어긋남 — 분 성분 포함(HH:mm), 화면·내보내기 동일
[L] 기록·통계 필터: 제외했던 행동을 삭제하면 잔존 ID로 칩·내보내기
    필터 문구가 허위 활성 — 실재 행동 기준으로 판정
[L] 목표 편집: 시작일을 종료일 뒤로 옮기면 종료<시작 저장 가능(DatePicker
    in: 은 표시 제약만) — 저장 시 정규화

+ QuestEditor·TagViews·GoalViews·인텐트·WidgetSupport 정독 — 추가 발견 없음
+ §15-11 서브 에이전트 금지 명문화 (사용자 지시)

검증: Debug/Store 빌드, 카탈로그 missing/stale 0 (새 문구 2키 en/ja)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 21:05:16 +09:00

136 lines
6.6 KiB
Swift

//
// SessionAlertManager.swift
// Haru_Danim
//
// .
// (settings.longSessionAlertHours) .
// LiveActivityManager.sync
// · ·· /
// (LiveActivityIntent ).
//
import Foundation
import UserNotifications
@MainActor
enum SessionAlertManager {
private static let idPrefix = "longSession-"
/// ( ) sync ,
/// '(await)'
/// . .
private static var generation = 0
/// (). 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
))
}
}
generation += 1
let gen = generation
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()
// sync
guard gen == generation else { return }
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) {
guard gen == generation else { return }
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
}
}
}