mycode/myApp/HaruDanim/IOS/Views/RecordEditors.swift
songyc macbook a400a71896 feat(ui): unify time wheels + diary calendar mood-photo backlayer
- 다짐 '마감 시각'·설정 '하루 시작 시간'의 컴팩트 시각 픽커도 RecordTimeWheel(시·분 전용)로 교체 — 기록 편집기와 같은 팝오버 커밋 유실 기전 제거, 앱의 시각 다이얼 전부 상시 노출 휠로 통일
- 일기 달력: 기분 사진이 있는 날짜는 셀 배경(백레이어)에 사진 썸네일 표시 — 이모지/연필 마커·오늘 표시는 그대로 위에 유지, 사진 없으면 기존과 동일. ImageIO ≤220px 축소 + NSCache(DiaryMoodThumbs)로 한 달치 렌더 부담 최소화, 검은 스크림 0.16+흰 날짜·마커 그림자로 가독성 확보
- 사진만 있는 엔트리는 hasContent가 moodImageData를 포함해 빈 엔트리 정리에서 보존됨(기존 로직 확인)
- 검증 인자 -diarySeedPhoto 신설(오늘=이모지+사진·어제=사진만) — iPad 시뮬 달력 스크린샷으로 백레이어·마커 확인, 마감 시각·하루 시작 시간 휠 스크린샷 확인
- l10n 무변화(기존 키 재사용, 카탈로그 클린). CLAUDE.md §6.5·§6.7·§14 갱신. Debug/Store 두 스킴 빌드 성공

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
2026-07-18 00:03:04 +09:00

456 lines
17 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)
}
}
// 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("시작") {
RecordTimeWheel(label: String(localized: "시작 시각"), selection: $startAt)
}
Section("종료") {
Toggle("종료됨", isOn: $isFinished.animation())
if isFinished {
RecordTimeWheel(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("시작") {
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)
}
}
.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("기록 시각") {
RecordTimeWheel(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 {
RecordTimeWheel(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()
}
}
}
}
}
}