'처음 보는 리뷰어' 관점 전체 검증(핵심 계산·데이터 계층·기록/통계 완료,
나머지 영역 진행 중)에서 확인된 결함 수정. 핵심 계산부(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>
136 lines
6.6 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|