mycode/myApp/HaruDanim/IOS/Views/RecordEditors.swift
songyc macbook 459c85700f fix(ui): smooth expand/collapse animation for time input rows
- 시각 입력 값 행의 펼침/접힘이 "확" 스냅된다는 실기기 피드백 → 조건부 삽입(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
2026-07-19 09:59:33 +09:00

525 lines
21 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 = ""
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()
}
}
}
}
}
}