mycode/myApp/HaruDanim/IOS/Views/RecordEditors.swift
songyc macbook 2567b53b71 fix: correct daily goal progress calculation and optimize widget refresh logic
- 목표 하루 진행률은 오늘이 수행일인 다짐만 모수로 계산 (요일·날짜 지정으로
  오늘 쉬는 다짐은 분모에서 제외, QuestProgress.isScheduled 신설)
- 오늘 수행할 다짐이 하나도 없는 날은 0% 대신 100%(완료 상태)로 표기
- 주간/월간 진행률은 기존대로 전체 다짐 기준 유지
- 이벤트 기반 위젯 새로 고침: 앱 내 모든 핵심 Mutation(측정 시작/종료,
  횟수 추가, 기록 추가·수정·삭제, 행동·꼬리표·목표·다짐 CRUD, 순서 변경,
  목표 상태 변경)의 끝단에서 DataChange.commit() 호출 — 저장 즉시 확정 후
  Live Activity·위젯 타임라인·워치 스냅숏을 한 번에 동기화
- 주기적 백그라운드 새로 고침 없이 변경 시점에만 갱신해 WidgetKit
  업데이트 예산 절약 (타이머 표시는 기존 Text(style: .timer)로 시스템이
  자체 렌더링하므로 타임라인 소모 없음)
- 하루 시작 시간·주 시작 요일 설정 변경 시에도 위젯 집계 갱신
- 시뮬레이터 검증: 월·수·금 다짐이 있는 목표의 토요일 하루 진행률이
  33%가 아닌 50%(오늘 수행 다짐 2개 평균)로 표시됨 확인

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-11 08:30:12 +09:00

408 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// RecordEditors.swift
// Haru_Danim
//
// · ( / , CLAUDE.md §6.1, §6.5)
//
import SwiftUI
import SwiftData
// MARK: - ( )
struct ActionRecordsSheet: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let action: Action
/// true ( ' ' )
var startWithAdd = false
@State private var editingSession: TimeSession?
@State private var editingEntry: CountEntry?
@State private var showingAdd = false
private var recentSessions: [TimeSession] {
action.sessions.sorted { $0.startAt > $1.startAt }
}
private var recentEntries: [CountEntry] {
action.countEntries.sorted { $0.timestamp > $1.timestamp }
}
var body: some View {
NavigationStack {
List {
Section {
Button {
showingAdd = true
} label: {
Label(
action.trackingType == .time ? String(localized: "기록 직접 추가") : String(localized: "횟수 직접 추가"),
systemImage: "plus.circle"
)
.foregroundStyle(AppTheme.green)
}
}
Section("기록 (최신순)") {
if action.trackingType == .time {
if recentSessions.isEmpty {
Text("기록이 없어요").foregroundStyle(.secondary)
}
ForEach(recentSessions) { session in
Button {
editingSession = session
} label: {
SessionRowLabel(session: session)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions {
Button("삭제", role: .destructive) {
context.delete(session)
DataChange.commit(context: context)
}
}
}
} else {
if recentEntries.isEmpty {
Text("기록이 없어요").foregroundStyle(.secondary)
}
ForEach(recentEntries) { entry in
Button {
editingEntry = entry
} label: {
CountRowLabel(entry: entry)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions {
Button("삭제", role: .destructive) {
context.delete(entry)
DataChange.commit(context: context)
}
}
}
}
}
}
.navigationTitle("\(action.name) 기록")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("완료") { dismiss() }
}
}
.sheet(item: $editingSession) { session in
SessionEditorView(session: session)
}
.sheet(item: $editingEntry) { entry in
CountEntryEditorView(entry: entry)
}
.sheet(isPresented: $showingAdd) {
if action.trackingType == .time {
SessionAddView(action: action)
} else {
CountAddView(action: action)
}
}
.onAppear {
if startWithAdd { showingAdd = true }
}
}
}
}
struct SessionRowLabel: View {
let session: TimeSession
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(Format.fullDate(session.startAt))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if session.endAt == nil {
Text("진행 중")
.font(.caption.weight(.bold))
.foregroundStyle(AppTheme.yellow)
}
}
HStack {
Text("\(Format.time(session.startAt)) ~ \(session.endAt.map(Format.time) ?? "")")
.font(.body)
Spacer()
Text(Format.durationShort(session.duration()))
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
}
if !session.note.isEmpty {
Label(session.note, systemImage: "note.text")
.font(.caption)
.foregroundStyle(AppTheme.yellow)
.lineLimit(2)
}
}
}
}
struct CountRowLabel: View {
let entry: CountEntry
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(Format.fullDate(entry.timestamp))
.font(.caption)
.foregroundStyle(.secondary)
Text(Format.time(entry.timestamp))
.font(.body)
if !entry.note.isEmpty {
Label(entry.note, systemImage: "note.text")
.font(.caption)
.foregroundStyle(AppTheme.yellow)
.lineLimit(2)
}
}
Spacer()
Text("+\(entry.amount)")
.font(.subheadline.weight(.semibold))
.foregroundStyle(AppTheme.green)
}
}
}
// MARK: -
struct SessionEditorView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let session: TimeSession
@State private var startAt: Date = .now
@State private var isFinished = true
@State private var endAt: Date = .now
@State private var note = ""
var body: some View {
NavigationStack {
Form {
Section("시작") {
DatePicker("시작 시각", selection: $startAt)
}
Section("종료") {
Toggle("종료됨", isOn: $isFinished.animation())
if isFinished {
DatePicker("종료 시각", selection: $endAt, in: startAt...)
} else {
Text("종료 시각을 끄면 진행 중 상태가 돼요.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("메모") {
TextField("이 기록에 대한 메모 (선택)", text: $note, axis: .vertical)
.lineLimit(2...5)
}
Section {
Button("기록 삭제", role: .destructive) {
context.delete(session)
DataChange.commit(context: context)
dismiss()
}
}
}
.navigationTitle("시간 기록 수정")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") {
session.startAt = startAt
session.endAt = isFinished ? max(endAt, startAt) : nil
session.note = note.trimmingCharacters(in: .whitespacesAndNewlines)
DataChange.commit(context: context)
dismiss()
}
}
}
.onAppear {
startAt = session.startAt
note = session.note
if let end = session.endAt {
isFinished = true
endAt = end
} else {
isFinished = false
endAt = .now
}
}
}
}
}
// MARK: -
struct SessionAddView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let action: Action
@State private var startAt: Date = .now.addingTimeInterval(-3600)
@State private var endAt: Date = .now
@State private var note = ""
var body: some View {
NavigationStack {
Form {
DatePicker("시작 시각", selection: $startAt)
DatePicker("종료 시각", selection: $endAt, in: startAt...)
Section("메모") {
TextField("이 기록에 대한 메모 (선택)", text: $note, axis: .vertical)
.lineLimit(2...5)
}
}
.navigationTitle("시간 기록 추가")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("추가") {
let session = TimeSession(action: action, startAt: startAt, endAt: max(endAt, startAt))
session.note = note.trimmingCharacters(in: .whitespacesAndNewlines)
context.insert(session)
DataChange.commit(context: context)
dismiss()
}
}
}
}
}
}
// MARK: -
struct CountEntryEditorView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let entry: CountEntry
@State private var timestamp: Date = .now
@State private var amount = 1
@State private var note = ""
var body: some View {
NavigationStack {
Form {
DatePicker("기록 시각", selection: $timestamp)
Stepper(value: $amount, in: 1...9999) {
HStack {
Text("수량")
Spacer()
Text("\(amount)").foregroundStyle(.secondary)
}
}
Section("메모") {
TextField("이 기록에 대한 메모 (선택)", text: $note, axis: .vertical)
.lineLimit(2...5)
}
Section {
Button("기록 삭제 (증가 취소)", role: .destructive) {
context.delete(entry)
DataChange.commit(context: context)
dismiss()
}
}
}
.navigationTitle("횟수 기록 수정")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("저장") {
entry.timestamp = timestamp
entry.amount = amount
entry.note = note.trimmingCharacters(in: .whitespacesAndNewlines)
DataChange.commit(context: context)
dismiss()
}
}
}
.onAppear {
timestamp = entry.timestamp
amount = entry.amount
note = entry.note
}
}
}
}
// MARK: - ( / )
struct CountAddView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let action: Action
@State private var amount = 1
@State private var useNow = true
@State private var timestamp: Date = .now
@State private var note = ""
var body: some View {
NavigationStack {
Form {
Stepper(value: $amount, in: 1...9999) {
HStack {
Text("추가할 횟수")
Spacer()
Text("\(amount)").foregroundStyle(.secondary)
}
}
Section("기록 시각") {
Picker("기록 시각", selection: $useNow.animation()) {
Text("지금 시각").tag(true)
Text("특정 시각 지정").tag(false)
}
.pickerStyle(.segmented)
if !useNow {
DatePicker("시각", selection: $timestamp)
}
}
Section("메모") {
TextField("이 기록에 대한 메모 (선택)", text: $note, axis: .vertical)
.lineLimit(2...5)
}
}
.navigationTitle("횟수 추가")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("취소") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("추가") {
let entry = CountEntry(
action: action,
timestamp: useNow ? .now : timestamp,
amount: amount
)
entry.note = note.trimmingCharacters(in: .whitespacesAndNewlines)
context.insert(entry)
DataChange.commit(context: context)
dismiss()
}
}
}
}
}
}