- 목표 하루 진행률은 오늘이 수행일인 다짐만 모수로 계산 (요일·날짜 지정으로 오늘 쉬는 다짐은 분모에서 제외, 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
408 lines
15 KiB
Swift
408 lines
15 KiB
Swift
//
|
||
// 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()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|