From a5b1653522d1db76b698ae9c06da7970325d0687 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 14 Sep 2026 23:34:17 +0900 Subject: [PATCH 01/10] =?UTF-8?q?ui:=20iOS=20=EB=B2=84=EC=A0=84=EB=B3=84?= =?UTF-8?q?=20prominentMenu=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Extension/View+Menu.swift | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 Application/Presentation/PresentationShared/Sources/Extension/View+Menu.swift diff --git a/Application/Presentation/PresentationShared/Sources/Extension/View+Menu.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+Menu.swift new file mode 100644 index 00000000..9d3d818e --- /dev/null +++ b/Application/Presentation/PresentationShared/Sources/Extension/View+Menu.swift @@ -0,0 +1,131 @@ +// +// View+Menu.swift +// PresentationShared +// +// Created by opfic on 9/14/26. +// + +import SwiftUI + +public struct ProminentMenuItem: Identifiable { + public let action: Action + public let title: String + public let systemImage: String? + public let role: ButtonRole? + + public var id: Action { action } + + public init( + action: Action, + title: String, + systemImage: String? = nil, + role: ButtonRole? = nil + ) { + self.action = action + self.title = title + self.systemImage = systemImage + self.role = role + } +} + +public extension View { + func prominentMenu( + items: [ProminentMenuItem], + isEnabled: Bool = true, + onSelect: @escaping (Action) -> Void + ) -> some View { + ProminentMenu( + label: self, + items: items, + isEnabled: isEnabled, + onSelect: onSelect + ) + } +} + +private struct ProminentMenu: View { + @State private var isPresented = false + + let label: Label + let items: [ProminentMenuItem] + let isEnabled: Bool + let onSelect: (Action) -> Void + + @ViewBuilder + var body: some View { + if #available(iOS 26.0, *) { + Menu { + ForEach(items) { item in + Button(role: item.role) { + onSelect(item.action) + } label: { + menuItemLabel(item) + } + } + } label: { + label + } + .disabled(!isEnabled) + } else { + Button { + isPresented.toggle() + } label: { + label + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .popover( + isPresented: $isPresented, + attachmentAnchor: .point(.bottomTrailing), + arrowEdge: .top + ) { + customMenu + .presentationCompactAdaptation(.popover) + .presentationBackground(.clear) + } + } + } + + private var customMenu: some View { + VStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + if 0 < index { + Divider() + } + + Button(role: item.role) { + isPresented = false + onSelect(item.action) + } label: { + menuItemLabel(item) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } + .buttonStyle(.plain) + .padding(.horizontal, 18) + .frame(minHeight: 52) + } + } + .frame(minWidth: 220) + .background(Color.surface, in: .rect(cornerRadius: 20)) + .compositingGroup() + .clipShape(.rect(cornerRadius: 20)) + .shadow(color: Color.black.opacity(0.18), radius: 20, y: 10) + .padding(8) + } + + private func menuItemLabel(_ item: ProminentMenuItem) -> some View { + HStack(spacing: 12) { + if let systemImage = item.systemImage { + Image(systemName: systemImage) + .frame(width: 22) + } + + Text(item.title) + .font(.body) + + Spacer(minLength: 0) + } + .foregroundStyle(item.role == .destructive ? Color.red : Color.primary) + } +} From 589816866b40f8151d674668ef10d7b09dcdb950 Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 15 Sep 2026 00:23:16 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EC=83=81=ED=83=9C=20=EC=A0=84=ED=99=98=20Presentat?= =?UTF-8?q?ion=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevelopmentDependencyPreparation.swift | 15 +- .../Record/Detail/RecordDetailFeature.swift | 10 +- .../Record/Detail/RecordDetailView.swift | 10 +- .../Record/GoalDetail/GoalDetailFeature.swift | 196 ++++++++++++++++-- .../Record/GoalDetail/GoalDetailView.swift | 135 +++++++++++- .../History/RecordVersionDetailView.swift | 24 ++- .../History/RecordVersionHistoryView.swift | 6 +- .../Resources/Localizable.xcstrings | 154 ++++++++++++++ 8 files changed, 521 insertions(+), 29 deletions(-) diff --git a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift index e0208c85..07242f5b 100644 --- a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift +++ b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift @@ -11,9 +11,11 @@ import PresentationShared public enum DevelopmentDependencyPreparation { public static func prepareGoal( _ dependencies: inout DependencyValues, - fetchGoalUseCase: FetchDevelopmentGoalUseCase + fetchGoalUseCase: FetchDevelopmentGoalUseCase, + updateGoalStatusUseCase: UpdateDevelopmentGoalStatusUseCase ) { dependencies.developmentFetchGoalUseCase = fetchGoalUseCase + dependencies.developmentUpdateGoalStatusUseCase = updateGoalStatusUseCase } public static func prepareQuery( @@ -52,6 +54,11 @@ extension DependencyValues { set { self[DevelopmentFetchRecordsUseCaseKey.self] = newValue } } + var developmentUpdateGoalStatusUseCase: UpdateDevelopmentGoalStatusUseCase { + get { self[DevelopmentUpdateGoalStatusUseCaseKey.self] } + set { self[DevelopmentUpdateGoalStatusUseCaseKey.self] = newValue } + } + var developmentFetchRecordHistoryUseCase: FetchDevelopmentRecordHistoryUseCase { get { self[DevelopmentFetchRecordHistoryUseCaseKey.self] } set { self[DevelopmentFetchRecordHistoryUseCaseKey.self] = newValue } @@ -95,6 +102,12 @@ private enum DevelopmentFetchRecordsUseCaseKey: DependencyKey { } } +private enum DevelopmentUpdateGoalStatusUseCaseKey: DependencyKey { + static var liveValue: UpdateDevelopmentGoalStatusUseCase { + preconditionFailure("UpdateDevelopmentGoalStatusUseCase must be provided.") + } +} + private enum DevelopmentFetchRecordHistoryUseCaseKey: DependencyKey { static var liveValue: FetchDevelopmentRecordHistoryUseCase { preconditionFailure("FetchDevelopmentRecordHistoryUseCase must be provided.") diff --git a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift index 8a787dff..75eb028d 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift @@ -14,6 +14,7 @@ struct RecordDetailFeature { struct State: Equatable { @Presents var alert: AlertState? let goalTitle: String + let allowsMutation: Bool var record: DevelopmentRecord var versions = [DevelopmentRecord.Version]() var contentState: ContentState @@ -30,8 +31,13 @@ struct RecordDetailFeature { versions.first { $0.id == currentVersionID } } - init(goalTitle: String, record: DevelopmentRecord) { + init( + goalTitle: String, + record: DevelopmentRecord, + allowsMutation: Bool = true + ) { self.goalTitle = goalTitle + self.allowsMutation = allowsMutation self.record = record self.currentVersionID = record.currentVersion?.id if record.currentVersion == nil, let draft = record.draft { @@ -88,6 +94,7 @@ struct RecordDetailFeature { case .alert(.presented(.confirmRestore(let version))): state.alert = nil guard !state.isRestoring, + state.allowsMutation, state.record.draft == nil, version.id != state.currentVersionID else { break } let request = state.restoreRequest?.sourceVersionID == version.id @@ -117,6 +124,7 @@ struct RecordDetailFeature { return fetchEffect(goalID: state.record.goalId, recordID: state.record.id) case .view(.restore(let version)): guard !state.isRestoring, + state.allowsMutation, state.record.draft == nil, version.id != state.currentVersionID else { break } state.alert = Self.restoreConfirmationAlert(version) diff --git a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift index 1e53b5fd..8c6d0da8 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift @@ -19,13 +19,15 @@ public struct RecordDetailView: View { public init( goalTitle: String, record: DevelopmentRecord, + allowsMutation: Bool = true, onUpdate: @escaping () -> Void = { } ) { self.onUpdate = onUpdate self._store = State(initialValue: Store( initialState: RecordDetailFeature.State( goalTitle: goalTitle, - record: record + record: record, + allowsMutation: allowsMutation ) ) { RecordDetailFeature() @@ -49,7 +51,11 @@ public struct RecordDetailView: View { markdownContent: version.markdownContent, version: version ) - actionSection + if store.allowsMutation { + actionSection + } else { + historyButton + } } case .failed: failureContent diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift index 2e95f33e..aca06842 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift @@ -14,52 +14,79 @@ struct RecordTimelineItem: Equatable, Identifiable { let currentVersion: DevelopmentRecord.Version? var id: String { record.id } - var title: String { currentVersion?.title ?? record.draft?.title ?? "" } - var isDraft: Bool { currentVersion == nil } - var versionNumber: Int? { currentVersion?.number } - var date: Date { currentVersion?.confirmedAt ?? record.draft?.updatedAt ?? record.createdAt } + var title: String { record.draft?.title ?? currentVersion?.title ?? "" } + var hasDraft: Bool { record.draft != nil } + var isUnconfirmed: Bool { currentVersion == nil } + var versionNumber: Int? { hasDraft ? nil : currentVersion?.number } + var date: Date { record.draft?.updatedAt ?? currentVersion?.confirmedAt ?? record.createdAt } } @Reducer struct GoalDetailFeature { @ObservableState struct State: Equatable { - @Presents var alert: AlertState? + @Presents var alert: AlertState? let goalId: String - var goalTitle = "" + var goal: DevelopmentGoal? var items = [RecordTimelineItem]() var isLoading = false + var isTransitioning = false var hasLoaded = false var hasLoadFailure = false + var goalTitle: String { + goal?.title ?? "" + } + + var goalStatus: DevelopmentGoal.Status? { + goal?.status + } + + var allowsRecordMutation: Bool { + goalStatus == .inProgress + } + init(goalId: String) { self.goalId = goalId } } enum Action: Equatable { - case alert(PresentationAction) + case alert(PresentationAction) case view(ViewAction) case store(StoreAction) + enum Alert: Equatable { + case confirmTransition(DevelopmentGoal.Status) + } + enum ViewAction: Equatable { case fetch case refresh + case selectStatus(DevelopmentGoal.Status) } enum StoreAction: Equatable { - case loaded(goalTitle: String, items: [RecordTimelineItem]) + case loaded(goal: DevelopmentGoal, items: [RecordTimelineItem]) + case transitioned(DevelopmentGoal) case failed + case transitionFailed } } @Dependency(\.developmentFetchGoalUseCase) private var fetchGoalUseCase @Dependency(\.developmentFetchRecordsUseCase) private var fetchRecordsUseCase @Dependency(\.developmentFetchRecordVersionUseCase) private var fetchRecordVersionUseCase + @Dependency(\.developmentUpdateGoalStatusUseCase) private var updateGoalStatusUseCase var body: some ReducerOf { Reduce { state, action in switch action { + case .alert(.presented(.confirmTransition(let status))): + guard !state.isTransitioning else { break } + state.alert = nil + state.isTransitioning = true + return transitionEffect(goalId: state.goalId, status: status) case .alert: break case .view(.fetch): @@ -72,16 +99,33 @@ struct GoalDetailFeature { state.isLoading = true state.hasLoadFailure = false return fetchEffect(goalId: state.goalId) - case .store(.loaded(let goalTitle, let items)): - state.goalTitle = goalTitle + case .view(.selectStatus(let status)): + guard let goal = state.goal, + !state.isLoading, + !state.isTransitioning, + Self.canTransition(from: goal.status, to: status) else { break } + if status == .completed, + let alert = Self.completionBlockingAlert(items: state.items) { + state.alert = alert + } else { + state.alert = Self.transitionConfirmationAlert(status) + } + case .store(.loaded(let goal, let items)): + state.goal = goal state.items = items state.isLoading = false state.hasLoaded = true state.hasLoadFailure = false + case .store(.transitioned(let goal)): + state.goal = goal + state.isTransitioning = false case .store(.failed): state.isLoading = false state.hasLoadFailure = true state.alert = Self.errorAlert + case .store(.transitionFailed): + state.isTransitioning = false + state.alert = Self.transitionErrorAlert } return .none @@ -129,19 +173,112 @@ extension GoalDetailFeature { ) } - await send(.store(.loaded(goalTitle: goal.title, items: items))) + await send(.store(.loaded(goal: goal, items: items))) } catch { await send(.store(.failed)) } } } + func transitionEffect( + goalId: String, + status: DevelopmentGoal.Status + ) -> Effect { + .run { [fetchGoalUseCase, updateGoalStatusUseCase] send in + do { + try await updateGoalStatusUseCase.execute(goalId, to: status) + let goal = try await fetchGoalUseCase.execute(goalId) + await send(.store(.transitioned(goal))) + } catch { + await send(.store(.transitionFailed)) + } + } + } + static func precedes(_ lhs: DevelopmentRecord, _ rhs: DevelopmentRecord) -> Bool { if lhs.createdAt == rhs.createdAt { return lhs.id < rhs.id } return lhs.createdAt < rhs.createdAt } - static var errorAlert: AlertState { + static func canTransition( + from currentStatus: DevelopmentGoal.Status, + to status: DevelopmentGoal.Status + ) -> Bool { + switch (currentStatus, status) { + case (.inProgress, .completed), + (.inProgress, .archived), + (.completed, .inProgress), + (.archived, .inProgress): + true + default: + false + } + } + + static func completionBlockingAlert( + items: [RecordTimelineItem] + ) -> AlertState? { + guard !items.isEmpty else { + return informationAlert( + titleKey: "development_goal_completion_record_required_title", + messageKey: "development_goal_completion_record_required_message" + ) + } + guard items.last?.isUnconfirmed == false else { + return informationAlert( + titleKey: "development_goal_completion_version_required_title", + messageKey: "development_goal_completion_version_required_message" + ) + } + guard !items.contains(where: \.hasDraft) else { + return informationAlert( + titleKey: "development_goal_completion_draft_title", + messageKey: "development_goal_completion_draft_message" + ) + } + return nil + } + + static func transitionConfirmationAlert( + _ status: DevelopmentGoal.Status + ) -> AlertState { + let keys: (title: String.LocalizationValue, message: String.LocalizationValue) + switch status { + case .inProgress: + keys = ( + "development_goal_resume_alert_title", + "development_goal_resume_alert_message" + ) + case .completed: + keys = ( + "development_goal_complete_alert_title", + "development_goal_complete_alert_message" + ) + case .archived: + keys = ( + "development_goal_archive_alert_title", + "development_goal_archive_alert_message" + ) + } + + return AlertState { + TextState(String(localized: keys.title, bundle: PresentationResources.bundle)) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_cancel", bundle: PresentationResources.bundle)) + } + ButtonState(action: .confirmTransition(status)) { + TextState(String( + localized: transitionActionKey(status), + bundle: PresentationResources.bundle + )) + } + } message: { + TextState(String(localized: keys.message, bundle: PresentationResources.bundle)) + } + } + + static var errorAlert: AlertState { AlertState { TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) } actions: { @@ -155,4 +292,39 @@ extension GoalDetailFeature { )) } } + + static var transitionErrorAlert: AlertState { + informationAlert( + titleKey: "common_error_title", + messageKey: "development_goal_transition_error_message" + ) + } + + static func transitionActionKey( + _ status: DevelopmentGoal.Status + ) -> String.LocalizationValue { + switch status { + case .inProgress: + "development_goal_resume" + case .completed: + "development_goal_complete" + case .archived: + "development_goal_archive" + } + } + + static func informationAlert( + titleKey: String.LocalizationValue, + messageKey: String.LocalizationValue + ) -> AlertState { + AlertState { + TextState(String(localized: titleKey, bundle: PresentationResources.bundle)) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) + } + } message: { + TextState(String(localized: messageKey, bundle: PresentationResources.bundle)) + } + } } diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift index 2fb1fbe2..ddde147c 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -27,7 +27,8 @@ public struct GoalDetailView: View { LazyVStack(spacing: 20, pinnedViews: [.sectionHeaders]) { Section { timelineCard - if let draft = store.items.first(where: \.isDraft) { + if store.allowsRecordMutation, + let draft = store.items.first(where: \.hasDraft) { continueButton(draft.record) } } header: { @@ -51,10 +52,16 @@ public struct GoalDetailView: View { RecordDetailView( goalTitle: store.goalTitle, record: destination.record, + allowsMutation: store.allowsRecordMutation, onUpdate: refresh ) } .toolbarBackground(Color.appBackground) + .overlay { + if store.isTransitioning { + LoadingView() + } + } } private var titleBar: some View { @@ -73,6 +80,30 @@ public struct GoalDetailView: View { .contentMargins(.horizontal, 16, for: .scrollContent) .padding(.horizontal, -16) } + + if let status = store.goalStatus { + HStack(spacing: 12) { + GoalStatusBadge(status: status) + Spacer() + Image(systemName: "ellipsis") + .font(.title3.weight(.semibold)) + .frame(width: 28, height: 28) + .prominentMenu( + items: statusMenuItems(status), + isEnabled: !store.isLoading && !store.isTransitioning + ) { status in + store.send(.view(.selectStatus(status))) + } + .adaptiveButtonStyle( + shape: .circle, + color: .surface, + glassEffect: .enabled + ) + .accessibilityLabel( + RecordPresentation.text("development_goal_status_menu") + ) + } + } } .frame(maxWidth: .infinity, alignment: .leading) .background(Color.appBackground) @@ -120,7 +151,7 @@ public struct GoalDetailView: View { } } - if store.hasLoaded { + if store.hasLoaded, store.allowsRecordMutation { Button { editorDestination = EditorDestination(record: nil) } label: { @@ -156,13 +187,41 @@ public struct GoalDetailView: View { } private func select(_ item: RecordTimelineItem) { - if item.isDraft { + if item.hasDraft, store.allowsRecordMutation { editorDestination = EditorDestination(record: item.record) } else { detailDestination = DetailDestination(record: item.record) } } + private func statusMenuItems( + _ status: DevelopmentGoal.Status + ) -> [ProminentMenuItem] { + switch status { + case .inProgress: + [ + ProminentMenuItem( + action: .completed, + title: RecordPresentation.text("development_goal_complete"), + systemImage: "checkmark.circle" + ), + ProminentMenuItem( + action: .archived, + title: RecordPresentation.text("development_goal_archive"), + systemImage: "archivebox" + ) + ] + case .completed, .archived: + [ + ProminentMenuItem( + action: .inProgress, + title: RecordPresentation.text("development_goal_resume"), + systemImage: "arrow.counterclockwise" + ) + ] + } + } + private func finishEditing() { editorDestination = nil refresh() @@ -190,7 +249,7 @@ private struct TimelineRow: View { .foregroundStyle(Color.primary) .lineLimit(2) status - if item.isDraft { + if item.hasDraft { RelativeTimeText( date: item.date, bodyFont: .caption, @@ -216,11 +275,11 @@ private struct TimelineRow: View { private var timelineIndicator: some View { VStack(spacing: 0) { Circle() - .fill(item.isDraft ? Color.surface : .accent) + .fill(item.hasDraft ? Color.surface : .accent) .frame(width: 13, height: 13) .overlay { Circle() - .strokeBorder(item.isDraft ? Color.warning : .accent, lineWidth: 2) + .strokeBorder(item.hasDraft ? Color.warning : .accent, lineWidth: 2) } if !isLast { Rectangle() @@ -234,11 +293,11 @@ private struct TimelineRow: View { private var status: some View { Text(statusText) .font(.caption) - .foregroundStyle(item.isDraft ? Color.warning : .accent) + .foregroundStyle(item.hasDraft ? Color.warning : .accent) .padding(.horizontal, 9) .padding(.vertical, 4) .background( - item.isDraft ? Color.warning.opacity(0.12) : Color.primaryContainer, + item.hasDraft ? Color.warning.opacity(0.12) : Color.primaryContainer, in: .rect(cornerRadius: 8) ) } @@ -254,6 +313,66 @@ private struct TimelineRow: View { } } +private struct GoalStatusBadge: View { + let status: DevelopmentGoal.Status + + var body: some View { + Label( + title, + systemImage: systemImage + ) + .font(.caption.weight(.semibold)) + .foregroundStyle(foreground) + .padding(.horizontal, 11) + .padding(.vertical, 6) + .background(background, in: .capsule) + } + + private var title: String { + switch status { + case .inProgress: + RecordPresentation.text("development_goal_status_in_progress") + case .completed: + RecordPresentation.text("development_goal_status_completed") + case .archived: + RecordPresentation.text("development_goal_status_archived") + } + } + + private var systemImage: String { + switch status { + case .inProgress: + "clock" + case .completed: + "checkmark.circle.fill" + case .archived: + "archivebox.fill" + } + } + + private var foreground: Color { + switch status { + case .inProgress: + .accent + case .completed: + .white + case .archived: + .textSecondary + } + } + + private var background: Color { + switch status { + case .inProgress: + .primaryContainer + case .completed: + .accent + case .archived: + .surfaceSecondary + } + } +} + private struct EditorDestination: Identifiable { let id = UUID() let record: DevelopmentRecord? diff --git a/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift b/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift index abf3fc4c..9f76507b 100644 --- a/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift @@ -26,7 +26,11 @@ struct RecordVersionDetailView: View { .padding(.bottom, 20) } .safeAreaInset(edge: .top, spacing: 0) { topBar } - .safeAreaInset(edge: .bottom, spacing: 0) { restoreBar } + .safeAreaInset(edge: .bottom, spacing: 0) { + if store.allowsMutation { + restoreBar + } + } .background(Color.appBackground.ignoresSafeArea()) .toolbarVisibility(.hidden, for: .navigationBar) .prominentAlert( @@ -94,7 +98,9 @@ struct RecordVersionDetailView: View { private var currentVersionCard: some View { Label { - if store.record.draft != nil { + if !store.allowsMutation { + Text(RecordPresentation.text("development_record_read_only_message")) + } else if store.record.draft != nil { Text(RecordPresentation.text("development_record_restore_draft_message")) } else { Text(String.localizedStringWithFormat( @@ -104,9 +110,9 @@ struct RecordVersionDetailView: View { } } icon: { Image( - systemName: store.record.draft == nil ? "clock.arrow.circlepath" : "pencil" + systemName: statusSystemImage ) - .foregroundStyle(store.record.draft == nil ? Color.accent : Color.warning) + .foregroundStyle(statusColor) } .font(.subheadline) .foregroundStyle(Color.textSecondary) @@ -115,6 +121,16 @@ struct RecordVersionDetailView: View { .background(Color.surfaceSecondary, in: .rect(cornerRadius: 18)) } + private var statusSystemImage: String { + if !store.allowsMutation { return "lock" } + return store.record.draft == nil ? "clock.arrow.circlepath" : "pencil" + } + + private var statusColor: Color { + if !store.allowsMutation { return .textSecondary } + return store.record.draft == nil ? .accent : .warning + } + private var restoreBar: some View { Button { store.send(.view(.restore(version))) diff --git a/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift b/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift index fd89cc2e..ae965485 100644 --- a/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift +++ b/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift @@ -124,7 +124,11 @@ struct RecordVersionHistoryView: View { private var footer: some View { Label( - RecordPresentation.text("development_record_history_footer"), + RecordPresentation.text( + store.allowsMutation + ? "development_record_history_footer" + : "development_record_history_read_only_footer" + ), systemImage: "lock" ) .font(.caption) diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 64f54296..0a7f736c 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -424,6 +424,146 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 목표" } } } }, + "development_goal_archive" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Archive" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "보관하기" } } + } + }, + "development_goal_archive_alert_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Archived goals are read-only until you move them back to in progress." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "보관하면 다시 진행 중으로 되돌릴 때까지 기록을 수정할 수 없어요." } } + } + }, + "development_goal_archive_alert_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Archive this goal?" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표를 보관할까요?" } } + } + }, + "development_goal_complete" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Complete" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "완료하기" } } + } + }, + "development_goal_complete_alert_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Completed goals are read-only until you move them back to in progress." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "완료하면 다시 진행 중으로 되돌릴 때까지 기록을 수정할 수 없어요." } } + } + }, + "development_goal_complete_alert_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Complete this goal?" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표를 완료할까요?" } } + } + }, + "development_goal_completion_draft_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm every remaining draft, then try completing the goal again." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "남아 있는 초안을 모두 확정한 뒤 목표 완료를 다시 시도해주세요." } } + } + }, + "development_goal_completion_draft_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm the remaining drafts" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "남은 초안을 확정해주세요" } } + } + }, + "development_goal_completion_record_required_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Add and confirm at least one development record before completing the goal." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록을 하나 이상 작성하고 확정한 뒤 목표를 완료할 수 있어요." } } + } + }, + "development_goal_completion_record_required_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "A confirmed record is required" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "확정된 기록이 필요해요" } } + } + }, + "development_goal_completion_version_required_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm the latest development record before completing the goal." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "마지막 개발 기록을 먼저 확정한 뒤 목표를 완료할 수 있어요." } } + } + }, + "development_goal_completion_version_required_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm the latest record" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "마지막 기록을 확정해주세요" } } + } + }, + "development_goal_resume" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Move to In Progress" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중으로 되돌리기" } } + } + }, + "development_goal_resume_alert_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "You can add and edit development records again." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중으로 되돌리면 개발 기록을 다시 작성하고 수정할 수 있어요." } } + } + }, + "development_goal_resume_alert_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Resume this goal?" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표를 다시 진행할까요?" } } + } + }, + "development_goal_status_archived" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Archived" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "보관" } } + } + }, + "development_goal_status_completed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Completed" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "완료" } } + } + }, + "development_goal_status_in_progress" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "In Progress" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중" } } + } + }, + "development_goal_status_menu" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Change Goal Status" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 상태 변경" } } + } + }, + "development_goal_transition_error_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The goal status could not be changed. Please try again." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 상태를 변경하지 못했어요. 다시 시도해주세요." } } + } + }, "development_record_add" : { "extractionState" : "manual", "localizations" : { @@ -585,6 +725,13 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "버전 이력" } } } }, + "development_record_history_read_only_footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Move the goal back to in progress to restore an earlier version." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "이전 버전으로 되돌리려면 목표를 진행 중으로 되돌려주세요." } } + } + }, "development_record_markdown_hint" : { "extractionState" : "manual", "localizations" : { @@ -620,6 +767,13 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "이전 버전" } } } }, + "development_record_read_only_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Move the goal back to in progress to change this record." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "이 기록을 변경하려면 목표를 진행 중으로 되돌려주세요." } } + } + }, "development_record_result_title" : { "extractionState" : "manual", "localizations" : { From 1f39254d422889c60c39e6cbbbd8a1a351f29422 Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 15 Sep 2026 00:23:26 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20App=EC=97=90=20=EA=B0=9C=EB=B0=9C?= =?UTF-8?q?=20=EB=AA=A9=ED=91=9C=20=EC=83=81=ED=83=9C=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Dependency/AppGraph+PresentationDependencies.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index 639d4b7c..b283b69f 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -27,7 +27,10 @@ private extension AppGraph { &dependencies, fetchGoalUseCase: developmentGraphSet .developmentGoalUseCaseGraph - .fetchDevelopmentGoalUseCase + .fetchDevelopmentGoalUseCase, + updateGoalStatusUseCase: developmentGraphSet + .developmentGoalUseCaseGraph + .updateDevelopmentGoalStatusUseCase ) DevelopmentDependencyPreparation.prepareQuery( &dependencies, From 6cc67db7d7d3c39d557e83964dab1674960e76cb Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 15 Sep 2026 00:23:36 +0900 Subject: [PATCH 04/10] =?UTF-8?q?test:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EC=83=81=ED=83=9C=20=EC=A0=84=ED=99=98=EA=B3=BC=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tests/Record/GoalDetailFeatureTests.swift | 217 +++++++++++++++++- .../Record/RecordDetailFeatureTests.swift | 32 +++ .../Record/RecordFeatureTestSupport.swift | 32 ++- 3 files changed, 274 insertions(+), 7 deletions(-) diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift index c169d679..f3d61b81 100644 --- a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift @@ -6,6 +6,7 @@ // import Testing +import Domain import Foundation import PresentationShared @testable import Development @@ -42,8 +43,8 @@ struct GoalDetailFeatureTests { await store.send(.view(.fetch)) { $0.isLoading = true } - await store.receive(.store(.loaded(goalTitle: goal.title, items: items))) { - $0.goalTitle = goal.title + await store.receive(.store(.loaded(goal: goal, items: items))) { + $0.goal = goal $0.items = items $0.isLoading = false $0.hasLoaded = true @@ -75,7 +76,7 @@ struct GoalDetailFeatureTests { currentVersion: currentVersion ) var state = GoalDetailFeature.State(goalId: goal.id) - state.goalTitle = goal.title + state.goal = goal state.items = [previousItem] state.hasLoaded = true let store = TestStore(initialState: state) { @@ -93,7 +94,7 @@ struct GoalDetailFeatureTests { await store.send(.view(.refresh)) { $0.isLoading = true } - await store.receive(.store(.loaded(goalTitle: goal.title, items: [currentItem]))) { + await store.receive(.store(.loaded(goal: goal, items: [currentItem]))) { $0.items = [currentItem] $0.isLoading = false } @@ -133,4 +134,212 @@ struct GoalDetailFeatureTests { $0.hasLoadFailure = true } } + + @Test("진행 중 목표는 확인 후 보관 상태로 전환한다") + func 진행_중_목표는_확인_후_보관_상태로_전환한다() async throws { + let goal = try makeDevelopmentGoal() + let archivedGoal = try makeDevelopmentGoal(status: .archived) + let spy = UpdateDevelopmentGoalStatusUseCaseSpy() + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentUpdateGoalStatusUseCase = spy + $0.developmentFetchGoalUseCase = FetchDevelopmentGoalUseCaseStub( + result: .success(archivedGoal) + ) + } + + await store.send(.view(.selectStatus(.archived))) { + $0.alert = GoalDetailFeature.transitionConfirmationAlert(.archived) + } + await store.send(.alert(.presented(.confirmTransition(.archived)))) { + $0.alert = nil + $0.isTransitioning = true + } + await store.receive(.store(.transitioned(archivedGoal))) { + $0.goal = archivedGoal + $0.isTransitioning = false + } + + #expect(await spy.requests() == [ + .init(goalId: goal.id, status: .archived) + ]) + } + + @Test("완료되거나 보관된 목표는 진행 중으로 되돌릴 수 있다") + func 완료되거나_보관된_목표는_진행_중으로_되돌릴_수_있다() async throws { + for status in [DevelopmentGoal.Status.completed, .archived] { + let goal = try makeDevelopmentGoal(status: status) + let resumedGoal = try makeDevelopmentGoal() + let spy = UpdateDevelopmentGoalStatusUseCaseSpy() + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentUpdateGoalStatusUseCase = spy + $0.developmentFetchGoalUseCase = FetchDevelopmentGoalUseCaseStub( + result: .success(resumedGoal) + ) + } + + await store.send(.view(.selectStatus(.inProgress))) { + $0.alert = GoalDetailFeature.transitionConfirmationAlert(.inProgress) + } + await store.send(.alert(.presented(.confirmTransition(.inProgress)))) { + $0.alert = nil + $0.isTransitioning = true + } + await store.receive(.store(.transitioned(resumedGoal))) { + $0.goal = resumedGoal + $0.isTransitioning = false + } + + #expect(await spy.requests() == [ + .init(goalId: goal.id, status: .inProgress) + ]) + } + } + + @Test("개발 기록이 없으면 목표 완료 전에 기록 작성을 안내한다") + func 개발_기록이_없으면_목표_완료_전에_기록_작성을_안내한다() async throws { + let goal = try makeDevelopmentGoal() + let spy = UpdateDevelopmentGoalStatusUseCaseSpy() + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentUpdateGoalStatusUseCase = spy + } + + await store.send(.view(.selectStatus(.completed))) { + $0.alert = GoalDetailFeature.completionBlockingAlert(items: []) + } + + #expect(await spy.requests().isEmpty) + } + + @Test("마지막 기록이 초안이면 목표 완료 전에 기록 확정을 안내한다") + func 마지막_기록이_초안이면_목표_완료_전에_기록_확정을_안내한다() async throws { + let goal = try makeDevelopmentGoal() + let draft = try makeDevelopmentRecord() + let item = RecordTimelineItem(record: draft, currentVersion: nil) + let spy = UpdateDevelopmentGoalStatusUseCaseSpy() + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.items = [item] + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentUpdateGoalStatusUseCase = spy + } + + await store.send(.view(.selectStatus(.completed))) { + $0.alert = GoalDetailFeature.completionBlockingAlert(items: [item]) + } + + #expect(await spy.requests().isEmpty) + } + + @Test("정정 초안이 남아 있으면 목표 완료 전에 초안 확정을 안내한다") + func 정정_초안이_남아_있으면_목표_완료_전에_초안_확정을_안내한다() async throws { + let goal = try makeDevelopmentGoal() + let version = try makeDevelopmentRecordVersion() + let record = try makeConfirmedDevelopmentRecord( + draft: makeDevelopmentRecordDraft(baseVersionId: version.id) + ) + let item = RecordTimelineItem(record: record, currentVersion: version) + let spy = UpdateDevelopmentGoalStatusUseCaseSpy() + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.items = [item] + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentUpdateGoalStatusUseCase = spy + } + + await store.send(.view(.selectStatus(.completed))) { + $0.alert = GoalDetailFeature.completionBlockingAlert(items: [item]) + } + + #expect(await spy.requests().isEmpty) + } + + @Test("모든 기록이 확정되면 확인 후 목표를 완료한다") + func 모든_기록이_확정되면_확인_후_목표를_완료한다() async throws { + let goal = try makeDevelopmentGoal() + let completedGoal = try makeDevelopmentGoal(status: .completed) + let record = try makeConfirmedDevelopmentRecord() + let version = try makeDevelopmentRecordVersion() + let item = RecordTimelineItem(record: record, currentVersion: version) + let spy = UpdateDevelopmentGoalStatusUseCaseSpy() + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.items = [item] + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentUpdateGoalStatusUseCase = spy + $0.developmentFetchGoalUseCase = FetchDevelopmentGoalUseCaseStub( + result: .success(completedGoal) + ) + } + + await store.send(.view(.selectStatus(.completed))) { + $0.alert = GoalDetailFeature.transitionConfirmationAlert(.completed) + } + await store.send(.alert(.presented(.confirmTransition(.completed)))) { + $0.alert = nil + $0.isTransitioning = true + } + await store.receive(.store(.transitioned(completedGoal))) { + $0.goal = completedGoal + $0.isTransitioning = false + } + + #expect(await spy.requests() == [ + .init(goalId: goal.id, status: .completed) + ]) + } + + @Test("목표 상태 전환 실패는 현재 상태를 유지하고 오류를 표시한다") + func 목표_상태_전환_실패는_현재_상태를_유지하고_오류를_표시한다() async throws { + let goal = try makeDevelopmentGoal() + let spy = UpdateDevelopmentGoalStatusUseCaseSpy(result: .failure(RecordTestError.failed)) + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentUpdateGoalStatusUseCase = spy + } + + await store.send(.view(.selectStatus(.archived))) { + $0.alert = GoalDetailFeature.transitionConfirmationAlert(.archived) + } + await store.send(.alert(.presented(.confirmTransition(.archived)))) { + $0.alert = nil + $0.isTransitioning = true + } + await store.receive(.store(.transitionFailed)) { + $0.isTransitioning = false + $0.alert = GoalDetailFeature.transitionErrorAlert + } + + #expect(store.state.goal == goal) + #expect(await spy.requests() == [ + .init(goalId: goal.id, status: .archived) + ]) + } } diff --git a/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift index 5ead5cfd..3a0ce762 100644 --- a/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift @@ -299,4 +299,36 @@ struct RecordDetailFeatureTests { #expect(await spy.requests().isEmpty) } + + @Test("읽기 전용 목표는 이전 버전 되돌리기를 시작하지 않는다") + func 읽기_전용_목표는_이전_버전_되돌리기를_시작하지_않는다() async throws { + let initialVersion = try makeDevelopmentRecordVersion(id: "version-1") + let currentVersion = try makeDevelopmentRecordVersion( + id: "version-2", + number: 2, + kind: .correction, + sourceVersionId: initialVersion.id + ) + let record = try makeConfirmedDevelopmentRecord( + versionId: currentVersion.id, + versionNumber: currentVersion.number + ) + let spy = RestoreDevelopmentRecordUseCaseSpy(result: .failure(RecordTestError.failed)) + var state = RecordDetailFeature.State( + goalTitle: "개발 목표", + record: record, + allowsMutation: false + ) + state.versions = [initialVersion, currentVersion] + state.contentState = .loaded + let store = TestStore(initialState: state) { + RecordDetailFeature() + } withDependencies: { + $0.developmentRestoreRecordUseCase = spy + } + + await store.send(.view(.restore(initialVersion))) + + #expect(await spy.requests().isEmpty) + } } diff --git a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift index df532552..754f2b4e 100644 --- a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift +++ b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift @@ -35,6 +35,29 @@ struct FetchDevelopmentGoalUseCaseStub: FetchDevelopmentGoalUseCase { } } +actor UpdateDevelopmentGoalStatusUseCaseSpy: UpdateDevelopmentGoalStatusUseCase { + struct Request: Equatable { + let goalId: String + let status: DevelopmentGoal.Status + } + + private let result: Result + private var recordedRequests = [Request]() + + init(result: Result = .success(())) { + self.result = result + } + + func execute(_ goalId: String, to status: DevelopmentGoal.Status) async throws { + recordedRequests.append(.init(goalId: goalId, status: status)) + try result.get() + } + + func requests() -> [Request] { + recordedRequests + } +} + struct FetchDevelopmentRecordsUseCaseStub: FetchDevelopmentRecordsUseCase { let result: Result<[DevelopmentRecord], Error> @@ -241,16 +264,19 @@ actor RestoreDevelopmentRecordUseCaseSpy: RestoreDevelopmentRecordUseCase { } } -func makeDevelopmentGoal(title: String = "개발 목표") throws -> DevelopmentGoal { +func makeDevelopmentGoal( + title: String = "개발 목표", + status: DevelopmentGoal.Status = .inProgress +) throws -> DevelopmentGoal { let date = Date(timeIntervalSince1970: 1_700_000_000) return try DevelopmentGoal( id: "goal", title: title, description: "설명", - status: .inProgress, + status: status, createdAt: date, updatedAt: date, - completedAt: nil + completedAt: status == .completed ? date : nil ) } From 0e33b9755257297ef531eaf862e2ecd7986621ea Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 15 Sep 2026 00:50:18 +0900 Subject: [PATCH 05/10] =?UTF-8?q?ui:=20ellipsis=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Record/GoalDetail/GoalDetailView.swift | 60 ++++++++++--------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift index ddde147c..ea730dfb 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -10,6 +10,7 @@ import Domain import PresentationShared public struct GoalDetailView: View { + @Environment(\.dismiss) private var dismiss @State private var store: StoreOf @State private var editorDestination: EditorDestination? @State private var detailDestination: DetailDestination? @@ -37,7 +38,9 @@ public struct GoalDetailView: View { .padding(.horizontal) } } - .background(Color.appBackground) + .safeAreaInset(edge: .top, spacing: 0) { topBar } + .background(Color.appBackground.ignoresSafeArea()) + .toolbarVisibility(.hidden, for: .navigationBar) .onAppear { store.send(.view(.fetch)) } .prominentAlert(store, state: \.alert, action: \.alert) .sheet(item: $editorDestination) { destination in @@ -56,7 +59,6 @@ public struct GoalDetailView: View { onUpdate: refresh ) } - .toolbarBackground(Color.appBackground) .overlay { if store.isTransitioning { LoadingView() @@ -64,6 +66,33 @@ public struct GoalDetailView: View { } } + private var topBar: some View { + HStack { + RecordBackButton(action: dismiss.callAsFunction) + .disabled(store.isTransitioning) + Spacer() + if let status = store.goalStatus { + Image(systemName: "ellipsis") + .font(.title3.weight(.semibold)) + .frame(width: 28, height: 28) + .prominentMenu( + items: statusMenuItems(status), + isEnabled: !store.isLoading && !store.isTransitioning + ) { status in + store.send(.view(.selectStatus(status))) + } + .adaptiveButtonStyle( + shape: .circle, + color: .surface, + glassEffect: .enabled + ) + } + } + .padding(.horizontal) + .padding(.bottom, 12) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) + } + private var titleBar: some View { VStack(alignment: .leading, spacing: 8) { Text(RecordPresentation.text("development_goal_title")) @@ -82,27 +111,7 @@ public struct GoalDetailView: View { } if let status = store.goalStatus { - HStack(spacing: 12) { - GoalStatusBadge(status: status) - Spacer() - Image(systemName: "ellipsis") - .font(.title3.weight(.semibold)) - .frame(width: 28, height: 28) - .prominentMenu( - items: statusMenuItems(status), - isEnabled: !store.isLoading && !store.isTransitioning - ) { status in - store.send(.view(.selectStatus(status))) - } - .adaptiveButtonStyle( - shape: .circle, - color: .surface, - glassEffect: .enabled - ) - .accessibilityLabel( - RecordPresentation.text("development_goal_status_menu") - ) - } + GoalStatusBadge(status: status) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -317,10 +326,7 @@ private struct GoalStatusBadge: View { let status: DevelopmentGoal.Status var body: some View { - Label( - title, - systemImage: systemImage - ) + Label(title, systemImage: systemImage) .font(.caption.weight(.semibold)) .foregroundStyle(foreground) .padding(.horizontal, 11) From 1a70132eae3322a73c2cbec6c5bad5124ea144b8 Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 15 Sep 2026 01:32:44 +0900 Subject: [PATCH 06/10] =?UTF-8?q?ui:=20=EA=B0=81=20row=20=EA=B0=84=20spaci?= =?UTF-8?q?ng=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Record/History/RecordVersionHistoryView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift b/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift index ae965485..96375f03 100644 --- a/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift +++ b/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift @@ -100,7 +100,7 @@ struct RecordVersionHistoryView: View { } private var historyCard: some View { - LazyVStack(spacing: 0) { + LazyVStack(spacing: 12) { ForEach(Array(sortedVersions.enumerated()), id: \.element.id) { index, version in Button { if version.id == store.currentVersionID { From a3809cdb36e962ed59bb08b2b891b67c512e7b3c Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 15 Sep 2026 09:00:16 +0900 Subject: [PATCH 07/10] =?UTF-8?q?ui:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EA=B3=BC=20=EB=B2=84=EC=A0=84=20=EC=9D=B4=EB=A0=A5=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20=ED=91=9C=ED=98=84=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Record/GoalDetail/GoalDetailView.swift | 25 +++++------- .../History/RecordVersionHistoryView.swift | 21 +++++----- .../Sources/Record/RecordPresentation.swift | 38 ++++++++++++++++++- 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift index ea730dfb..c32ad213 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -153,6 +153,7 @@ public struct GoalDetailView: View { ForEach(Array(store.items.enumerated()), id: \.element.id) { index, item in TimelineRow( item: item, + isFirst: index == 0, isLast: index == store.items.count - 1, onSelect: { select(item) } ) @@ -243,6 +244,7 @@ public struct GoalDetailView: View { private struct TimelineRow: View { let item: RecordTimelineItem + let isFirst: Bool let isLast: Bool let onSelect: () -> Void @@ -277,26 +279,19 @@ private struct TimelineRow: View { .padding(.top, 4) } .contentShape(.rect) + .frame(minHeight: RecordTimelineLayout.rowHeight, alignment: .top) + .background(alignment: .topLeading) { + RecordTimelineConnector(isFirst: isFirst, isLast: isLast) + } } .buttonStyle(.plain) } private var timelineIndicator: some View { - VStack(spacing: 0) { - Circle() - .fill(item.hasDraft ? Color.surface : .accent) - .frame(width: 13, height: 13) - .overlay { - Circle() - .strokeBorder(item.hasDraft ? Color.warning : .accent, lineWidth: 2) - } - if !isLast { - Rectangle() - .fill(Color.accent.opacity(0.45)) - .frame(width: 2, height: 72) - } - } - .padding(.top, 3) + Circle() + .fill(item.hasDraft ? Color.warning : .accent) + .frame(width: RecordTimelineLayout.markerSize, height: RecordTimelineLayout.markerSize) + .padding(.top, RecordTimelineLayout.markerTopPadding) } private var status: some View { diff --git a/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift b/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift index 96375f03..225e5252 100644 --- a/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift +++ b/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift @@ -100,7 +100,7 @@ struct RecordVersionHistoryView: View { } private var historyCard: some View { - LazyVStack(spacing: 12) { + LazyVStack(spacing: 0) { ForEach(Array(sortedVersions.enumerated()), id: \.element.id) { index, version in Button { if version.id == store.currentVersionID { @@ -184,13 +184,17 @@ private struct VersionHistoryRow: View { .foregroundStyle(Color.border) } .contentShape(.rect) + .frame(minHeight: RecordTimelineLayout.rowHeight, alignment: .top) .background(alignment: .topLeading) { if !isLast { Rectangle() .fill(Color.accent.opacity(0.45)) - .frame(width: 2) + .frame(width: RecordTimelineLayout.lineWidth) .frame(maxHeight: .infinity) - .offset(x: 5.5, y: 16) + .offset( + x: RecordTimelineLayout.lineXOffset, + y: RecordTimelineLayout.lineYOffset + ) } } } @@ -198,12 +202,11 @@ private struct VersionHistoryRow: View { private var timelineIndicator: some View { Circle() .fill(isCurrent ? Color.accent : Color.textTertiary) - .frame(width: 13, height: 13) - .overlay { - Circle() - .strokeBorder(isCurrent ? Color.accent : Color.border, lineWidth: 2) - } - .padding(.top, 3) + .frame( + width: RecordTimelineLayout.markerSize, + height: RecordTimelineLayout.markerSize + ) + .padding(.top, RecordTimelineLayout.markerTopPadding) } @ViewBuilder diff --git a/Application/Presentation/Development/Sources/Record/RecordPresentation.swift b/Application/Presentation/Development/Sources/Record/RecordPresentation.swift index fcdd7bbe..d274670c 100644 --- a/Application/Presentation/Development/Sources/Record/RecordPresentation.swift +++ b/Application/Presentation/Development/Sources/Record/RecordPresentation.swift @@ -5,7 +5,7 @@ // Created by opfic on 9/13/26. // -import Foundation +import SwiftUI import PresentationShared enum RecordPresentation { @@ -17,3 +17,39 @@ enum RecordPresentation { "#\(number)" } } + +enum RecordTimelineLayout { + static let rowHeight: CGFloat = 88 + static let markerSize: CGFloat = 13 + static let lineWidth: CGFloat = 2 + static let markerTopPadding: CGFloat = 3 + static let lineXOffset = (markerSize - lineWidth) / 2 + static let lineYOffset = markerTopPadding + markerSize / 2 +} + +struct RecordTimelineConnector: View { + let isFirst: Bool + let isLast: Bool + + var body: some View { + ZStack(alignment: .topLeading) { + if !isFirst { + line + .frame(height: RecordTimelineLayout.lineYOffset) + } + if !isLast { + line + .frame(maxHeight: .infinity) + .padding(.top, RecordTimelineLayout.lineYOffset) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var line: some View { + Rectangle() + .fill(Color.accent.opacity(0.45)) + .frame(width: RecordTimelineLayout.lineWidth) + .offset(x: RecordTimelineLayout.lineXOffset) + } +} From f6609aaacfb0ce404016a8d332ee85ab59223b68 Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 15 Sep 2026 09:54:23 +0900 Subject: [PATCH 08/10] =?UTF-8?q?ui:=20=EC=8A=A4=ED=8B=B0=ED=82=A4=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EB=B3=B8=EB=AC=B8=20=EA=B0=84=EA=B2=A9=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Record/GoalDetail/GoalDetailView.swift | 8 +++++--- .../HomeTab/Sources/Search/SearchView.swift | 10 ++++++++-- .../Presentation/TodayTab/Sources/TodayView.swift | 9 ++++++--- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift index c32ad213..e3c9c3c8 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -25,11 +25,12 @@ public struct GoalDetailView: View { public var body: some View { ScrollView { - LazyVStack(spacing: 20, pinnedViews: [.sectionHeaders]) { + LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) { Section { + let draft = store.items.first(where: \.hasDraft) timelineCard - if store.allowsRecordMutation, - let draft = store.items.first(where: \.hasDraft) { + .padding(.bottom, store.allowsRecordMutation && draft != nil ? 8 : 0) + if store.allowsRecordMutation, let draft { continueButton(draft.record) } } header: { @@ -114,6 +115,7 @@ public struct GoalDetailView: View { GoalStatusBadge(status: status) } } + .padding(.bottom, 8) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.appBackground) } diff --git a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift index 520072ad..417963c2 100644 --- a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift +++ b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift @@ -17,17 +17,23 @@ struct SearchView: View { var body: some View { NavigationStack(path: $router.path) { ScrollView { - LazyVStack(alignment: .leading, spacing: 24, pinnedViews: [.sectionHeaders]) { + LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { Section { if !store.searchQuery.isEmpty { SearchResults( store: store, onSelectTodo: { router.push(.todo($0)) } ) + .padding(.bottom, 8) } RecentSearchQuries(store: store) + .padding(.bottom, 8) instruction - } header: { tipCard } + } header: { + tipCard + .padding(.bottom, 8) + .background(Color.appBackground) + } } .padding(.horizontal) } diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index 2f919b59..d63c6e54 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -33,18 +33,20 @@ public struct TodayView: View { public var body: some View { NavigationStack(path: $path) { ScrollView { - LazyVStack(alignment: .leading, spacing: 8, pinnedViews: [.sectionHeaders]) { + let sections = store.sections + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { Section { - if store.sections.isEmpty, !store.isLoading { + if sections.isEmpty, !store.isLoading { emptyContent } else { - ForEach(store.sections) { section in + ForEach(sections) { section in TodoSection( section: section, isNavigationEnabled: !store.isTodoInspectorPresented, onSelect: { path.append(.todo(TodoIdItem(id: $0.id))) }, onInspect: { store.send(.showTodoInspector($0)) } ) + .padding(.bottom, section.id == sections.last?.id ? 0 : 8) } .padding(.bottom, 12) } @@ -54,6 +56,7 @@ public struct TodayView: View { achievementCard filterBar } + .padding(.bottom, 8) .background(Color.appBackground) } } From 7948e09d2c07201070d89c0322ed0ac000ec14ec Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 15 Sep 2026 09:56:00 +0900 Subject: [PATCH 09/10] =?UTF-8?q?ui:=20=ED=88=B4=EB=B0=94=20=EB=B0=B0?= =?UTF-8?q?=EA=B2=BD=EC=83=89=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/Presentation/TodayTab/Sources/TodayView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Application/Presentation/TodayTab/Sources/TodayView.swift b/Application/Presentation/TodayTab/Sources/TodayView.swift index d63c6e54..89b54d52 100644 --- a/Application/Presentation/TodayTab/Sources/TodayView.swift +++ b/Application/Presentation/TodayTab/Sources/TodayView.swift @@ -70,6 +70,7 @@ public struct TodayView: View { CategoryFilterSheet(store: store) } } + .toolbarBackground(Color.appBackground) .inspector(isPresented: $store.isTodoInspectorPresented) { todoInspector } From cab0f71387c18f8427924ae3fb343bc3b7875c09 Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 15 Sep 2026 11:11:31 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EC=83=81=ED=83=9C=20=EC=A0=84=ED=99=98=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=20=EC=B2=98=EB=A6=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Record/GoalDetail/GoalDetailFeature.swift | 19 ++++---- .../Tests/Record/GoalDetailFeatureTests.swift | 43 +++++++++++-------- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift index aca06842..edc38c8c 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift @@ -28,6 +28,7 @@ struct GoalDetailFeature { @Presents var alert: AlertState? let goalId: String var goal: DevelopmentGoal? + var updatedGoalStatus: DevelopmentGoal.Status? var items = [RecordTimelineItem]() var isLoading = false var isTransitioning = false @@ -39,7 +40,7 @@ struct GoalDetailFeature { } var goalStatus: DevelopmentGoal.Status? { - goal?.status + updatedGoalStatus ?? goal?.status } var allowsRecordMutation: Bool { @@ -68,7 +69,7 @@ struct GoalDetailFeature { enum StoreAction: Equatable { case loaded(goal: DevelopmentGoal, items: [RecordTimelineItem]) - case transitioned(DevelopmentGoal) + case transitioned(DevelopmentGoal.Status) case failed case transitionFailed } @@ -100,10 +101,10 @@ struct GoalDetailFeature { state.hasLoadFailure = false return fetchEffect(goalId: state.goalId) case .view(.selectStatus(let status)): - guard let goal = state.goal, + guard let goalStatus = state.goalStatus, !state.isLoading, !state.isTransitioning, - Self.canTransition(from: goal.status, to: status) else { break } + Self.canTransition(from: goalStatus, to: status) else { break } if status == .completed, let alert = Self.completionBlockingAlert(items: state.items) { state.alert = alert @@ -112,12 +113,13 @@ struct GoalDetailFeature { } case .store(.loaded(let goal, let items)): state.goal = goal + state.updatedGoalStatus = nil state.items = items state.isLoading = false state.hasLoaded = true state.hasLoadFailure = false - case .store(.transitioned(let goal)): - state.goal = goal + case .store(.transitioned(let status)): + state.updatedGoalStatus = status state.isTransitioning = false case .store(.failed): state.isLoading = false @@ -184,11 +186,10 @@ extension GoalDetailFeature { goalId: String, status: DevelopmentGoal.Status ) -> Effect { - .run { [fetchGoalUseCase, updateGoalStatusUseCase] send in + .run { [updateGoalStatusUseCase] send in do { try await updateGoalStatusUseCase.execute(goalId, to: status) - let goal = try await fetchGoalUseCase.execute(goalId) - await send(.store(.transitioned(goal))) + await send(.store(.transitioned(status))) } catch { await send(.store(.transitionFailed)) } diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift index f3d61b81..936eaf50 100644 --- a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift @@ -135,10 +135,9 @@ struct GoalDetailFeatureTests { } } - @Test("진행 중 목표는 확인 후 보관 상태로 전환한다") - func 진행_중_목표는_확인_후_보관_상태로_전환한다() async throws { + @Test("상태 전환 성공은 후속 재조회 없이 요청 상태를 반영한다") + func 상태_전환_성공은_후속_재조회_없이_요청_상태를_반영한다() async throws { let goal = try makeDevelopmentGoal() - let archivedGoal = try makeDevelopmentGoal(status: .archived) let spy = UpdateDevelopmentGoalStatusUseCaseSpy() var state = GoalDetailFeature.State(goalId: goal.id) state.goal = goal @@ -148,7 +147,7 @@ struct GoalDetailFeatureTests { } withDependencies: { $0.developmentUpdateGoalStatusUseCase = spy $0.developmentFetchGoalUseCase = FetchDevelopmentGoalUseCaseStub( - result: .success(archivedGoal) + result: .failure(RecordTestError.failed) ) } @@ -159,8 +158,8 @@ struct GoalDetailFeatureTests { $0.alert = nil $0.isTransitioning = true } - await store.receive(.store(.transitioned(archivedGoal))) { - $0.goal = archivedGoal + await store.receive(.store(.transitioned(.archived))) { + $0.updatedGoalStatus = .archived $0.isTransitioning = false } @@ -169,11 +168,26 @@ struct GoalDetailFeatureTests { ]) } + @Test("반영한 상태는 다음 상태 전환의 기준으로 사용한다") + func 반영한_상태는_다음_상태_전환의_기준으로_사용한다() async throws { + let goal = try makeDevelopmentGoal() + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.updatedGoalStatus = .archived + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } + + await store.send(.view(.selectStatus(.inProgress))) { + $0.alert = GoalDetailFeature.transitionConfirmationAlert(.inProgress) + } + } + @Test("완료되거나 보관된 목표는 진행 중으로 되돌릴 수 있다") func 완료되거나_보관된_목표는_진행_중으로_되돌릴_수_있다() async throws { for status in [DevelopmentGoal.Status.completed, .archived] { let goal = try makeDevelopmentGoal(status: status) - let resumedGoal = try makeDevelopmentGoal() let spy = UpdateDevelopmentGoalStatusUseCaseSpy() var state = GoalDetailFeature.State(goalId: goal.id) state.goal = goal @@ -182,9 +196,6 @@ struct GoalDetailFeatureTests { GoalDetailFeature() } withDependencies: { $0.developmentUpdateGoalStatusUseCase = spy - $0.developmentFetchGoalUseCase = FetchDevelopmentGoalUseCaseStub( - result: .success(resumedGoal) - ) } await store.send(.view(.selectStatus(.inProgress))) { @@ -194,8 +205,8 @@ struct GoalDetailFeatureTests { $0.alert = nil $0.isTransitioning = true } - await store.receive(.store(.transitioned(resumedGoal))) { - $0.goal = resumedGoal + await store.receive(.store(.transitioned(.inProgress))) { + $0.updatedGoalStatus = .inProgress $0.isTransitioning = false } @@ -277,7 +288,6 @@ struct GoalDetailFeatureTests { @Test("모든 기록이 확정되면 확인 후 목표를 완료한다") func 모든_기록이_확정되면_확인_후_목표를_완료한다() async throws { let goal = try makeDevelopmentGoal() - let completedGoal = try makeDevelopmentGoal(status: .completed) let record = try makeConfirmedDevelopmentRecord() let version = try makeDevelopmentRecordVersion() let item = RecordTimelineItem(record: record, currentVersion: version) @@ -290,9 +300,6 @@ struct GoalDetailFeatureTests { GoalDetailFeature() } withDependencies: { $0.developmentUpdateGoalStatusUseCase = spy - $0.developmentFetchGoalUseCase = FetchDevelopmentGoalUseCaseStub( - result: .success(completedGoal) - ) } await store.send(.view(.selectStatus(.completed))) { @@ -302,8 +309,8 @@ struct GoalDetailFeatureTests { $0.alert = nil $0.isTransitioning = true } - await store.receive(.store(.transitioned(completedGoal))) { - $0.goal = completedGoal + await store.receive(.store(.transitioned(.completed))) { + $0.updatedGoalStatus = .completed $0.isTransitioning = false }