'처음 보는 리뷰어' 관점 전체 검증(핵심 계산·데이터 계층·기록/통계 완료,
나머지 영역 진행 중)에서 확인된 결함 수정. 핵심 계산부(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>
544 lines
22 KiB
Swift
544 lines
22 KiB
Swift
//
|
||
// 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()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|