From 59d6105d210544b6fd515ae1e120e30480c33db1 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Tue, 14 Jul 2026 00:04:08 +0900 Subject: [PATCH] feat(tracking): long-running session alerts, timer cap fix, memo save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 장시간 측정 알림: 측정이 설정한 시간(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 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- .../IOS/Core/LiveActivityManager.swift | 7 +- .../IOS/Core/SessionAlertManager.swift | 125 ++++++++++++++++++ myApp/HaruDanim/IOS/Views/MainView.swift | 4 + myApp/HaruDanim/IOS/Views/SettingsView.swift | 14 +- myApp/HaruDanim/Shared/Settings.swift | 14 ++ .../Shared/TrackingActivityAttributes.swift | 6 +- 6 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 myApp/HaruDanim/IOS/Core/SessionAlertManager.swift diff --git a/myApp/HaruDanim/IOS/Core/LiveActivityManager.swift b/myApp/HaruDanim/IOS/Core/LiveActivityManager.swift index ca629c3..d5ff7d4 100644 --- a/myApp/HaruDanim/IOS/Core/LiveActivityManager.swift +++ b/myApp/HaruDanim/IOS/Core/LiveActivityManager.swift @@ -17,14 +17,17 @@ enum LiveActivityManager { /// 현재 진행 중인 세션들을 읽어 Live Activity를 시작/갱신/종료한다. /// 세션 시작·종료·수정 후마다 호출. static func sync(context: ModelContext) { - guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } - let descriptor = FetchDescriptor( 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) ?? "" diff --git a/myApp/HaruDanim/IOS/Core/SessionAlertManager.swift b/myApp/HaruDanim/IOS/Core/SessionAlertManager.swift new file mode 100644 index 0000000..9d2b258 --- /dev/null +++ b/myApp/HaruDanim/IOS/Core/SessionAlertManager.swift @@ -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 + } + } +} diff --git a/myApp/HaruDanim/IOS/Views/MainView.swift b/myApp/HaruDanim/IOS/Views/MainView.swift index 0582e77..0a00cc2 100644 --- a/myApp/HaruDanim/IOS/Views/MainView.swift +++ b/myApp/HaruDanim/IOS/Views/MainView.swift @@ -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) diff --git a/myApp/HaruDanim/IOS/Views/SettingsView.swift b/myApp/HaruDanim/IOS/Views/SettingsView.swift index 23bec6c..0dabfd2 100644 --- a/myApp/HaruDanim/IOS/Views/SettingsView.swift +++ b/myApp/HaruDanim/IOS/Views/SettingsView.swift @@ -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") diff --git a/myApp/HaruDanim/Shared/Settings.swift b/myApp/HaruDanim/Shared/Settings.swift index 041bf2e..0f2e0bc 100644 --- a/myApp/HaruDanim/Shared/Settings.swift +++ b/myApp/HaruDanim/Shared/Settings.swift @@ -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)] = [ diff --git a/myApp/HaruDanim/Shared/TrackingActivityAttributes.swift b/myApp/HaruDanim/Shared/TrackingActivityAttributes.swift index 5785854..c3a5263 100644 --- a/myApp/HaruDanim/Shared/TrackingActivityAttributes.swift +++ b/myApp/HaruDanim/Shared/TrackingActivityAttributes.swift @@ -33,8 +33,10 @@ extension TrackingActivityAttributes.ContentState { ) } - /// 타이머 표시용 구간 (상한은 충분히 먼 미래) + /// 타이머 표시용 구간. ClosedRange가 유한해야 해서 상한을 두되, + /// 30일이던 것을 1년으로 늘려 상한 도달로 타이머가 멈춰 보이는 일이 없게 한다 + /// (실사용에선 장시간 측정 알림이 훨씬 먼저 개입한다) var timerRange: ClosedRange { - startedAt...startedAt.addingTimeInterval(60 * 60 * 24 * 30) + startedAt...startedAt.addingTimeInterval(60 * 60 * 24 * 365) } }