mycode/myApp/HaruDanim/IOS/Views/RecordEditors.swift
songyc macbook c18e3c7c6f fix(review): 전체 코드 리뷰 1차 — 데이터 복구·통계 표시·기록 편집 결함 수정
'처음 보는 리뷰어' 관점 전체 검증(핵심 계산·데이터 계층·기록/통계 완료,
나머지 영역 진행 중)에서 확인된 결함 수정. 핵심 계산부(DayMath·
QuestProgress·Models)는 발견 0건.

[M] DataStore: 스토어 손상 백업 복구 직후 legacy 이관 가드(타깃 없음)가
    다시 열려 구 샌드박스 스토어(영구 잔존)가 부활 — 수개월 전 데이터로
    조용한 롤백. App Group 플래그(migration.legacyStoreImported)로 평생
    1회 보장 (§5.1 문서화)
[M] 통계 시리즈가 행동/꼬리표 이름 키 — 동명 2개면 차트가 한 선으로 합쳐
    지고 Identifiable id 충돌, 동명 꼬리표는 합산. Format.disambiguated
    (이름 (2) 형식)로 통계 탭·통계 내보내기·⑤ 위젯 3표면 통일, 꼬리표
    집계는 identity 키로 재작성 (수치는 기존과 동일, 표시·범례만 구분)
[L] SessionAlertManager: 시작→즉시 종료 연타 시 조회(await)~추가 사이
    경합으로 종료된 세션의 장시간 알림이 잔존 — 세대 카운터로 마지막
    호출만 확정
[L] 세션 편집기: 분 절사 값을 무조건 덮어써 1분 미만 세션이 메모만
    고쳐도 0초로 파괴, 시작=종료 0길이 기록은 저장돼도 어디에도 안 보임
    — 안 움직인 필드는 원본 시각(초) 보존 + 0길이 저장 차단(문구 갱신)
[L] 타임테이블 시간축: 하루 시작이 정시가 아니면(06:30) 라벨이 시만
    표기해 최대 59분 어긋남 — 분 성분 포함(HH:mm), 화면·내보내기 동일
[L] 기록·통계 필터: 제외했던 행동을 삭제하면 잔존 ID로 칩·내보내기
    필터 문구가 허위 활성 — 실재 행동 기준으로 판정
[L] 목표 편집: 시작일을 종료일 뒤로 옮기면 종료<시작 저장 가능(DatePicker
    in: 은 표시 제약만) — 저장 시 정규화

+ QuestEditor·TagViews·GoalViews·인텐트·WidgetSupport 정독 — 추가 발견 없음
+ §15-11 서브 에이전트 금지 명문화 (사용자 지시)

검증: Debug/Store 빌드, 카탈로그 missing/stale 0 (새 문구 2키 en/ja)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 21:05:16 +09:00

544 lines
22 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
/// ()
/// ( 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:)
/// , · .
struct RecordTimeWheel: View {
let label: String
@Binding var selection: Date
var components: DatePickerComponents = [.date, .hourAndMinute]
var body: some View {
DatePicker(label, selection: $selection, displayedComponents: components)
.datePickerStyle(.wheel)
.labelsHidden()
.frame(maxWidth: .infinity)
.accessibilityLabel(label)
}
}
/// : (+ +)
/// (RecordTimeWheel)
/// .
/// / (§6.5).
struct CollapsibleTimeWheel: View {
let label: String
@Binding var selection: Date
var components: DatePickerComponents = [.date, .hourAndMinute]
@State private var isExpanded: Bool
init(label: String, selection: Binding<Date>,
components: DatePickerComponents = [.date, .hourAndMinute],
initiallyExpanded: Bool = false) {
self.label = label
self._selection = selection
self.components = components
self._isExpanded = State(initialValue: initiallyExpanded)
}
/// UIDatePicker ( 216pt) /
/// (if) 0 .
/// List/Form ""
/// ( ), .
private static let wheelHeight: CGFloat = 216
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
withAnimation(.smooth(duration: 0.3)) {
isExpanded.toggle()
}
} label: {
HStack {
Text(label)
.foregroundStyle(.primary)
Spacer()
Text(selection, format: valueFormat)
.foregroundStyle(isExpanded ? AnyShapeStyle(AppTheme.green) : AnyShapeStyle(.secondary))
Image(systemName: "chevron.down")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
.rotationEffect(.degrees(isExpanded ? 180 : 0))
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text(label))
.accessibilityValue(Text(selection, format: valueFormat))
// Color.clear (0 ) overlay
// UIDatePicker
Color.clear
.frame(height: isExpanded ? Self.wheelHeight : 0)
.overlay(alignment: .top) {
RecordTimeWheel(label: label, selection: $selection, components: components)
}
.clipped()
.opacity(isExpanded ? 1 : 0)
.allowsHitTesting(isExpanded)
.accessibilityHidden(!isExpanded)
}
}
/// · , ()+
private var valueFormat: Date.FormatStyle {
components == .hourAndMinute
? Date.FormatStyle().hour().minute()
: Date.FormatStyle().month().day().weekday(.abbreviated).hour().minute()
}
}
// 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 = ""
/// ( )
/// (§6.5) ,
/// 1 0 .
private var effectiveStart: Date {
startAt == flooredToMinute(session.startAt) ? session.startAt : startAt
}
private var effectiveEnd: Date {
guard let end = session.endAt else { return endAt }
return endAt == flooredToMinute(end) ? end : endAt
}
/// 0( ) ··
///
private var invalidOrder: Bool {
isFinished && effectiveEnd <= effectiveStart
}
var body: some View {
NavigationStack {
Form {
Section("시작") {
CollapsibleTimeWheel(label: String(localized: "시작 시각"), selection: $startAt)
}
Section("종료") {
Toggle("종료됨", isOn: $isFinished.animation())
if isFinished {
CollapsibleTimeWheel(label: String(localized: "종료 시각"), selection: $endAt)
if invalidOrder {
Text("종료 시각이 시작 시각보다 빠르거나 같아요.")
.font(.caption)
.foregroundStyle(.red)
}
} 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("저장") {
// ( )
// (14:32~15:32) (59 13) .
// (effectiveStart/End )
session.startAt = effectiveStart
session.endAt = isFinished ? effectiveEnd : nil
session.note = note.trimmingCharacters(in: .whitespacesAndNewlines)
DataChange.commit(context: context)
dismiss()
}
.disabled(invalidOrder)
}
}
.onAppear {
startAt = flooredToMinute(session.startAt)
note = session.note
if let end = session.endAt {
isFinished = true
endAt = flooredToMinute(end)
} else {
isFinished = false
endAt = flooredToMinute(.now)
}
}
}
}
}
// MARK: -
struct SessionAddView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let action: Action
@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 {
Section("시작") {
CollapsibleTimeWheel(label: String(localized: "시작 시각"), selection: $startAt)
}
Section("종료") {
CollapsibleTimeWheel(label: String(localized: "종료 시각"), selection: $endAt)
if endAt <= startAt {
Text("종료 시각이 시작 시각보다 빠르거나 같아요.")
.font(.caption)
.foregroundStyle(.red)
}
}
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()
}
.disabled(endAt <= startAt)
}
}
}
}
}
// 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 {
Section("기록 시각") {
CollapsibleTimeWheel(label: String(localized: "기록 시각"), 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 = flooredToMinute(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 = flooredToMinute(.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 {
CollapsibleTimeWheel(label: String(localized: "기록 시각"), 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()
}
}
}
}
}
}