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
This commit is contained in:
parent
b980d7df7a
commit
59d6105d21
@ -17,14 +17,17 @@ enum LiveActivityManager {
|
||||
/// 현재 진행 중인 세션들을 읽어 Live Activity를 시작/갱신/종료한다.
|
||||
/// 세션 시작·종료·수정 후마다 호출.
|
||||
static func sync(context: ModelContext) {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||
|
||||
let descriptor = FetchDescriptor<TimeSession>(
|
||||
predicate: #Predicate { $0.endAt == nil },
|
||||
sortBy: [SortDescriptor(\.startAt, order: .forward)]
|
||||
)
|
||||
let running = (try? context.fetch(descriptor)) ?? []
|
||||
|
||||
// 장시간 측정 알림 예약도 같은 길목에서 동기화 (Live Activity 허용 여부와 무관)
|
||||
SessionAlertManager.sync(running: running)
|
||||
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||
|
||||
let mode = LiveActivityMode(
|
||||
rawValue: AppGroup.defaults.string(forKey: SettingsKeys.liveActivityMode)
|
||||
?? UserDefaults.standard.string(forKey: SettingsKeys.liveActivityMode) ?? ""
|
||||
|
||||
125
myApp/HaruDanim/IOS/Core/SessionAlertManager.swift
Normal file
125
myApp/HaruDanim/IOS/Core/SessionAlertManager.swift
Normal file
@ -0,0 +1,125 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -596,6 +596,7 @@ struct GoalSummaryCard: View {
|
||||
|
||||
struct SessionMemoSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var context
|
||||
let session: TimeSession
|
||||
|
||||
@State private var text = ""
|
||||
@ -638,6 +639,7 @@ struct SessionMemoSheet: View {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("저장") {
|
||||
session.note = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
try? context.save()
|
||||
dismiss()
|
||||
}
|
||||
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
@ -653,6 +655,7 @@ struct SessionMemoSheet: View {
|
||||
|
||||
struct CountMemoSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var context
|
||||
let entry: CountEntry
|
||||
|
||||
@State private var text = ""
|
||||
@ -695,6 +698,7 @@ struct CountMemoSheet: View {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("저장") {
|
||||
entry.note = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
try? context.save()
|
||||
dismiss()
|
||||
}
|
||||
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
@ -20,6 +20,8 @@ struct SettingsView: View {
|
||||
@AppStorage(SettingsKeys.dayStartMinutes, store: AppGroup.defaults) private var dayStartMinutes = 0
|
||||
@AppStorage(SettingsKeys.liveActivityMode, store: AppGroup.defaults) private var liveActivityMode = LiveActivityMode.latest.rawValue
|
||||
@AppStorage(SettingsKeys.minSessionSeconds, store: AppGroup.defaults) private var minSessionSeconds = 0
|
||||
@AppStorage(SettingsKeys.longSessionAlertHours, store: AppGroup.defaults) private var longSessionAlertHours = 0
|
||||
@Environment(\.modelContext) private var context
|
||||
private let premium = PremiumManager.shared
|
||||
@AppStorage(SettingsKeys.visibleTabs) private var visibleTabsRaw = TabBarConfig.defaultVisibleTabs
|
||||
// 내비게이션 방식은 이 기기에서만 쓰는 UI 설정 → 동기화하지 않는 standard 저장소 사용
|
||||
@ -135,10 +137,15 @@ struct SettingsView: View {
|
||||
Text(option.label).tag(option.seconds)
|
||||
}
|
||||
}
|
||||
Picker("장시간 측정 알림", selection: $longSessionAlertHours) {
|
||||
ForEach(LongSessionAlertOption.choices, id: \.hours) { option in
|
||||
Text(option.label).tag(option.hours)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("측정")
|
||||
} footer: {
|
||||
Text("설정한 시간보다 짧게 측정하고 종료한 기록은 저장하지 않아요. 버튼을 실수로 눌렀을 때 유용해요.")
|
||||
Text("설정한 시간보다 짧게 측정하고 종료한 기록은 저장하지 않아요. 버튼을 실수로 눌렀을 때 유용해요. 장시간 측정 알림은 측정이 설정한 시간을 넘기면 알려줘요 — 끄는 걸 잊었을 때 유용해요.")
|
||||
}
|
||||
Section {
|
||||
Picker("대표 시간 기준", selection: $liveActivityMode) {
|
||||
@ -193,6 +200,11 @@ struct SettingsView: View {
|
||||
.onChange(of: dayStartMinutes) {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
.onChange(of: longSessionAlertHours) { _, newValue in
|
||||
// 켜는 순간 권한 요청 + 이미 진행 중인 측정에도 즉시 예약 반영
|
||||
if newValue > 0 { SessionAlertManager.requestAuthorizationIfNeeded() }
|
||||
LiveActivityManager.sync(context: context)
|
||||
}
|
||||
.onChange(of: language) {
|
||||
// 시스템의 앱별 언어 오버라이드 방식: 다음 실행부터 적용된다
|
||||
UserDefaults.standard.set([language], forKey: "AppleLanguages")
|
||||
|
||||
@ -23,6 +23,8 @@ enum SettingsKeys {
|
||||
static let gridColumns = "settings.gridColumns"
|
||||
/// 이 시간(초)보다 짧게 측정된 세션은 기록하지 않음. 0 = 사용 안 함
|
||||
static let minSessionSeconds = "settings.minSessionSeconds"
|
||||
/// 측정이 이 시간(시)을 넘기면 로컬 알림 (끄는 걸 잊음 방지). 0 = 사용 안 함
|
||||
static let longSessionAlertHours = "settings.longSessionAlertHours"
|
||||
/// 탭바에 직접 노출할 탭(더보기 제외 1~3개). AppTab rawValue를 쉼표로 연결한 문자열
|
||||
static let visibleTabs = "settings.visibleTabs"
|
||||
/// 모음 탭 목표 카드 표시 방식. GoalCardStyle rawValue ("perQuest" | "combined")
|
||||
@ -74,6 +76,18 @@ enum TabBarConfig {
|
||||
static let minVisible = 1
|
||||
}
|
||||
|
||||
/// 장시간 측정 알림 옵션 선택지 (시간)
|
||||
enum LongSessionAlertOption {
|
||||
static let choices: [(hours: Int, label: String)] = [
|
||||
(0, String(localized: "사용 안 함")),
|
||||
(1, String(localized: "1시간")),
|
||||
(2, String(localized: "2시간")),
|
||||
(3, String(localized: "3시간")),
|
||||
(6, String(localized: "6시간")),
|
||||
(12, String(localized: "12시간")),
|
||||
]
|
||||
}
|
||||
|
||||
/// 짧은 기록 무시 옵션 선택지 (초)
|
||||
enum MinSessionOption {
|
||||
static let choices: [(seconds: Int, label: String)] = [
|
||||
|
||||
@ -33,8 +33,10 @@ extension TrackingActivityAttributes.ContentState {
|
||||
)
|
||||
}
|
||||
|
||||
/// 타이머 표시용 구간 (상한은 충분히 먼 미래)
|
||||
/// 타이머 표시용 구간. ClosedRange가 유한해야 해서 상한을 두되,
|
||||
/// 30일이던 것을 1년으로 늘려 상한 도달로 타이머가 멈춰 보이는 일이 없게 한다
|
||||
/// (실사용에선 장시간 측정 알림이 훨씬 먼저 개입한다)
|
||||
var timerRange: ClosedRange<Date> {
|
||||
startedAt...startedAt.addingTimeInterval(60 * 60 * 24 * 30)
|
||||
startedAt...startedAt.addingTimeInterval(60 * 60 * 24 * 365)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user