- 다짐 '마감 시각'·설정 '하루 시작 시간'의 컴팩트 시각 픽커도 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
456 lines
17 KiB
Swift
456 lines
17 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)
|
||
}
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|