- 시각 입력 값 행의 펼침/접힘이 "확" 스냅된다는 실기기 피드백 → 조건부 삽입(if) 대신 높이 0↔216pt 프레임 보간(.smooth 0.3s, 바운스 없음)으로 변경 — List/Form 행 높이가 매 프레임 따라와 부드럽게 열리고 닫힘. 공용 CollapsibleTimeWheel 한 곳 수정으로 6곳(기록 편집 4시트·마감 시각·하루 시작 시간) 전부 적용 - 휠은 Color.clear의 overlay(top 고정)로 띄움 — UIDatePicker의 고유 높이(216)가 레이아웃에 개입해 접힘 상태에 빈 공간이 남던 문제(1차 구현에서 발견) 방지. 접힘 시 clipped+터치 차단+보이스오버 숨김 - 값 반영 경로·검증(종료<시작 경고 등) 불변, 새 문자열 없음(카탈로그 클린) - 검증: 세션 수정 접힘(빈 공간 없음)·설정 펼침 시뮬 스크린샷, Debug/Store 빌드 성공. CLAUDE.md §6.5에 보간 구조 유지 규칙 기재 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
525 lines
21 KiB
Swift
525 lines
21 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 = ""
|
||
|
||
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 endAt < startAt {
|
||
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초)가 어긋난다
|
||
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 = 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()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|