diff --git a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate index f6bd72e..7fd1356 100644 Binary files a/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate and b/myApp/Haru_Danim/Haru_Danim.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/myApp/Haru_Danim/IOS/Core/DebugSeed.swift b/myApp/Haru_Danim/IOS/Core/DebugSeed.swift index a2108fc..677b5fc 100644 --- a/myApp/Haru_Danim/IOS/Core/DebugSeed.swift +++ b/myApp/Haru_Danim/IOS/Core/DebugSeed.swift @@ -36,6 +36,7 @@ enum DebugSeed { let reading = Action(name: "독서", symbolName: "book.fill", trackingType: .time, sortOrder: 0) reading.tags = [study] reading.isFavorite = true + reading.promptsForNote = true let english = Action(name: "영어 공부", symbolName: "graduationcap.fill", trackingType: .time, sortOrder: 1) english.tags = [study] let running = Action(name: "달리기", symbolName: "figure.run", trackingType: .time, sortOrder: 2) @@ -44,6 +45,7 @@ enum DebugSeed { pushup.tags = [workout] let water = Action(name: "물 마시기", symbolName: "drop.fill", trackingType: .count, sortOrder: 4) water.tags = [life] + water.promptsForNote = true for action in [reading, english, running, pushup, water] { context.insert(action) } @@ -81,6 +83,10 @@ enum DebugSeed { context.insert(CountEntry(action: pushup, timestamp: at(daysAgo: daysAgo, hour: 19), amount: 20)) } } + // 메모가 달린 횟수 기록 (기록 탭 메모 표시 확인용) + let notedWater = CountEntry(action: water, timestamp: at(daysAgo: 0, hour: 20)) + notedWater.note = "자기 전 물 한 컵" + context.insert(notedWater) // 목표 + 다짐 let toeic = Goal( diff --git a/myApp/Haru_Danim/IOS/Core/Models.swift b/myApp/Haru_Danim/IOS/Core/Models.swift index ebc74ad..6bee071 100644 --- a/myApp/Haru_Danim/IOS/Core/Models.swift +++ b/myApp/Haru_Danim/IOS/Core/Models.swift @@ -72,6 +72,8 @@ final class Action { var sortOrder: Int = 0 /// 행동 탭에서 최상단 "즐겨찾기" 섹션에 표시 var isFavorite: Bool = false + /// 기록 시(시간 측정 종료 / 횟수 추가) 메모 작성 창을 띄울지 + var promptsForNote: Bool = false var createdAt: Date = Date() @Relationship(inverse: \Tag.actions) @@ -148,6 +150,8 @@ extension TimeSession { final class CountEntry { var timestamp: Date = Date() var amount: Int = 1 + /// 기록에 남기는 메모 (선택) + var note: String = "" var action: Action? diff --git a/myApp/Haru_Danim/IOS/Localizable.xcstrings b/myApp/Haru_Danim/IOS/Localizable.xcstrings index 4cb570d..cbb5855 100644 --- a/myApp/Haru_Danim/IOS/Localizable.xcstrings +++ b/myApp/Haru_Danim/IOS/Localizable.xcstrings @@ -65,6 +65,9 @@ }, "+%lld" : { + }, + "+%lld 기록 완료" : { + }, "1.0" : { @@ -107,6 +110,9 @@ }, "기록 삭제 (증가 취소)" : { + }, + "기록 시 메모 창 표시" : { + }, "기록 시각" : { @@ -443,6 +449,9 @@ }, "이름" : { + }, + "이번 기록에 대한 메모 (선택)" : { + }, "이번 측정에 대한 메모 (선택)" : { @@ -524,6 +533,12 @@ }, "측정 기준" : { + }, + "켜면 시간 측정을 종료할 때 메모 창이 떠요. 건너뛸 수 있어요." : { + + }, + "켜면 횟수를 추가할 때마다 메모 창이 떠요. 건너뛸 수 있어요." : { + }, "큰 목표를 세우고, 그 안에 다짐을 추가해 보세요." : { @@ -616,6 +631,9 @@ }, "확인" : { + }, + "회" : { + }, "횟수" : { diff --git a/myApp/Haru_Danim/IOS/Views/ActionViews.swift b/myApp/Haru_Danim/IOS/Views/ActionViews.swift index d770734..72c3988 100644 --- a/myApp/Haru_Danim/IOS/Views/ActionViews.swift +++ b/myApp/Haru_Danim/IOS/Views/ActionViews.swift @@ -220,6 +220,14 @@ struct ActionDetailView: View { .foregroundStyle(AppTheme.yellow) } } + Toggle(isOn: Bindable(action).promptsForNote) { + Label { + Text("기록 시 메모 창 표시") + } icon: { + Image(systemName: "note.text") + .foregroundStyle(AppTheme.green) + } + } HStack { Text("아이콘") Spacer() @@ -320,6 +328,7 @@ struct ActionEditorView: View { @State private var name = "" @State private var symbolName = "star.fill" @State private var trackingType: TrackingType = .time + @State private var promptsForNote = false @State private var selectedTags: Set = [] @State private var showingSymbolPicker = false @State private var showingNewTag = false @@ -388,6 +397,20 @@ struct ActionEditorView: View { .pickerStyle(.inline) .labelsHidden() } + Section { + Toggle(isOn: $promptsForNote) { + Label { + Text("기록 시 메모 창 표시") + } icon: { + Image(systemName: "note.text") + .foregroundStyle(AppTheme.green) + } + } + } footer: { + Text(trackingType == .time + ? "켜면 시간 측정을 종료할 때 메모 창이 떠요. 건너뛸 수 있어요." + : "켜면 횟수를 추가할 때마다 메모 창이 떠요. 건너뛸 수 있어요.") + } } .navigationTitle(action == nil ? "행동 추가" : "행동 수정") .navigationBarTitleDisplayMode(.inline) @@ -415,6 +438,7 @@ struct ActionEditorView: View { name = action.name symbolName = action.symbolName trackingType = action.trackingType + promptsForNote = action.promptsForNote selectedTags = Set(action.tags.map(\.persistentModelID)) } @@ -433,6 +457,7 @@ struct ActionEditorView: View { action.name = trimmed action.symbolName = symbolName action.trackingType = trackingType + action.promptsForNote = promptsForNote action.tags = tags } else { let maxOrder = allActions.map(\.sortOrder).max() ?? -1 @@ -442,6 +467,7 @@ struct ActionEditorView: View { trackingType: trackingType, sortOrder: maxOrder + 1 ) + newAction.promptsForNote = promptsForNote newAction.tags = tags context.insert(newAction) } diff --git a/myApp/Haru_Danim/IOS/Views/HistoryView.swift b/myApp/Haru_Danim/IOS/Views/HistoryView.swift index 2138e4f..9a94728 100644 --- a/myApp/Haru_Danim/IOS/Views/HistoryView.swift +++ b/myApp/Haru_Danim/IOS/Views/HistoryView.swift @@ -279,6 +279,13 @@ struct HistoryView: View { Text(Format.time(item.entry.timestamp)) .font(.caption) .foregroundStyle(.secondary) + if !item.entry.note.isEmpty { + Label(item.entry.note, systemImage: "note.text") + .font(.caption) + .foregroundStyle(AppTheme.yellow) + .lineLimit(2) + .padding(.top, 1) + } } Spacer() Text("+\(item.entry.amount)") diff --git a/myApp/Haru_Danim/IOS/Views/MainView.swift b/myApp/Haru_Danim/IOS/Views/MainView.swift index 23caaa0..201daa6 100644 --- a/myApp/Haru_Danim/IOS/Views/MainView.swift +++ b/myApp/Haru_Danim/IOS/Views/MainView.swift @@ -30,6 +30,7 @@ struct MainView: View { @State private var recordsAction: Action? @State private var deletingAction: Action? @State private var memoSession: TimeSession? + @State private var memoEntry: CountEntry? private var anyRunning: Bool { !runningSessions.isEmpty } @@ -71,6 +72,9 @@ struct MainView: View { .sheet(item: $memoSession) { session in SessionMemoSheet(session: session) } + .sheet(item: $memoEntry) { entry in + CountMemoSheet(entry: entry) + } .alert( "행동 삭제", isPresented: Binding( @@ -219,18 +223,24 @@ struct MainView: View { LiveActivityManager.sync(context: context) } case .count: - context.insert(CountEntry(action: action, timestamp: .now)) + let entry = CountEntry(action: action, timestamp: .now) + context.insert(entry) + if action.promptsForNote { + memoEntry = entry + } } } /// 세션 종료. "짧은 기록 무시" 설정보다 짧으면 기록하지 않고 삭제 (실수 방지) - /// 정상 기록된 세션은 메모 작성 시트를 띄운다 (건너뛰기 가능). + /// 정상 기록된 세션은 행동의 메모 옵션이 켜져 있으면 메모 작성 시트를 띄운다 (건너뛰기 가능). private func finish(_ session: TimeSession) { if minSessionSeconds > 0, session.duration() < Double(minSessionSeconds) { context.delete(session) } else { session.endAt = .now - memoSession = session + if session.action?.promptsForNote == true { + memoSession = session + } } LiveActivityManager.sync(context: context) } @@ -309,6 +319,63 @@ struct SessionMemoSheet: View { } } +// MARK: - 횟수 기록 메모 시트 + +struct CountMemoSheet: View { + @Environment(\.dismiss) private var dismiss + let entry: CountEntry + + @State private var text = "" + @FocusState private var focused: Bool + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 12) { + if let action = entry.action { + HStack(spacing: 10) { + Image(systemName: action.symbolName) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 32, height: 32) + .background(action.color, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + VStack(alignment: .leading, spacing: 1) { + Text(action.name) + .font(.subheadline.weight(.semibold)) + Text("+\(entry.amount) 기록 완료") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + TextField("이번 기록에 대한 메모 (선택)", text: $text, axis: .vertical) + .lineLimit(3...5) + .padding(10) + .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .focused($focused) + Spacer() + } + .padding() + .background(AppTheme.background) + .navigationTitle("메모 남기기") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("건너뛰기") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("저장") { + entry.note = text.trimmingCharacters(in: .whitespacesAndNewlines) + dismiss() + } + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + .presentationDetents([.height(280)]) + .onAppear { text = entry.note } + } +} + // MARK: - 지글(흔들림) 애니메이션 struct JiggleEffect: ViewModifier { @@ -414,15 +481,14 @@ struct ActionButtonCell: View { var body: some View { Button { onTap() - if action.trackingType == .count { - bounce.toggle() - } + bounce.toggle() } label: { VStack(alignment: .leading, spacing: compact ? 3 : 6) { HStack { Image(systemName: action.symbolName) .font(.system(size: compact ? 17 : 26, weight: .medium)) .foregroundStyle(.white) + .symbolEffect(.bounce, value: bounce) Spacer() if action.isRunning && !compact { Image(systemName: "record.circle") @@ -454,7 +520,7 @@ struct ActionButtonCell: View { } } .buttonStyle(PressableButtonStyle()) - .sensoryFeedback(.impact(weight: .light), trigger: bounce) + .sensoryFeedback(.impact(weight: .medium), trigger: bounce) .disabled(isEditing) } @@ -475,30 +541,49 @@ struct ActionButtonCell: View { .lineLimit(1) } case .count: - Text(todayLabel) - .font(.caption2) - .foregroundStyle(.white.opacity(0.85)) - .lineLimit(1) + HStack(alignment: .firstTextBaseline, spacing: compact ? 2 : 3) { + if !compact { + Text("오늘") + .font(.caption2) + .foregroundStyle(.white.opacity(0.75)) + } + Text("\(todayCount)") + .font(.system(size: compact ? 18 : 27, weight: .heavy, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .contentTransition(.numericText(value: Double(todayCount))) + Text("회") + .font((compact ? Font.caption2 : Font.caption).weight(.semibold)) + .foregroundStyle(.white.opacity(0.85)) + } + .lineLimit(1) + .animation(.snappy(duration: 0.3), value: todayCount) } } private var todayLabel: String { let agg = Aggregator() - switch action.trackingType { - case .time: - let seconds = agg.seconds(for: action, in: agg.math.dayRange(containing: .now)) - return seconds > 0 ? "오늘 \(Format.durationShort(seconds))" : "오늘 0분" - case .count: - let count = agg.count(for: action, in: agg.math.dayRange(containing: .now)) - return "오늘 \(count)회" - } + let seconds = agg.seconds(for: action, in: agg.math.dayRange(containing: .now)) + return seconds > 0 ? "오늘 \(Format.durationShort(seconds))" : "오늘 0분" + } + + private var todayCount: Int { + let agg = Aggregator() + return agg.count(for: action, in: agg.math.dayRange(containing: .now)) } } +/// 누르면 크게 줄어들었다가 놓으면 튀어오르듯 복귀 (감쇠 낮은 스프링으로 오버슈트) struct PressableButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .scaleEffect(configuration.isPressed ? 0.93 : 1) - .animation(.spring(duration: 0.2), value: configuration.isPressed) + .scaleEffect(configuration.isPressed ? 0.85 : 1) + .brightness(configuration.isPressed ? 0.08 : 0) + .animation( + configuration.isPressed + ? .spring(duration: 0.12) + : .spring(response: 0.35, dampingFraction: 0.45), + value: configuration.isPressed + ) } } diff --git a/myApp/Haru_Danim/IOS/Views/RecordEditors.swift b/myApp/Haru_Danim/IOS/Views/RecordEditors.swift index 8f3edb7..bf8030f 100644 --- a/myApp/Haru_Danim/IOS/Views/RecordEditors.swift +++ b/myApp/Haru_Danim/IOS/Views/RecordEditors.swift @@ -149,6 +149,12 @@ struct CountRowLabel: View { .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)") @@ -278,6 +284,7 @@ struct CountEntryEditorView: View { @State private var timestamp: Date = .now @State private var amount = 1 + @State private var note = "" var body: some View { NavigationStack { @@ -290,6 +297,10 @@ struct CountEntryEditorView: View { Text("\(amount)회").foregroundStyle(.secondary) } } + Section("메모") { + TextField("이 기록에 대한 메모 (선택)", text: $note, axis: .vertical) + .lineLimit(2...5) + } Section { Button("기록 삭제 (증가 취소)", role: .destructive) { context.delete(entry) @@ -307,6 +318,7 @@ struct CountEntryEditorView: View { Button("저장") { entry.timestamp = timestamp entry.amount = amount + entry.note = note.trimmingCharacters(in: .whitespacesAndNewlines) dismiss() } } @@ -314,6 +326,7 @@ struct CountEntryEditorView: View { .onAppear { timestamp = entry.timestamp amount = entry.amount + note = entry.note } } } @@ -329,6 +342,7 @@ struct CountAddView: View { @State private var amount = 1 @State private var useNow = true @State private var timestamp: Date = .now + @State private var note = "" var body: some View { NavigationStack { @@ -350,6 +364,10 @@ struct CountAddView: View { DatePicker("시각", selection: $timestamp) } } + Section("메모") { + TextField("이 기록에 대한 메모 (선택)", text: $note, axis: .vertical) + .lineLimit(2...5) + } } .navigationTitle("횟수 추가") .navigationBarTitleDisplayMode(.inline) @@ -359,11 +377,13 @@ struct CountAddView: View { } ToolbarItem(placement: .confirmationAction) { Button("추가") { - context.insert(CountEntry( + let entry = CountEntry( action: action, timestamp: useNow ? .now : timestamp, amount: amount - )) + ) + entry.note = note.trimmingCharacters(in: .whitespacesAndNewlines) + context.insert(entry) dismiss() } }