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:
songyc macbook 2026-07-14 00:04:08 +09:00
parent b980d7df7a
commit 59d6105d21
6 changed files with 165 additions and 5 deletions

View File

@ -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) ?? ""

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

View File

@ -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)

View File

@ -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")

View File

@ -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)] = [

View File

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