diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index 36e62a0..9303bc8 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -199,7 +199,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f ### 6.5 기록 탭 (`HistoryView`, `RecordEditors`) - **범위 조회**: 기록은 전량 로드하지 않고 선택 날짜가 속한 **주 범위**만 @Query로 가져온다(`HistoryRecordsView` — SwiftData 동적 쿼리 패턴: 날짜가 바뀌면 새 조건으로 재생성). 하루 경계 걸침·진행 중 세션은 "시작 < 범위끝 AND (종료 없음 OR 종료 > 범위시작)" 겹침 조건으로 포함 - 날짜 헤더(화살표 이동, 날짜 탭=달력 팝오버, "오늘" 버튼) + 목록/타임테이블 세그먼트 -- 목록: 시간 기록(시작~종료·총 시간·메모)·횟수 기록(시각·+n·메모), 탭하면 수정 시트 +- 목록: 시간 기록(시작~종료·총 시간·메모)·횟수 기록(시각·+n·메모), 탭하면 수정 시트. **수정·추가 시트의 시각 입력은 상시 노출 휠 다이얼**(`RecordEditors.swift` RecordTimeWheel) — 컴팩트 팝오버는 다이얼 감속 중 닫히면 정착값이 유실되는 실기기 보고가 있어 폐기. `in:` 범위 제약도 하한 근처 선택을 소리 없이 되감아 금지(검증은 종료<시작 경고 문구+저장 비활성). 편집 상태는 분 단위 절사(flooredToMinute) — 실측정 기록의 초 찌꺼기(14:32:47)가 남으면 다이얼로 맞춘 1시간이 59분으로 표시되는 어긋남 방지. 마감 시각(다짐)·하루 시작 시간(설정)의 hourAndMinute 컴팩트 픽커는 편집 빈도가 낮아 유지 — 같은 증상 보고 시 RecordTimeWheel 패턴 적용 - 타임테이블: 24시간 축(하루 시작 시간 기준), 하루/일주일 전환(주간 보기에서 화살표는 7일씩 점프). 시간=색 블록, 횟수=색 점. 블록/점 탭=수정 - 필터: `RecordFilterSheet`(공용) — 꼬리표 아래 행동이 그룹된 트리, **"제외한 행동 ID" 집합**으로 관리해 기본이 전체 선택. 활성 시 초록 칩 표시 - 내보내기: 현재 날짜/모드/필터 그대로 `ExportBuilder.history` 스냅숏 → 이미지 시트 @@ -328,10 +328,10 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f | 영역 | 인자 | |---|---| -| 모음 | `-startEditing` `-expandGoalCard` `-pinGoals ` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` | +| 모음 | `-startEditing` `-expandGoalCard` `-pinGoals ` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` `-recordAddFor "이름"`(기록 직접 입력 시트) | | 측정 알림 | `-settings.longSessionAlertHours `(설정 주입) `-longSessionAlertTestSeconds `(시간 대신 N초 발화, provisional 권한 자동) `-alertDump`(예약 상태 → Documents/session-alerts.txt) | | 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-watchGoals `(생성순 목표 N개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) `-progressSelfTest`(spanProgress 수치 37건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) | -| 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan ` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) | +| 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan ` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor `(최신 기록 수정 시트) | | 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사) `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) | | 버블 | `-radialExpanded` `-radialDoubleTapTest`(2초 뒤 FAB 더블탭 재현 — `-settings.radialDoubleTapTab <탭>`과 함께) | | 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage ` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` | diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index 3038701..dea32fb 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -9197,6 +9197,22 @@ } } }, + "종료 시각이 시작 시각보다 빨라요.": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The end time is earlier than the start time." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了時刻が開始時刻より前になっています。" + } + } + } + }, "종료됨": { "localizations": { "en": { diff --git a/myApp/HaruDanim/IOS/Views/HistoryView.swift b/myApp/HaruDanim/IOS/Views/HistoryView.swift index e000535..5ab69e8 100644 --- a/myApp/HaruDanim/IOS/Views/HistoryView.swift +++ b/myApp/HaruDanim/IOS/Views/HistoryView.swift @@ -168,6 +168,15 @@ struct HistoryView: View { if UserDefaults.standard.bool(forKey: "showExport") { showingExport = true } + // 검증용: -recordShowEditor session|count → 최신 기록의 수정 시트 표시 + switch UserDefaults.standard.string(forKey: "recordShowEditor") { + case "session": + editingSession = allActions.flatMap(\.sessions).sorted { $0.startAt > $1.startAt }.first + case "count": + editingEntry = allActions.flatMap(\.countEntries).sorted { $0.timestamp > $1.timestamp }.first + default: + break + } #endif } .onChange(of: router.pendingHistoryActionID) { diff --git a/myApp/HaruDanim/IOS/Views/MainView.swift b/myApp/HaruDanim/IOS/Views/MainView.swift index c027862..a2cb01f 100644 --- a/myApp/HaruDanim/IOS/Views/MainView.swift +++ b/myApp/HaruDanim/IOS/Views/MainView.swift @@ -139,6 +139,13 @@ struct MainView: View { router.openHistory(filtering: action.persistentModelID, visibleTabsRaw: visibleTabsRaw) } } + // 검증용: -recordAddFor "행동이름" → 롱프레스 '기록 직접 입력·수정'과 동일한 시트 + if let name = UserDefaults.standard.string(forKey: "recordAddFor"), + let action = actions.first(where: { $0.name == name }) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + recordsAction = action + } + } } #endif } diff --git a/myApp/HaruDanim/IOS/Views/RecordEditors.swift b/myApp/HaruDanim/IOS/Views/RecordEditors.swift index 5107f63..80ad19e 100644 --- a/myApp/HaruDanim/IOS/Views/RecordEditors.swift +++ b/myApp/HaruDanim/IOS/Views/RecordEditors.swift @@ -8,6 +8,32 @@ import SwiftUI import SwiftData +/// 다이얼(휠)은 분 단위이므로 편집 상태는 초를 절사해서 다룬다 — +/// 실측정 기록의 초 찌꺼기(예 14:32:47)가 남으면 다이얼로 15:32를 맞춰도 +/// 실제 길이가 59분 13초가 되어 "1시간"이 아닌 "59분"으로 표시되는 어긋남이 생긴다. +private func flooredToMinute(_ date: Date) -> Date { + let comps = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date) + return Calendar.current.date(from: comps) ?? date +} + +/// 기록 편집용 시각 다이얼 — 팝오버 없이 상시 노출되는 휠. +/// 컴팩트 스타일은 팝오버 다이얼이 감속 중에 닫히면 정착값이 바인딩에 +/// 반영되지 않는 경우가 있어(사용자 실기기 보고), 다이얼 자체가 곧 표시가 +/// 되도록 휠을 인라인으로 둔다. 범위 제약(in:)은 하한 근처 선택을 소리 없이 +/// 되감아 버리므로 쓰지 않고, 검증은 호출부의 경고·저장 비활성으로 한다. +private struct RecordTimeWheel: View { + let label: String + @Binding var selection: Date + + var body: some View { + DatePicker(label, selection: $selection) + .datePickerStyle(.wheel) + .labelsHidden() + .frame(maxWidth: .infinity) + .accessibilityLabel(label) + } +} + // MARK: - 행동별 기록 관리 시트 (롱프레스 메뉴에서 진입) struct ActionRecordsSheet: View { @@ -190,12 +216,17 @@ struct SessionEditorView: View { NavigationStack { Form { Section("시작") { - DatePicker("시작 시각", selection: $startAt) + RecordTimeWheel(label: String(localized: "시작 시각"), selection: $startAt) } Section("종료") { Toggle("종료됨", isOn: $isFinished.animation()) if isFinished { - DatePicker("종료 시각", selection: $endAt, in: startAt...) + RecordTimeWheel(label: String(localized: "종료 시각"), selection: $endAt) + if endAt < startAt { + Text("종료 시각이 시작 시각보다 빨라요.") + .font(.caption) + .foregroundStyle(.red) + } } else { Text("종료 시각을 끄면 진행 중 상태가 돼요.") .font(.caption) @@ -222,23 +253,26 @@ struct SessionEditorView: View { } ToolbarItem(placement: .confirmationAction) { Button("저장") { + // 화면의 다이얼 값(분 단위) 그대로 저장 — 초 찌꺼기를 남기면 + // 표시(14:32~15:32)와 실제 길이(59분 13초)가 어긋난다 session.startAt = startAt session.endAt = isFinished ? max(endAt, startAt) : nil session.note = note.trimmingCharacters(in: .whitespacesAndNewlines) DataChange.commit(context: context) dismiss() } + .disabled(isFinished && endAt < startAt) } } .onAppear { - startAt = session.startAt + startAt = flooredToMinute(session.startAt) note = session.note if let end = session.endAt { isFinished = true - endAt = end + endAt = flooredToMinute(end) } else { isFinished = false - endAt = .now + endAt = flooredToMinute(.now) } } } @@ -252,15 +286,24 @@ struct SessionAddView: View { @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 startAt: Date = flooredToMinute(.now.addingTimeInterval(-3600)) + @State private var endAt: Date = flooredToMinute(.now) @State private var note = "" var body: some View { NavigationStack { Form { - DatePicker("시작 시각", selection: $startAt) - DatePicker("종료 시각", selection: $endAt, in: startAt...) + Section("시작") { + RecordTimeWheel(label: String(localized: "시작 시각"), selection: $startAt) + } + Section("종료") { + RecordTimeWheel(label: String(localized: "종료 시각"), selection: $endAt) + if endAt < startAt { + Text("종료 시각이 시작 시각보다 빨라요.") + .font(.caption) + .foregroundStyle(.red) + } + } Section("메모") { TextField("이 기록에 대한 메모 (선택)", text: $note, axis: .vertical) .lineLimit(2...5) @@ -280,6 +323,7 @@ struct SessionAddView: View { DataChange.commit(context: context) dismiss() } + .disabled(endAt < startAt) } } } @@ -300,7 +344,9 @@ struct CountEntryEditorView: View { var body: some View { NavigationStack { Form { - DatePicker("기록 시각", selection: $timestamp) + Section("기록 시각") { + RecordTimeWheel(label: String(localized: "기록 시각"), selection: $timestamp) + } Stepper(value: $amount, in: 1...9999) { HStack { Text("수량") @@ -337,7 +383,7 @@ struct CountEntryEditorView: View { } } .onAppear { - timestamp = entry.timestamp + timestamp = flooredToMinute(entry.timestamp) amount = entry.amount note = entry.note } @@ -354,7 +400,7 @@ struct CountAddView: View { @State private var amount = 1 @State private var useNow = true - @State private var timestamp: Date = .now + @State private var timestamp: Date = flooredToMinute(.now) @State private var note = "" var body: some View { @@ -374,7 +420,7 @@ struct CountAddView: View { } .pickerStyle(.segmented) if !useNow { - DatePicker("시각", selection: $timestamp) + RecordTimeWheel(label: String(localized: "기록 시각"), selection: $timestamp) } } Section("메모") {