From 565d9c7c2a1f164b42a4a717faacd514af7952ec Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 15 Sep 2026 13:13:46 +0900 Subject: [PATCH 1/9] =?UTF-8?q?ui:=20=EA=B0=9C=EB=B0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=9E=91=EC=84=B1=20=EB=8F=99=EC=9E=91=EC=9D=84=20?= =?UTF-8?q?=ED=95=98=EB=8B=A8=EC=97=90=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GoalDetail/GoalDetailRecordActions.swift | 78 +++++++++++++++++++ .../Record/GoalDetail/GoalDetailView.swift | 47 ++++------- 2 files changed, 94 insertions(+), 31 deletions(-) create mode 100644 Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailRecordActions.swift diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailRecordActions.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailRecordActions.swift new file mode 100644 index 00000000..9fd8f7a6 --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailRecordActions.swift @@ -0,0 +1,78 @@ +// +// GoalDetailRecordActions.swift +// Development +// +// Created by opfic on 9/15/26. +// + +import SwiftUI +import PresentationShared + +struct GoalDetailRecordActionBar: View { + @ScaledMetric(relativeTo: .headline) private var buttonLabelHeight = CGFloat(40) + let hasDraft: Bool + let isDisabled: Bool + let onAddRecord: () -> Void + let onContinueRecord: () -> Void + + var body: some View { + Group { + if hasDraft { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + secondaryAddRecordButton + continueRecordButton + } + VStack(alignment: .trailing, spacing: 8) { + secondaryAddRecordButton + continueRecordButton + } + } + } else { + primaryRecordButton( + RecordPresentation.text("development_record_add"), + action: onAddRecord + ) + } + } + .padding(.horizontal) + .padding(.vertical, 12) + .background(Color.surface, ignoresSafeAreaEdges: .bottom) + } + + private var secondaryAddRecordButton: some View { + Button(action: onAddRecord) { + Text(RecordPresentation.text("development_record_add")) + .font(.callout) + .foregroundStyle(Color.accent) + .fixedSize(horizontal: true, vertical: false) + .frame(maxWidth: .infinity) + .frame(height: buttonLabelHeight) + } + .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .primaryContainer) + .disabled(isDisabled) + } + + private var continueRecordButton: some View { + primaryRecordButton( + RecordPresentation.text("development_record_continue"), + action: onContinueRecord + ) + } + + private func primaryRecordButton( + _ title: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(title) + .font(.headline) + .foregroundStyle(Color.white) + .fixedSize(horizontal: true, vertical: false) + .frame(maxWidth: .infinity) + .frame(height: buttonLabelHeight) + } + .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .accent) + .disabled(isDisabled) + } +} diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift index e3c9c3c8..ea8eaec1 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -27,12 +27,7 @@ public struct GoalDetailView: View { ScrollView { LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) { Section { - let draft = store.items.first(where: \.hasDraft) timelineCard - .padding(.bottom, store.allowsRecordMutation && draft != nil ? 8 : 0) - if store.allowsRecordMutation, let draft { - continueButton(draft.record) - } } header: { titleBar } @@ -40,6 +35,20 @@ public struct GoalDetailView: View { } } .safeAreaInset(edge: .top, spacing: 0) { topBar } + .safeAreaInset(edge: .bottom, spacing: 0) { + if store.hasLoaded, store.allowsRecordMutation { + GoalDetailRecordActionBar( + hasDraft: draftRecord != nil, + isDisabled: store.isLoading || store.isTransitioning, + onAddRecord: { + editorDestination = EditorDestination(record: nil) + }, + onContinueRecord: { + editorDestination = EditorDestination(record: draftRecord) + } + ) + } + } .background(Color.appBackground.ignoresSafeArea()) .toolbarVisibility(.hidden, for: .navigationBar) .onAppear { store.send(.view(.fetch)) } @@ -162,21 +171,6 @@ public struct GoalDetailView: View { } } } - - if store.hasLoaded, store.allowsRecordMutation { - Button { - editorDestination = EditorDestination(record: nil) - } label: { - Label( - RecordPresentation.text("development_record_add"), - systemImage: "plus" - ) - .font(.callout) - .foregroundStyle(Color.accent) - } - .adaptiveButtonStyle(color: .primaryContainer) - .disabled(store.isLoading) - } } .padding() .background { @@ -185,17 +179,8 @@ public struct GoalDetailView: View { } } - private func continueButton(_ record: DevelopmentRecord) -> some View { - Button { - editorDestination = EditorDestination(record: record) - } label: { - Text(RecordPresentation.text("development_record_continue")) - .font(.headline) - .foregroundStyle(Color.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - } - .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .accent) + private var draftRecord: DevelopmentRecord? { + store.items.first(where: \.hasDraft)?.record } private func select(_ item: RecordTimelineItem) { From 8a0f55cfbd16e151312bdacd3f2017df9326645e Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 15 Sep 2026 13:14:08 +0900 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20=EC=B5=9C=EC=8B=A0=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20=EA=B8=B0=EB=A1=9D=EC=9D=84=20=EB=A8=BC=EC=A0=80=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Record/GoalDetail/GoalDetailFeature.swift | 4 +-- .../Tests/Record/GoalDetailFeatureTests.swift | 28 ++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift index edc38c8c..00737886 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift @@ -198,7 +198,7 @@ extension GoalDetailFeature { static func precedes(_ lhs: DevelopmentRecord, _ rhs: DevelopmentRecord) -> Bool { if lhs.createdAt == rhs.createdAt { return lhs.id < rhs.id } - return lhs.createdAt < rhs.createdAt + return rhs.createdAt < lhs.createdAt } static func canTransition( @@ -225,7 +225,7 @@ extension GoalDetailFeature { messageKey: "development_goal_completion_record_required_message" ) } - guard items.last?.isUnconfirmed == false else { + guard items.first?.isUnconfirmed == false else { return informationAlert( titleKey: "development_goal_completion_version_required_title", messageKey: "development_goal_completion_version_required_message" diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift index 936eaf50..b93a3440 100644 --- a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift @@ -13,8 +13,8 @@ import PresentationShared @MainActor struct GoalDetailFeatureTests { - @Test("타임라인은 생성 순서로 기록을 정렬하고 최신 확정 버전을 구성한다") - func 타임라인은_생성_순서로_기록을_정렬하고_최신_확정_버전을_구성한다() async throws { + @Test("타임라인은 최신 기록부터 정렬하고 최신 확정 버전을 구성한다") + func 타임라인은_최신_기록부터_정렬하고_최신_확정_버전을_구성한다() async throws { let goal = try makeDevelopmentGoal(title: "개발 목표와 기록 이력 구성") let confirmedRecord = try makeConfirmedDevelopmentRecord(id: "confirmed") let draftRecord = try makeDevelopmentRecord( @@ -23,8 +23,8 @@ struct GoalDetailFeatureTests { ) let version = try makeDevelopmentRecordVersion(recordId: confirmedRecord.id) let items = [ - RecordTimelineItem(record: confirmedRecord, currentVersion: version), - RecordTimelineItem(record: draftRecord, currentVersion: nil) + RecordTimelineItem(record: draftRecord, currentVersion: nil), + RecordTimelineItem(record: confirmedRecord, currentVersion: version) ] let store = TestStore( initialState: GoalDetailFeature.State(goalId: goal.id) @@ -236,15 +236,23 @@ struct GoalDetailFeatureTests { #expect(await spy.requests().isEmpty) } - @Test("마지막 기록이 초안이면 목표 완료 전에 기록 확정을 안내한다") - func 마지막_기록이_초안이면_목표_완료_전에_기록_확정을_안내한다() async throws { + @Test("최신 기록이 초안이면 목표 완료 전에 기록 확정을 안내한다") + func 최신_기록이_초안이면_목표_완료_전에_기록_확정을_안내한다() async throws { let goal = try makeDevelopmentGoal() - let draft = try makeDevelopmentRecord() - let item = RecordTimelineItem(record: draft, currentVersion: nil) + let draft = try makeDevelopmentRecord( + id: "draft", + createdAt: Date(timeIntervalSince1970: 1_700_000_300) + ) + let confirmed = try makeConfirmedDevelopmentRecord(id: "confirmed") + let version = try makeDevelopmentRecordVersion(recordId: confirmed.id) + let items = [ + RecordTimelineItem(record: draft, currentVersion: nil), + RecordTimelineItem(record: confirmed, currentVersion: version) + ] let spy = UpdateDevelopmentGoalStatusUseCaseSpy() var state = GoalDetailFeature.State(goalId: goal.id) state.goal = goal - state.items = [item] + state.items = items state.hasLoaded = true let store = TestStore(initialState: state) { GoalDetailFeature() @@ -253,7 +261,7 @@ struct GoalDetailFeatureTests { } await store.send(.view(.selectStatus(.completed))) { - $0.alert = GoalDetailFeature.completionBlockingAlert(items: [item]) + $0.alert = GoalDetailFeature.completionBlockingAlert(items: items) } #expect(await spy.requests().isEmpty) From 6dea03ddac452a0b6aeea603cae9b00cdc6eef98 Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 15 Sep 2026 14:10:36 +0900 Subject: [PATCH 3/9] =?UTF-8?q?ui:=20=ED=85=8C=EB=91=90=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/Presentation/HomeTab/Sources/Search/SearchView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift index 417963c2..91150ee8 100644 --- a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift +++ b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift @@ -162,7 +162,6 @@ private struct SearchField: View { .background { RoundedRectangle(cornerRadius: 16) .fill(Color.surface) - .strokeBorder(Color.border, lineWidth: 2) } .onAppear { isFocused = true From 0d10f04a99bd07461c8a37fb9b6295fc3ee11276 Mon Sep 17 00:00:00 2001 From: opficdev Date: Tue, 15 Sep 2026 15:12:11 +0900 Subject: [PATCH 4/9] =?UTF-8?q?ui:=20=ED=83=AD=EB=B0=94=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Record/Editor/RecordEditorView.swift | 11 ----------- .../PresentationShared/Sources/Extension/View+.swift | 9 +++++++++ 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift index fbf711ba..d678adf3 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift @@ -222,17 +222,6 @@ public struct RecordEditorView: View { } -private extension View { - @ViewBuilder - func topBarButtonStyle(color: Color = .clear) -> some View { - if #available(iOS 26.0, *) { - adaptiveButtonStyle(shape: .circle, color: color, glassEffect: .enabled) - } else { - adaptiveButtonStyle(color: color, glassEffect: .enabled) - } - } -} - private struct FieldCard: View { let title: String @ViewBuilder let content: Content diff --git a/Application/Presentation/PresentationShared/Sources/Extension/View+.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+.swift index e024bbc9..61b59fc3 100644 --- a/Application/Presentation/PresentationShared/Sources/Extension/View+.swift +++ b/Application/Presentation/PresentationShared/Sources/Extension/View+.swift @@ -13,6 +13,15 @@ public enum AdaptiveButtonGlassEffect { } public extension View { + @ViewBuilder + func topBarButtonStyle(color: Color = .clear) -> some View { + if #available(iOS 26.0, *) { + adaptiveButtonStyle(shape: .circle, color: color, glassEffect: .enabled) + } else { + adaptiveButtonStyle(color: color, glassEffect: .enabled) + } + } + func toolbarBackground(_ color: Color) -> some View { overlay(alignment: .top) { GeometryReader { proxy in From f828e4a37145b5ac4ec306bcd9925a5379194ece Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 19 Sep 2026 20:33:00 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20TCA=20=EB=B2=84=EC=A0=84=201.26.2?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /Users/opfic/Library/Developer/Xcode/DerivedData/DevLog-bnaraznbjqlervdissoctxcqnwbn/SourcePackages/checkouts/swift-composable-architecture/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift:149:17 Cannot form key path to main actor-isolated subscript 'subscript(fileID:filePath:line:column:)' --- Libraries/ThirdParty/Project.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Libraries/ThirdParty/Project.swift b/Libraries/ThirdParty/Project.swift index 1b61b6dc..bf33cad8 100644 --- a/Libraries/ThirdParty/Project.swift +++ b/Libraries/ThirdParty/Project.swift @@ -23,7 +23,11 @@ let project = Project( ), .package( url: "https://github.com/pointfreeco/swift-composable-architecture", - .exact("1.25.5") + .exact("1.26.2") + ), + .package( + url: "https://github.com/pointfreeco/xctest-dynamic-overlay", + .exact("1.13.1") ), .package( url: "https://github.com/apple/swift-collections.git", From 092d3dfd3791a788dd39235b22347876b36b70a4 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 19 Sep 2026 21:13:32 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20Todo=20=EC=97=B0=EA=B2=B0=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppGraph+PresentationDependencies.swift | 5 + .../DevelopmentDependencyPreparation.swift | 31 +++ .../GoalDetailFeature+Support.swift | 145 ++++++++++ .../Record/GoalDetail/GoalDetailFeature.swift | 215 ++++++--------- .../Record/GoalDetail/GoalDetailView.swift | 187 +++---------- .../GoalDetail/GoalDetailViewSupport.swift | 156 +++++++++++ .../GoalDetail/GoalLinkedTodoCard.swift | 120 +++++++++ .../GoalDetail/GoalTodoLinkFeature.swift | 254 ++++++++++++++++++ .../Record/GoalDetail/GoalTodoLinkSheet.swift | 246 +++++++++++++++++ .../Tests/Record/GoalDetailFeatureTests.swift | 22 ++ .../Record/GoalDetailTodoFeatureTests.swift | 105 ++++++++ .../Record/GoalDetailTodoTestSupport.swift | 103 +++++++ .../Record/GoalTodoLinkFeatureTests.swift | 121 +++++++++ .../Resources/Localizable.xcstrings | 133 +++++++++ .../Sources/Extension/View+Menu.swift | 2 +- 15 files changed, 1556 insertions(+), 289 deletions(-) create mode 100644 Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature+Support.swift create mode 100644 Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift create mode 100644 Application/Presentation/Development/Sources/Record/GoalDetail/GoalLinkedTodoCard.swift create mode 100644 Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkFeature.swift create mode 100644 Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift create mode 100644 Application/Presentation/Development/Tests/Record/GoalDetailTodoFeatureTests.swift create mode 100644 Application/Presentation/Development/Tests/Record/GoalDetailTodoTestSupport.swift create mode 100644 Application/Presentation/Development/Tests/Record/GoalTodoLinkFeatureTests.swift diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index b283b69f..91407be8 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -59,6 +59,11 @@ private extension AppGraph { .developmentRecordMutationUseCaseGraph .restoreDevelopmentRecordUseCase ) + DevelopmentDependencyPreparation.prepareTodo( + &dependencies, + fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase, + updateTodoGoalUseCase: todoGraphSet.todoGoalUseCaseGraph.updateTodoGoalUseCase + ) } func prepareEntryDependencies(_ dependencies: inout DependencyValues) { diff --git a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift index 07242f5b..dabde203 100644 --- a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift +++ b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift @@ -41,6 +41,15 @@ public enum DevelopmentDependencyPreparation { dependencies.developmentConfirmRecordUseCase = confirmRecordUseCase dependencies.developmentRestoreRecordUseCase = restoreRecordUseCase } + + public static func prepareTodo( + _ dependencies: inout DependencyValues, + fetchTodosUseCase: FetchTodosUseCase, + updateTodoGoalUseCase: UpdateTodoGoalUseCase + ) { + dependencies.developmentFetchTodosUseCase = fetchTodosUseCase + dependencies.developmentUpdateTodoGoalUseCase = updateTodoGoalUseCase + } } extension DependencyValues { @@ -88,6 +97,16 @@ extension DependencyValues { get { self[DevelopmentRestoreRecordUseCaseKey.self] } set { self[DevelopmentRestoreRecordUseCaseKey.self] = newValue } } + + var developmentFetchTodosUseCase: FetchTodosUseCase { + get { self[DevelopmentFetchTodosUseCaseKey.self] } + set { self[DevelopmentFetchTodosUseCaseKey.self] = newValue } + } + + var developmentUpdateTodoGoalUseCase: UpdateTodoGoalUseCase { + get { self[DevelopmentUpdateTodoGoalUseCaseKey.self] } + set { self[DevelopmentUpdateTodoGoalUseCaseKey.self] = newValue } + } } private enum DevelopmentFetchGoalUseCaseKey: DependencyKey { @@ -143,3 +162,15 @@ private enum DevelopmentRestoreRecordUseCaseKey: DependencyKey { preconditionFailure("RestoreDevelopmentRecordUseCase must be provided.") } } + +private enum DevelopmentFetchTodosUseCaseKey: DependencyKey { + static var liveValue: FetchTodosUseCase { + preconditionFailure("FetchTodosUseCase must be provided.") + } +} + +private enum DevelopmentUpdateTodoGoalUseCaseKey: DependencyKey { + static var liveValue: UpdateTodoGoalUseCase { + preconditionFailure("UpdateTodoGoalUseCase must be provided.") + } +} diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature+Support.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature+Support.swift new file mode 100644 index 00000000..8cdb1beb --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature+Support.swift @@ -0,0 +1,145 @@ +// +// GoalDetailFeature+Support.swift +// Development +// +// Created by opfic on 9/15/26. +// + +import Domain +import Foundation +import PresentationShared + +extension GoalDetailFeature { + static func precedes(_ lhs: DevelopmentRecord, _ rhs: DevelopmentRecord) -> Bool { + if lhs.createdAt == rhs.createdAt { return lhs.id < rhs.id } + return rhs.createdAt < lhs.createdAt + } + + 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.first?.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: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) + } + } message: { + TextState(String( + localized: "development_record_timeline_error_message", + bundle: PresentationResources.bundle + )) + } + } + + 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/GoalDetailFeature.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift index 00737886..b9e6d812 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift @@ -5,6 +5,7 @@ // Created by opfic on 9/13/26. // +import Core import Domain import Foundation import PresentationShared @@ -26,14 +27,20 @@ struct GoalDetailFeature { @ObservableState struct State: Equatable { @Presents var alert: AlertState? + @Presents var todoLinkSheet: GoalTodoLinkFeature.State? let goalId: String var goal: DevelopmentGoal? var updatedGoalStatus: DevelopmentGoal.Status? var items = [RecordTimelineItem]() + var todos = [Todo]() + var linkedTodos = [Todo]() var isLoading = false + var isTodoLoading = false var isTransitioning = false var hasLoaded = false + var hasLoadedTodos = false var hasLoadFailure = false + var hasTodoLoadFailure = false var goalTitle: String { goal?.title ?? "" @@ -47,13 +54,19 @@ struct GoalDetailFeature { goalStatus == .inProgress } + var allowsTodoLinkMutation: Bool { + goalStatus == .inProgress + } + init(goalId: String) { self.goalId = goalId } } - enum Action: Equatable { + enum Action: BindableAction, Equatable { case alert(PresentationAction) + case todoLinkSheet(PresentationAction) + case binding(BindingAction) case view(ViewAction) case store(StoreAction) @@ -63,14 +76,18 @@ struct GoalDetailFeature { enum ViewAction: Equatable { case fetch + case manageTodos case refresh + case retryTodos case selectStatus(DevelopmentGoal.Status) } enum StoreAction: Equatable { case loaded(goal: DevelopmentGoal, items: [RecordTimelineItem]) + case loadedTodos([Todo]) case transitioned(DevelopmentGoal.Status) case failed + case todosFailed case transitionFailed } } @@ -78,9 +95,11 @@ struct GoalDetailFeature { @Dependency(\.developmentFetchGoalUseCase) private var fetchGoalUseCase @Dependency(\.developmentFetchRecordsUseCase) private var fetchRecordsUseCase @Dependency(\.developmentFetchRecordVersionUseCase) private var fetchRecordVersionUseCase + @Dependency(\.developmentFetchTodosUseCase) private var fetchTodosUseCase @Dependency(\.developmentUpdateGoalStatusUseCase) private var updateGoalStatusUseCase var body: some ReducerOf { + BindingReducer() Reduce { state, action in switch action { case .alert(.presented(.confirmTransition(let status))): @@ -90,16 +109,51 @@ struct GoalDetailFeature { return transitionEffect(goalId: state.goalId, status: status) case .alert: break + case .todoLinkSheet(.dismiss), + .todoLinkSheet(.presented(.delegate(.close))): + state.todoLinkSheet = nil + case .todoLinkSheet(.presented(.delegate(.saved(let todos)))): + state.todos = todos + state.linkedTodos = todos.filter { $0.goalId == state.goalId } + state.todoLinkSheet = nil + case .todoLinkSheet: + break + case .binding: + break case .view(.fetch): guard !state.hasLoaded, !state.isLoading else { break } state.isLoading = true state.hasLoadFailure = false - return fetchEffect(goalId: state.goalId) + state.isTodoLoading = true + state.hasTodoLoadFailure = false + return .concatenate( + fetchEffect(goalId: state.goalId), + fetchTodosEffect() + ) + case .view(.manageTodos): + guard state.hasLoadedTodos, + state.allowsTodoLinkMutation, + !state.isTodoLoading, + state.todoLinkSheet == nil else { break } + state.todoLinkSheet = GoalTodoLinkFeature.State( + goalId: state.goalId, + todos: state.todos + ) case .view(.refresh): guard !state.isLoading else { break } state.isLoading = true state.hasLoadFailure = false - return fetchEffect(goalId: state.goalId) + state.isTodoLoading = true + state.hasTodoLoadFailure = false + return .concatenate( + fetchEffect(goalId: state.goalId), + fetchTodosEffect() + ) + case .view(.retryTodos): + guard !state.isTodoLoading else { break } + state.isTodoLoading = true + state.hasTodoLoadFailure = false + return fetchTodosEffect() case .view(.selectStatus(let status)): guard let goalStatus = state.goalStatus, !state.isLoading, @@ -118,6 +172,12 @@ struct GoalDetailFeature { state.isLoading = false state.hasLoaded = true state.hasLoadFailure = false + case .store(.loadedTodos(let todos)): + state.todos = todos + state.linkedTodos = todos.filter { $0.goalId == state.goalId } + state.isTodoLoading = false + state.hasLoadedTodos = true + state.hasTodoLoadFailure = false case .store(.transitioned(let status)): state.updatedGoalStatus = status state.isTransitioning = false @@ -125,6 +185,9 @@ struct GoalDetailFeature { state.isLoading = false state.hasLoadFailure = true state.alert = Self.errorAlert + case .store(.todosFailed): + state.isTodoLoading = false + state.hasTodoLoadFailure = true case .store(.transitionFailed): state.isTransitioning = false state.alert = Self.transitionErrorAlert @@ -133,6 +196,9 @@ struct GoalDetailFeature { return .none } .ifLet(\.$alert, action: \.alert) + .ifLet(\.$todoLinkSheet, action: \.todoLinkSheet) { + GoalTodoLinkFeature() + } } } @@ -196,136 +262,23 @@ extension GoalDetailFeature { } } - static func precedes(_ lhs: DevelopmentRecord, _ rhs: DevelopmentRecord) -> Bool { - if lhs.createdAt == rhs.createdAt { return lhs.id < rhs.id } - return rhs.createdAt < lhs.createdAt - } - - 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.first?.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: { - ButtonState(role: .cancel) { - TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) + func fetchTodosEffect() -> Effect { + .run { [fetchTodosUseCase] send in + do { + let page = try await fetchTodosUseCase.execute( + TodoQuery( + sortTarget: .updatedAt, + sortOrder: .latest, + pageSize: 100, + fetchAllPages: true + ), + cursor: nil + ) + await send(.store(.loadedTodos(page.items))) + } catch { + await send(.store(.todosFailed)) } - } message: { - TextState(String( - localized: "development_record_timeline_error_message", - bundle: PresentationResources.bundle - )) } } - 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 ea8eaec1..4c63c15f 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -14,6 +14,7 @@ public struct GoalDetailView: View { @State private var store: StoreOf @State private var editorDestination: EditorDestination? @State private var detailDestination: DetailDestination? + @State private var todoDestination: TodoDestination? public init(goalId: String) { self._store = State(initialValue: Store( @@ -28,6 +29,15 @@ public struct GoalDetailView: View { LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) { Section { timelineCard + GoalLinkedTodoCard( + todos: store.linkedTodos, + isLoading: store.isTodoLoading, + hasLoadFailure: store.hasTodoLoadFailure, + allowsManagement: store.hasLoaded && store.allowsTodoLinkMutation, + onManage: { store.send(.view(.manageTodos)) }, + onRetry: { store.send(.view(.retryTodos)) }, + onSelect: { todoDestination = TodoDestination(id: $0) } + ) } header: { titleBar } @@ -58,17 +68,33 @@ public struct GoalDetailView: View { goalId: store.goalId, goalTitle: store.goalTitle, record: destination.record, - onCompletion: finishEditing + onCompletion: { + editorDestination = nil + store.send(.view(.refresh)) + } ) } + .sheet(item: $store.scope(state: \.todoLinkSheet, action: \.todoLinkSheet)) { + GoalTodoLinkSheet(store: $0) + } .navigationDestination(item: $detailDestination) { destination in RecordDetailView( goalTitle: store.goalTitle, record: destination.record, allowsMutation: store.allowsRecordMutation, - onUpdate: refresh + onUpdate: { store.send(.view(.refresh)) } ) } + .navigationDestination(item: $todoDestination) { destination in + TodoDetailView(store: Store( + initialState: TodoDetailFeature.State( + todoId: destination.id, + showEditButton: false + ) + ) { + TodoDetailFeature() + }) + } .overlay { if store.isTransitioning { LoadingView() @@ -172,7 +198,7 @@ public struct GoalDetailView: View { } } } - .padding() + .padding([.horizontal, .top]) // TimelineView 자체의 하단 패딩과 겹치기 때문에 .bottom은 제외 .background { RoundedRectangle(cornerRadius: 24) .fill(Color.surface) @@ -191,9 +217,7 @@ public struct GoalDetailView: View { } } - private func statusMenuItems( - _ status: DevelopmentGoal.Status - ) -> [ProminentMenuItem] { + private func statusMenuItems(_ status: DevelopmentGoal.Status) -> [ProminentMenuItem] { switch status { case .inProgress: [ @@ -218,155 +242,4 @@ public struct GoalDetailView: View { ] } } - - private func finishEditing() { - editorDestination = nil - refresh() - } - - private func refresh() { - store.send(.view(.refresh)) - } -} - -private struct TimelineRow: View { - let item: RecordTimelineItem - let isFirst: Bool - let isLast: Bool - let onSelect: () -> Void - - var body: some View { - Button { - onSelect() - } label: { - HStack(alignment: .top, spacing: 16) { - timelineIndicator - VStack(alignment: .leading, spacing: 5) { - Text(item.title) - .font(.body.weight(.semibold)) - .foregroundStyle(Color.primary) - .lineLimit(2) - status - if item.hasDraft { - RelativeTimeText( - date: item.date, - bodyFont: .caption, - bodyColor: .textTertiary - ) - } else { - Text(item.date, format: .dateTime.month().day()) - .font(.caption) - .foregroundStyle(Color.textTertiary) - } - } - Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(.callout.weight(.semibold)) - .foregroundStyle(Color.border) - .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 { - Circle() - .fill(item.hasDraft ? Color.warning : .accent) - .frame(width: RecordTimelineLayout.markerSize, height: RecordTimelineLayout.markerSize) - .padding(.top, RecordTimelineLayout.markerTopPadding) - } - - private var status: some View { - Text(statusText) - .font(.caption) - .foregroundStyle(item.hasDraft ? Color.warning : .accent) - .padding(.horizontal, 9) - .padding(.vertical, 4) - .background( - item.hasDraft ? Color.warning.opacity(0.12) : Color.primaryContainer, - in: .rect(cornerRadius: 8) - ) - } - - private var statusText: String { - guard let versionNumber = item.versionNumber else { - return RecordPresentation.text("development_record_draft_status") - } - return String.localizedStringWithFormat( - RecordPresentation.text("development_record_confirmed_version_format"), - RecordPresentation.versionLabel(versionNumber) - ) - } -} - -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? -} - -private struct DetailDestination: Identifiable { - let record: DevelopmentRecord - var id: String { record.id } } diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift new file mode 100644 index 00000000..38de98c9 --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift @@ -0,0 +1,156 @@ +// +// GoalDetailViewSupport.swift +// Development +// +// Created by opfic on 9/15/26. +// + +import SwiftUI +import Domain +import PresentationShared + +struct TimelineRow: View { + let item: RecordTimelineItem + let isFirst: Bool + let isLast: Bool + let onSelect: () -> Void + + var body: some View { + Button { + onSelect() + } label: { + HStack(alignment: .top, spacing: 16) { + timelineIndicator + VStack(alignment: .leading, spacing: 5) { + Text(item.title) + .font(.body.weight(.semibold)) + .foregroundStyle(Color.primary) + .lineLimit(2) + status + if item.hasDraft { + RelativeTimeText( + date: item.date, + bodyFont: .caption, + bodyColor: .textTertiary + ) + } else { + Text(item.date, format: .dateTime.month().day()) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.callout.weight(.semibold)) + .foregroundStyle(Color.border) + .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 { + Circle() + .fill(item.hasDraft ? Color.warning : .accent) + .frame(width: RecordTimelineLayout.markerSize, height: RecordTimelineLayout.markerSize) + .padding(.top, RecordTimelineLayout.markerTopPadding) + } + + private var status: some View { + Text(statusText) + .font(.caption) + .foregroundStyle(item.hasDraft ? Color.warning : .accent) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background( + item.hasDraft ? Color.warning.opacity(0.12) : Color.primaryContainer, + in: .rect(cornerRadius: 8) + ) + } + + private var statusText: String { + guard let versionNumber = item.versionNumber else { + return RecordPresentation.text("development_record_draft_status") + } + return String.localizedStringWithFormat( + RecordPresentation.text("development_record_confirmed_version_format"), + RecordPresentation.versionLabel(versionNumber) + ) + } +} + +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 + } + } +} + +struct EditorDestination: Identifiable { + let id = UUID() + let record: DevelopmentRecord? +} + +struct DetailDestination: Identifiable { + let record: DevelopmentRecord + var id: String { record.id } +} + +struct TodoDestination: Identifiable { + let id: String +} diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalLinkedTodoCard.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalLinkedTodoCard.swift new file mode 100644 index 00000000..9a2f0d97 --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalLinkedTodoCard.swift @@ -0,0 +1,120 @@ +// +// GoalLinkedTodoCard.swift +// Development +// +// Created by opfic on 9/15/26. +// + +import SwiftUI +import Domain +import PresentationShared + +struct GoalLinkedTodoCard: View { + let todos: [Todo] + let isLoading: Bool + let hasLoadFailure: Bool + let allowsManagement: Bool + let onManage: () -> Void + let onRetry: () -> Void + let onSelect: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + content + } + .padding([.horizontal, .top]) + .background(Color.surface, in: .rect(cornerRadius: 24)) + } + + private var header: some View { + HStack(spacing: 10) { + Text(RecordPresentation.text("development_goal_linked_todos")) + .font(.title3.bold()) + Spacer(minLength: 12) + if allowsManagement { + Button( + RecordPresentation.text("development_goal_todo_manage"), + action: onManage + ) + .font(.callout.weight(.semibold)) + .foregroundStyle(Color.accent) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(Color.primaryContainer, in: .capsule) + .disabled(isLoading) + } + } + } + + @ViewBuilder + private var content: some View { + if isLoading, todos.isEmpty { + ProgressView() + .tint(Color.accent) + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } else if hasLoadFailure, todos.isEmpty { + ContentUnavailableView { + Label( + RecordPresentation.text("common_error_title"), + systemImage: "exclamationmark.triangle" + ) + } description: { + Text(RecordPresentation.text("development_goal_todo_load_error_message")) + } actions: { + Button( + RecordPresentation.text("development_goal_todo_retry"), + action: onRetry + ) + .buttonStyle(.borderedProminent) + } + } else if todos.isEmpty { + ContentUnavailableView( + RecordPresentation.text("development_goal_todo_empty_title"), + systemImage: "checklist", + description: Text( + RecordPresentation.text("development_goal_todo_empty_message") + ) + ) + } else { + LazyVStack(spacing: 0) { + ForEach(todos, id: \.id) { todo in + GoalLinkedTodoRow(todo: todo) { + onSelect(todo.id) + } + if todo.id != todos.last?.id { + Divider() + } + } + } + } + } +} + +private struct GoalLinkedTodoRow: View { + let todo: Todo + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 14) { + Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle") + .font(.title2) + .foregroundStyle(todo.isCompleted ? Color.accent : Color.border) + Text(todo.title) + .font(.body) + .foregroundStyle(Color.primary) + .multilineTextAlignment(.leading) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: "chevron.right") + .font(.callout.weight(.semibold)) + .foregroundStyle(Color.border) + } + .padding(.vertical, 12) + .contentShape(.rect) + } + .buttonStyle(.plain) + } +} diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkFeature.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkFeature.swift new file mode 100644 index 00000000..67c53e4e --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkFeature.swift @@ -0,0 +1,254 @@ +// +// GoalTodoLinkFeature.swift +// Development +// +// Created by opfic on 9/15/26. +// + +import Core +import Domain +import Foundation +import PresentationShared + +struct TodoGoalLinkUpdate: Equatable, Sendable { + let todoId: String + let goalId: String? +} + +struct GoalTodoLinkSectionItem: Equatable, Identifiable { + let category: TodoCategoryItem + var todos: [Todo] + + var id: String { category.id } +} + +@Reducer +struct GoalTodoLinkFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + let goalId: String + var todos: [Todo] + var selectedTodoIDs: Set + var searchText = "" + var isLoading = false + var isUpdating = false + var hasLoadFailure = false + + init(goalId: String, todos: [Todo]) { + self.goalId = goalId + self.todos = todos + self.selectedTodoIDs = Set( + todos.lazy.filter { $0.goalId == goalId }.map(\.id) + ) + } + + var filteredTodos: [Todo] { + guard !searchText.isEmpty else { return todos } + return todos.filter { todo in + todo.title.localizedCaseInsensitiveContains(searchText) + || String(todo.number).localizedCaseInsensitiveContains(searchText) + } + } + + var sections: [GoalTodoLinkSectionItem] { + var sectionIndexByID = [String: Int]() + var sections = [GoalTodoLinkSectionItem]() + for todo in filteredTodos { + let category = TodoCategoryItem(from: todo.category) + if let index = sectionIndexByID[category.id] { + sections[index].todos.append(todo) + } else { + sectionIndexByID[category.id] = sections.count + sections.append(GoalTodoLinkSectionItem(category: category, todos: [todo])) + } + } + return sections + } + + var canClearSelection: Bool { + !selectedTodoIDs.isEmpty && !isUpdating + } + + var canSave: Bool { + !isLoading && !hasLoadFailure && !isUpdating + } + } + + enum Action: BindableAction, Equatable { + case alert(PresentationAction) + case binding(BindingAction) + case view(ViewAction) + case store(StoreAction) + case delegate(Delegate) + + enum ViewAction: Equatable { + case close + case clearSelection + case retry + case save + case toggleTodo(String) + } + + enum StoreAction: Equatable { + case loadedTodos([Todo]) + case linksUpdated(Set) + case linkUpdateFailed + case todosFailed + } + + enum Delegate: Equatable { + case close + case saved([Todo]) + } + } + + @Dependency(\.developmentFetchTodosUseCase) private var fetchTodosUseCase + @Dependency(\.developmentUpdateTodoGoalUseCase) private var updateTodoGoalUseCase + + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .alert, .binding, .delegate: + break + case .view(.close): + return .send(.delegate(.close)) + case .view(.clearSelection): + guard !state.isUpdating else { break } + state.selectedTodoIDs.removeAll() + case .view(.retry): + guard !state.isLoading else { break } + state.isLoading = true + state.hasLoadFailure = false + return fetchTodosEffect() + case .view(.save): + guard state.canSave else { break } + let updates = Self.todoLinkUpdates( + todos: state.todos, + goalId: state.goalId, + selectedTodoIDs: state.selectedTodoIDs + ) + guard !updates.isEmpty else { + return .send(.delegate(.saved(state.todos))) + } + state.isUpdating = true + return updateTodoLinksEffect( + updates: updates, + selectedTodoIDs: state.selectedTodoIDs + ) + case .view(.toggleTodo(let todoId)): + guard !state.isUpdating, + state.todos.contains(where: { $0.id == todoId }) else { break } + if state.selectedTodoIDs.contains(todoId) { + state.selectedTodoIDs.remove(todoId) + } else { + state.selectedTodoIDs.insert(todoId) + } + case .store(.loadedTodos(let todos)): + let goalId = state.goalId + state.todos = todos + state.selectedTodoIDs = Set( + todos.lazy.filter { $0.goalId == goalId }.map(\.id) + ) + state.isLoading = false + state.hasLoadFailure = false + case .store(.linksUpdated(let selectedTodoIDs)): + state.todos = state.todos.map { todo in + var todo = todo + if selectedTodoIDs.contains(todo.id) { + todo.goalId = state.goalId + } else if todo.goalId == state.goalId { + todo.goalId = nil + } + return todo + } + state.isUpdating = false + return .send(.delegate(.saved(state.todos))) + case .store(.linkUpdateFailed): + state.isUpdating = false + state.isLoading = true + state.hasLoadFailure = false + state.alert = Self.updateErrorAlert + return fetchTodosEffect() + case .store(.todosFailed): + state.isLoading = false + state.hasLoadFailure = true + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + } +} + +extension GoalTodoLinkFeature { + func fetchTodosEffect() -> Effect { + .run { [fetchTodosUseCase] send in + do { + let page = try await fetchTodosUseCase.execute( + TodoQuery( + sortTarget: .updatedAt, + sortOrder: .latest, + pageSize: 100, + fetchAllPages: true + ), + cursor: nil + ) + await send(.store(.loadedTodos(page.items))) + } catch { + await send(.store(.todosFailed)) + } + } + } + + func updateTodoLinksEffect( + updates: [TodoGoalLinkUpdate], + selectedTodoIDs: Set + ) -> Effect { + .run { [updateTodoGoalUseCase] send in + do { + for update in updates { + try await updateTodoGoalUseCase.execute( + todoId: update.todoId, + goalId: update.goalId + ) + } + await send(.store(.linksUpdated(selectedTodoIDs))) + } catch { + await send(.store(.linkUpdateFailed)) + } + } + } + + static func todoLinkUpdates( + todos: [Todo], + goalId: String, + selectedTodoIDs: Set + ) -> [TodoGoalLinkUpdate] { + todos.compactMap { todo in + let isLinked = todo.goalId == goalId + let shouldLink = selectedTodoIDs.contains(todo.id) + guard isLinked != shouldLink else { return nil } + return TodoGoalLinkUpdate( + todoId: todo.id, + goalId: shouldLink ? goalId : nil + ) + } + } + + static var updateErrorAlert: AlertState { + AlertState { + TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) + } + } message: { + TextState(String( + localized: "development_goal_todo_update_error_message", + bundle: PresentationResources.bundle + )) + } + } +} diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift new file mode 100644 index 00000000..5a4073f0 --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift @@ -0,0 +1,246 @@ +// +// GoalTodoLinkSheet.swift +// Development +// +// Created by opfic on 9/15/26. +// + +import SwiftUI +import Domain +import PresentationShared + +struct GoalTodoLinkSheet: View { + @Bindable var store: StoreOf + @ScaledMetric(relativeTo: .title) private var iconSize = UIFont.preferredFont( + forTextStyle: .title2, + compatibleWith: UITraitCollection(preferredContentSizeCategory: .large) + ).lineHeight + + var body: some View { + ScrollView { + LazyVStack(spacing: 20) { + searchField + sheetContent + Text(RecordPresentation.text("development_goal_todo_optional_footer")) + .font(.footnote) + .foregroundStyle(Color.textTertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .padding(16) + } + .safeAreaInset(edge: .top, spacing: 0) { topBar } + .scrollDismissesKeyboard(.interactively) + .background(Color.appBackground.ignoresSafeArea()) + .overlay { + if store.isUpdating { + LoadingView() + } + } + .prominentAlert(store, state: \.alert, action: \.alert) + .presentationDragIndicator(.visible) + .interactiveDismissDisabled(store.isUpdating) + } + + private var topBar: some View { + ZStack { + Text(RecordPresentation.text("development_goal_todo_link_title")) + .font(.headline) + HStack { + Button { + store.send(.view(.close)) + } label: { + if #available(iOS 26.0, *) { + Image(systemName: "xmark") + .frame(width: iconSize, height: iconSize) + .font(.title) + } else { + Text(RecordPresentation.text("common_close")) + } + } + .topBarButtonStyle(color: Color.surface) + .disabled(store.isUpdating) + Spacer() + HStack(spacing: 8) { + Image(systemName: "ellipsis") + .font(.title3.weight(.semibold)) + .frame(width: iconSize, height: iconSize) + .prominentMenu( + items: clearSelectionMenuItems, + isEnabled: store.canClearSelection + ) { _ in + store.send(.view(.clearSelection)) + } + .topBarButtonStyle(color: Color.surface) + Button { + store.send(.view(.save)) + } label: { + if #available(iOS 26.0, *) { + Image(systemName: "checkmark") + .frame(width: iconSize, height: iconSize) + .foregroundStyle(Color.primary) + .font(.title) + } else { + Text(RecordPresentation.text("development_goal_todo_done")) + .foregroundStyle(Color.accent) + } + } + .topBarButtonStyle(color: Color.surface) + .disabled(!store.canSave) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) + } + + private var searchField: some View { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .foregroundStyle(Color.textTertiary) + TextField( + RecordPresentation.text("development_goal_todo_search"), + text: $store.searchText + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + .font(.title3) + .padding() + .background { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) + .strokeBorder(Color.border, lineWidth: 1) + } + } + + private var clearSelectionMenuItems: [ProminentMenuItem] { + [ + ProminentMenuItem( + action: .clearSelection, + title: RecordPresentation.text("development_goal_todo_clear_selection"), + systemImage: "xmark.circle" + ) + ] + } + + @ViewBuilder + private var sheetContent: some View { + if store.isLoading, store.todos.isEmpty { + ProgressView() + .tint(Color.accent) + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else if store.hasLoadFailure, store.todos.isEmpty { + ContentUnavailableView { + Label( + RecordPresentation.text("common_error_title"), + systemImage: "exclamationmark.triangle" + ) + } description: { + Text(RecordPresentation.text("development_goal_todo_load_error_message")) + } actions: { + Button(RecordPresentation.text("development_goal_todo_retry")) { + store.send(.view(.retry)) + } + .buttonStyle(.borderedProminent) + } + } else if store.todos.isEmpty { + ContentUnavailableView( + RecordPresentation.text("development_goal_todo_available_empty_title"), + systemImage: "checklist", + description: Text( + RecordPresentation.text("development_goal_todo_available_empty_message") + ) + ) + } else if store.filteredTodos.isEmpty { + ContentUnavailableView.search(text: store.searchText) + } else { + ForEach(store.sections) { section in + GoalTodoLinkSection( + section: section, + selectedTodoIDs: store.selectedTodoIDs, + onToggle: { store.send(.view(.toggleTodo($0))) } + ) + } + } + } +} + +private enum GoalTodoLinkMenuAction: Hashable { + case clearSelection +} + +private struct GoalTodoLinkSection: View { + let section: GoalTodoLinkSectionItem + let selectedTodoIDs: Set + let onToggle: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Image(systemName: section.category.symbolName) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.white) + .frame(width: 30, height: 30) + .background(section.category.color, in: .circle) + Text(section.category.localizedName) + .font(.headline) + Spacer() + } + + LazyVStack(spacing: 0) { + ForEach(section.todos, id: \.id) { todo in + GoalTodoLinkRow( + todo: todo, + isSelected: selectedTodoIDs.contains(todo.id) + ) { + onToggle(todo.id) + } + } + } + .background(Color.surface) + .compositingGroup() + .clipShape(.rect(cornerRadius: 20)) + } + } +} + +private struct GoalTodoLinkRow: View { + let todo: Todo + let isSelected: Bool + let onToggle: () -> Void + + var body: some View { + HStack(spacing: 14) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.title2) + .foregroundStyle(isSelected ? Color.accent : Color.border) + VStack(alignment: .leading, spacing: 4) { + Text(todo.title) + .font(.body.weight(isSelected ? .semibold : .regular)) + .foregroundStyle(Color.primary) + .multilineTextAlignment(.leading) + .lineLimit(2) + HStack(spacing: 8) { + Text("#\(todo.number)") + Text(todo.dueDate ?? todo.updatedAt, format: .dateTime.month().day()) + } + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + if todo.isPinned { + Image(systemName: "star.fill") + .foregroundStyle(Color.warning) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(isSelected ? Color.primaryContainer : .clear) + .contentShape(.rect) + .onTapGesture(perform: onToggle) + } +} diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift index b93a3440..126906bf 100644 --- a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift @@ -38,10 +38,12 @@ struct GoalDetailFeatureTests { $0.developmentFetchRecordVersionUseCase = FetchDevelopmentRecordVersionUseCaseStub( resultByRecordId: [confirmedRecord.id: .success(version)] ) + $0.developmentFetchTodosUseCase = FetchTodosUseCaseStub() } await store.send(.view(.fetch)) { $0.isLoading = true + $0.isTodoLoading = true } await store.receive(.store(.loaded(goal: goal, items: items))) { $0.goal = goal @@ -49,6 +51,10 @@ struct GoalDetailFeatureTests { $0.isLoading = false $0.hasLoaded = true } + await store.receive(.store(.loadedTodos([]))) { + $0.isTodoLoading = false + $0.hasLoadedTodos = true + } } @Test("타임라인 새로고침은 변경된 현재 버전을 반영한다") @@ -89,15 +95,21 @@ struct GoalDetailFeatureTests { $0.developmentFetchRecordVersionUseCase = FetchDevelopmentRecordVersionUseCaseStub( resultByRecordId: [currentRecord.id: .success(currentVersion)] ) + $0.developmentFetchTodosUseCase = FetchTodosUseCaseStub() } await store.send(.view(.refresh)) { $0.isLoading = true + $0.isTodoLoading = true } await store.receive(.store(.loaded(goal: goal, items: [currentItem]))) { $0.items = [currentItem] $0.isLoading = false } + await store.receive(.store(.loadedTodos([]))) { + $0.isTodoLoading = false + $0.hasLoadedTodos = true + } } @Test("타임라인 조회 실패는 입력 가능한 재조회 상태를 유지한다") @@ -115,24 +127,34 @@ struct GoalDetailFeatureTests { $0.developmentFetchRecordVersionUseCase = FetchDevelopmentRecordVersionUseCaseStub( resultByRecordId: [:] ) + $0.developmentFetchTodosUseCase = FetchTodosUseCaseStub() } await store.send(.view(.fetch)) { $0.isLoading = true + $0.isTodoLoading = true } await store.receive(.store(.failed)) { $0.isLoading = false $0.hasLoadFailure = true $0.alert = makeRecordErrorAlert("development_record_timeline_error_message") } + await store.receive(.store(.loadedTodos([]))) { + $0.isTodoLoading = false + $0.hasLoadedTodos = true + } await store.send(.view(.refresh)) { $0.isLoading = true + $0.isTodoLoading = true $0.hasLoadFailure = false } await store.receive(.store(.failed)) { $0.isLoading = false $0.hasLoadFailure = true } + await store.receive(.store(.loadedTodos([]))) { + $0.isTodoLoading = false + } } @Test("상태 전환 성공은 후속 재조회 없이 요청 상태를 반영한다") diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailTodoFeatureTests.swift b/Application/Presentation/Development/Tests/Record/GoalDetailTodoFeatureTests.swift new file mode 100644 index 00000000..79ee25a6 --- /dev/null +++ b/Application/Presentation/Development/Tests/Record/GoalDetailTodoFeatureTests.swift @@ -0,0 +1,105 @@ +// +// GoalDetailTodoFeatureTests.swift +// DevelopmentTests +// +// Created by opfic on 9/15/26. +// + +import Testing +import Core +import Domain +import PresentationShared +@testable import Development + +@MainActor +struct GoalDetailTodoFeatureTests { + @Test("Todo 조회는 전체 항목을 최신 수정순으로 불러오고 현재 목표 연결을 분류한다") + func Todo_조회는_전체_항목을_최신_수정순으로_불러오고_현재_목표_연결을_분류한다() async { + let linked = makeTodo(id: "linked", number: 1, title: "연결됨", goalId: "goal") + let unlinked = makeTodo(id: "unlinked", number: 2, title: "연결 안 됨") + let spy = FetchTodosUseCaseSpy( + page: TodoPage(items: [linked, unlinked], nextCursor: nil) + ) + let store = TestStore( + initialState: GoalDetailFeature.State(goalId: "goal") + ) { + GoalDetailFeature() + } withDependencies: { + $0.developmentFetchTodosUseCase = spy + } + + await store.send(.view(.retryTodos)) { + $0.isTodoLoading = true + } + await store.receive(.store(.loadedTodos([linked, unlinked]))) { + $0.todos = [linked, unlinked] + $0.linkedTodos = [linked] + $0.isTodoLoading = false + $0.hasLoadedTodos = true + } + + let queries = await spy.queries() + #expect(queries.count == 1) + #expect(queries.first?.sortTarget == .updatedAt) + #expect(queries.first?.sortOrder == .latest) + #expect(queries.first?.pageSize == 100) + #expect(queries.first?.fetchAllPages == true) + } + + @Test("Todo 관리 시트를 표시하고 저장 결과를 상세 상태에 반영한다") + func Todo_관리_시트를_표시하고_저장_결과를_상세_상태에_반영한다() async throws { + let goal = try makeDevelopmentGoal() + let linked = makeTodo(id: "linked", number: 1, title: "기존", goalId: goal.id) + let unlinked = makeTodo(id: "unlinked", number: 2, title: "신규") + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.todos = [linked, unlinked] + state.linkedTodos = [linked] + state.hasLoadedTodos = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } + + await store.send(.view(.manageTodos)) { + $0.todoLinkSheet = GoalTodoLinkFeature.State( + goalId: goal.id, + todos: [linked, unlinked] + ) + } + + var newlyUnlinked = linked + newlyUnlinked.goalId = nil + var newlyLinked = unlinked + newlyLinked.goalId = goal.id + await store.send( + .todoLinkSheet(.presented(.delegate(.saved([newlyUnlinked, newlyLinked])))) + ) { + $0.todos = [newlyUnlinked, newlyLinked] + $0.linkedTodos = [newlyLinked] + $0.todoLinkSheet = nil + } + } + + @Test("Todo 조회 실패는 상세 화면과 분리된 재시도 상태를 만든다") + func Todo_조회_실패는_상세_화면과_분리된_재시도_상태를_만든다() async { + var state = GoalDetailFeature.State(goalId: "goal") + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentFetchTodosUseCase = FetchTodosUseCaseStub( + result: .failure(RecordTestError.failed) + ) + } + + await store.send(.view(.retryTodos)) { + $0.isTodoLoading = true + } + await store.receive(.store(.todosFailed)) { + $0.isTodoLoading = false + $0.hasTodoLoadFailure = true + } + + #expect(store.state.hasLoaded) + } +} diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailTodoTestSupport.swift b/Application/Presentation/Development/Tests/Record/GoalDetailTodoTestSupport.swift new file mode 100644 index 00000000..fc46c5f7 --- /dev/null +++ b/Application/Presentation/Development/Tests/Record/GoalDetailTodoTestSupport.swift @@ -0,0 +1,103 @@ +// +// GoalDetailTodoTestSupport.swift +// DevelopmentTests +// +// Created by opfic on 9/15/26. +// + +import Core +import Domain +import Foundation + +struct FetchTodosUseCaseStub: FetchTodosUseCase { + let result: Result + + init(result: Result = .success(TodoPage(items: [], nextCursor: nil))) { + self.result = result + } + + func execute(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { + try result.get() + } +} + +final class FetchTodosUseCaseSpy: FetchTodosUseCase { + private let page: TodoPage + private let recorder = FetchTodosUseCaseCallRecorder() + + init(page: TodoPage) { + self.page = page + } + + func execute(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { + await recorder.append(query) + return page + } + + func queries() async -> [TodoQuery] { + await recorder.queries() + } +} + +private actor FetchTodosUseCaseCallRecorder { + private var recordedQueries = [TodoQuery]() + + func append(_ query: TodoQuery) { + recordedQueries.append(query) + } + + func queries() -> [TodoQuery] { + recordedQueries + } +} + +actor UpdateTodoGoalUseCaseSpy: UpdateTodoGoalUseCase { + struct Request: Equatable { + let todoId: String + let goalId: String? + } + + private var results: [Result] + private var recordedRequests = [Request]() + + init(results: [Result] = [.success(())]) { + self.results = results + } + + func execute(todoId: String, goalId: String?) async throws { + recordedRequests.append(.init(todoId: todoId, goalId: goalId)) + guard !results.isEmpty else { return } + try results.removeFirst().get() + } + + func requests() -> [Request] { + recordedRequests + } +} + +func makeTodo( + id: String, + number: Int, + title: String, + goalId: String? = nil, + category: TodoCategory = .system(.feature) +) -> Todo { + let date = Date(timeIntervalSince1970: 1_700_000_000 + Double(number)) + return Todo( + id: id, + isPinned: false, + isCompleted: false, + isChecked: false, + number: number, + title: title, + content: "", + createdAt: date, + updatedAt: date, + completedAt: nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: category, + goalId: goalId + ) +} diff --git a/Application/Presentation/Development/Tests/Record/GoalTodoLinkFeatureTests.swift b/Application/Presentation/Development/Tests/Record/GoalTodoLinkFeatureTests.swift new file mode 100644 index 00000000..82f43908 --- /dev/null +++ b/Application/Presentation/Development/Tests/Record/GoalTodoLinkFeatureTests.swift @@ -0,0 +1,121 @@ +// +// GoalTodoLinkFeatureTests.swift +// DevelopmentTests +// +// Created by opfic on 9/15/26. +// + +import Testing +import Core +import Domain +import PresentationShared +@testable import Development + +@MainActor +struct GoalTodoLinkFeatureTests { + @Test("검색 결과를 카테고리별 섹션으로 구성한다") + func 검색_결과를_카테고리별_섹션으로_구성한다() { + let feature = makeTodo( + id: "feature", + number: 1, + title: "기능 구현", + category: .system(.feature) + ) + let document = makeTodo( + id: "document", + number: 2, + title: "기능 문서화", + category: .system(.doc) + ) + let improvement = makeTodo( + id: "improvement", + number: 3, + title: "성능 개선", + category: .system(.improvement) + ) + var state = GoalTodoLinkFeature.State( + goalId: "goal", + todos: [feature, document, improvement] + ) + + state.searchText = "기능" + + #expect(state.filteredTodos == [feature, document]) + #expect(state.sections.map(\.todos) == [[feature], [document]]) + } + + @Test("선택을 완료하면 기존 연결을 해제하고 새 Todo를 연결한다") + func 선택을_완료하면_기존_연결을_해제하고_새_Todo를_연결한다() async { + let linked = makeTodo(id: "linked", number: 1, title: "기존", goalId: "goal") + let unlinked = makeTodo(id: "unlinked", number: 2, title: "신규") + let spy = UpdateTodoGoalUseCaseSpy(results: [.success(()), .success(())]) + let store = TestStore( + initialState: GoalTodoLinkFeature.State( + goalId: "goal", + todos: [linked, unlinked] + ) + ) { + GoalTodoLinkFeature() + } withDependencies: { + $0.developmentUpdateTodoGoalUseCase = spy + } + + await store.send(.view(.toggleTodo(linked.id))) { + $0.selectedTodoIDs = [] + } + await store.send(.view(.toggleTodo(unlinked.id))) { + $0.selectedTodoIDs = [unlinked.id] + } + await store.send(.view(.save)) { + $0.isUpdating = true + } + + var newlyUnlinked = linked + newlyUnlinked.goalId = nil + var newlyLinked = unlinked + newlyLinked.goalId = "goal" + await store.receive(.store(.linksUpdated([unlinked.id]))) { + $0.todos = [newlyUnlinked, newlyLinked] + $0.isUpdating = false + } + await store.receive(.delegate(.saved([newlyUnlinked, newlyLinked]))) + + #expect(await spy.requests() == [ + .init(todoId: linked.id, goalId: nil), + .init(todoId: unlinked.id, goalId: "goal") + ]) + } + + @Test("Todo 연결 변경이 실패하면 실제 연결 상태를 다시 불러온다") + func Todo_연결_변경이_실패하면_실제_연결_상태를_다시_불러온다() async { + let todo = makeTodo(id: "todo", number: 1, title: "연결 대상") + let updateSpy = UpdateTodoGoalUseCaseSpy(results: [.failure(RecordTestError.failed)]) + var state = GoalTodoLinkFeature.State(goalId: "goal", todos: [todo]) + state.selectedTodoIDs = [todo.id] + let store = TestStore(initialState: state) { + GoalTodoLinkFeature() + } withDependencies: { + $0.developmentUpdateTodoGoalUseCase = updateSpy + $0.developmentFetchTodosUseCase = FetchTodosUseCaseStub( + result: .success(TodoPage(items: [todo], nextCursor: nil)) + ) + } + + await store.send(.view(.save)) { + $0.isUpdating = true + } + await store.receive(.store(.linkUpdateFailed)) { + $0.isUpdating = false + $0.isLoading = true + $0.alert = makeRecordErrorAlert("development_goal_todo_update_error_message") + } + await store.receive(.store(.loadedTodos([todo]))) { + $0.selectedTodoIDs = [] + $0.isLoading = false + } + + #expect(await updateSpy.requests() == [ + .init(todoId: todo.id, goalId: "goal") + ]) + } +} diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 0a7f736c..1c941734 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -424,6 +424,139 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 목표" } } } }, + "development_goal_linked_todos" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Linked Todos" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "연결된 Todo" } } + } + }, + "development_goal_todo_available_empty_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Create a Todo first, then return here to link it." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo를 만든 뒤 이곳에서 목표와 연결할 수 있어요." } } + } + }, + "development_goal_todo_available_empty_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "There are no Todos to link" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "연결할 Todo가 없어요" } } + } + }, + "development_goal_todo_clear_selection" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Clear Selection" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "선택 해제" } } + } + }, + "development_goal_todo_completed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Completed" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "완료됨" } } + } + }, + "development_goal_todo_count_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld개" } } + } + }, + "development_goal_todo_done" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Done" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "완료" } } + } + }, + "development_goal_todo_empty_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Use Manage to link supporting work when needed." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "필요할 때 관리에서 관련 Todo를 연결할 수 있어요." } } + } + }, + "development_goal_todo_empty_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No linked Todos" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "연결된 Todo가 없어요" } } + } + }, + "development_goal_todo_incomplete" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Incomplete" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중" } } + } + }, + "development_goal_todo_link_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Link Todos" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo 연결" } } + } + }, + "development_goal_todo_load_error_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Todos could not be loaded. Please try again." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo를 불러오지 못했어요. 다시 시도해주세요." } } + } + }, + "development_goal_todo_manage" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Manage" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "관리" } } + } + }, + "development_goal_todo_optional_footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "You can write goals and records without linking Todos." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo를 연결하지 않아도 목표와 기록을 작성할 수 있어요." } } + } + }, + "development_goal_todo_pinned" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Pinned" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "중요 Todo" } } + } + }, + "development_goal_todo_retry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Try Again" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "다시 시도" } } + } + }, + "development_goal_todo_search" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Search Todos" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo 검색" } } + } + }, + "development_goal_todo_selected_count_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld selected" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld개 선택됨" } } + } + }, + "development_goal_todo_update_error_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Some Todo links could not be updated. The current links were loaded again." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "일부 Todo 연결을 변경하지 못해 현재 연결 상태를 다시 불러왔어요." } } + } + }, "development_goal_archive" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Presentation/PresentationShared/Sources/Extension/View+Menu.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+Menu.swift index 9d3d818e..c0f5e601 100644 --- a/Application/Presentation/PresentationShared/Sources/Extension/View+Menu.swift +++ b/Application/Presentation/PresentationShared/Sources/Extension/View+Menu.swift @@ -81,7 +81,7 @@ private struct ProminentMenu: View { ) { customMenu .presentationCompactAdaptation(.popover) - .presentationBackground(.clear) + .presentationBackground(Color.surface) } } } From 64cb5a545c7dc45041accb569be9d2954985c7e1 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 19 Sep 2026 22:31:09 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20=EA=B0=81=20=EB=82=B4=EB=B9=84?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=EC=85=98=EC=9D=84=20Store=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Record/GoalDetail/GoalDetailFeature.swift | 65 +++++++++++++++++++ .../Record/GoalDetail/GoalDetailView.swift | 48 ++++++-------- .../GoalDetail/GoalDetailViewSupport.swift | 14 ---- .../Record/GoalDetailDestinationTests.swift | 59 +++++++++++++++++ .../Record/GoalDetailTodoFeatureTests.swift | 14 ++++ 5 files changed, 157 insertions(+), 43 deletions(-) create mode 100644 Application/Presentation/Development/Tests/Record/GoalDetailDestinationTests.swift diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift index b9e6d812..c1915dbd 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift @@ -27,6 +27,9 @@ struct GoalDetailFeature { @ObservableState struct State: Equatable { @Presents var alert: AlertState? + @Presents var recordEditor: RecordEditorDestination? + @Presents var recordDetail: RecordDetailDestination? + @Presents var todoDetail: TodoDetailDestination? @Presents var todoLinkSheet: GoalTodoLinkFeature.State? let goalId: String var goal: DevelopmentGoal? @@ -65,6 +68,9 @@ struct GoalDetailFeature { enum Action: BindableAction, Equatable { case alert(PresentationAction) + case recordEditor(PresentationAction) + case recordDetail(PresentationAction) + case todoDetail(PresentationAction) case todoLinkSheet(PresentationAction) case binding(BindingAction) case view(ViewAction) @@ -75,10 +81,14 @@ struct GoalDetailFeature { } enum ViewAction: Equatable { + case addRecord + case continueRecord case fetch case manageTodos case refresh case retryTodos + case selectRecord(RecordTimelineItem) + case selectTodo(String) case selectStatus(DevelopmentGoal.Status) } @@ -97,6 +107,7 @@ struct GoalDetailFeature { @Dependency(\.developmentFetchRecordVersionUseCase) private var fetchRecordVersionUseCase @Dependency(\.developmentFetchTodosUseCase) private var fetchTodosUseCase @Dependency(\.developmentUpdateGoalStatusUseCase) private var updateGoalStatusUseCase + @Dependency(\.uuid) private var uuid var body: some ReducerOf { BindingReducer() @@ -109,6 +120,18 @@ struct GoalDetailFeature { return transitionEffect(goalId: state.goalId, status: status) case .alert: break + case .recordEditor(.dismiss): + state.recordEditor = nil + case .recordEditor: + break + case .recordDetail(.dismiss): + state.recordDetail = nil + case .recordDetail: + break + case .todoDetail(.dismiss): + state.todoDetail = nil + case .todoDetail: + break case .todoLinkSheet(.dismiss), .todoLinkSheet(.presented(.delegate(.close))): state.todoLinkSheet = nil @@ -120,6 +143,21 @@ struct GoalDetailFeature { break case .binding: break + case .view(.addRecord): + guard state.hasLoaded, + state.allowsRecordMutation, + !state.isLoading, + !state.isTransitioning, + state.recordEditor == nil else { break } + state.recordEditor = RecordEditorDestination(id: uuid(), record: nil) + case .view(.continueRecord): + guard state.hasLoaded, + state.allowsRecordMutation, + !state.isLoading, + !state.isTransitioning, + state.recordEditor == nil, + let record = state.items.first(where: \.hasDraft)?.record else { break } + state.recordEditor = RecordEditorDestination(id: uuid(), record: record) case .view(.fetch): guard !state.hasLoaded, !state.isLoading else { break } state.isLoading = true @@ -154,6 +192,18 @@ struct GoalDetailFeature { state.isTodoLoading = true state.hasTodoLoadFailure = false return fetchTodosEffect() + case .view(.selectRecord(let item)): + guard state.recordEditor == nil, + state.recordDetail == nil else { break } + if item.hasDraft, state.allowsRecordMutation { + state.recordEditor = RecordEditorDestination(id: uuid(), record: item.record) + } else { + state.recordDetail = RecordDetailDestination(record: item.record) + } + case .view(.selectTodo(let todoId)): + guard state.linkedTodos.contains(where: { $0.id == todoId }), + state.todoDetail == nil else { break } + state.todoDetail = TodoDetailDestination(todoId: todoId) case .view(.selectStatus(let status)): guard let goalStatus = state.goalStatus, !state.isLoading, @@ -282,3 +332,18 @@ extension GoalDetailFeature { } } + +struct RecordEditorDestination: Equatable, Identifiable { + let id: UUID + let record: DevelopmentRecord? +} + +struct RecordDetailDestination: Equatable, Identifiable { + var id: String { record.id } + let record: DevelopmentRecord +} + +struct TodoDetailDestination: Equatable, Identifiable { + var id: String { todoId } + let todoId: String +} diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift index 4c63c15f..9e5ceac4 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -12,9 +12,6 @@ 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? - @State private var todoDestination: TodoDestination? public init(goalId: String) { self._store = State(initialValue: Store( @@ -36,7 +33,7 @@ public struct GoalDetailView: View { allowsManagement: store.hasLoaded && store.allowsTodoLinkMutation, onManage: { store.send(.view(.manageTodos)) }, onRetry: { store.send(.view(.retryTodos)) }, - onSelect: { todoDestination = TodoDestination(id: $0) } + onSelect: { store.send(.view(.selectTodo($0))) } ) } header: { titleBar @@ -51,10 +48,10 @@ public struct GoalDetailView: View { hasDraft: draftRecord != nil, isDisabled: store.isLoading || store.isTransitioning, onAddRecord: { - editorDestination = EditorDestination(record: nil) + store.send(.view(.addRecord)) }, onContinueRecord: { - editorDestination = EditorDestination(record: draftRecord) + store.send(.view(.continueRecord)) } ) } @@ -63,13 +60,12 @@ public struct GoalDetailView: View { .toolbarVisibility(.hidden, for: .navigationBar) .onAppear { store.send(.view(.fetch)) } .prominentAlert(store, state: \.alert, action: \.alert) - .sheet(item: $editorDestination) { destination in + .sheet(item: $store.scope(state: \.recordEditor, action: \.recordEditor)) { destination in RecordEditorView( goalId: store.goalId, goalTitle: store.goalTitle, - record: destination.record, + record: destination.state.record, onCompletion: { - editorDestination = nil store.send(.view(.refresh)) } ) @@ -77,23 +73,25 @@ public struct GoalDetailView: View { .sheet(item: $store.scope(state: \.todoLinkSheet, action: \.todoLinkSheet)) { GoalTodoLinkSheet(store: $0) } - .navigationDestination(item: $detailDestination) { destination in + .navigationDestination(item: $store.scope(state: \.recordDetail, action: \.recordDetail)) { destination in RecordDetailView( goalTitle: store.goalTitle, - record: destination.record, + record: destination.state.record, allowsMutation: store.allowsRecordMutation, onUpdate: { store.send(.view(.refresh)) } ) } - .navigationDestination(item: $todoDestination) { destination in - TodoDetailView(store: Store( - initialState: TodoDetailFeature.State( - todoId: destination.id, - showEditButton: false - ) - ) { - TodoDetailFeature() - }) + .navigationDestination(item: $store.scope(state: \.todoDetail, action: \.todoDetail)) { + TodoDetailView( + store: Store( + initialState: TodoDetailFeature.State( + todoId: $0.state.todoId, + showEditButton: false + ) + ) { + TodoDetailFeature() + } + ) } .overlay { if store.isTransitioning { @@ -192,7 +190,7 @@ public struct GoalDetailView: View { item: item, isFirst: index == 0, isLast: index == store.items.count - 1, - onSelect: { select(item) } + onSelect: { store.send(.view(.selectRecord(item))) } ) } } @@ -209,14 +207,6 @@ public struct GoalDetailView: View { store.items.first(where: \.hasDraft)?.record } - private func select(_ item: RecordTimelineItem) { - 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: diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift index 38de98c9..f3ee0a37 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift @@ -140,17 +140,3 @@ struct GoalStatusBadge: View { } } } - -struct EditorDestination: Identifiable { - let id = UUID() - let record: DevelopmentRecord? -} - -struct DetailDestination: Identifiable { - let record: DevelopmentRecord - var id: String { record.id } -} - -struct TodoDestination: Identifiable { - let id: String -} diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailDestinationTests.swift b/Application/Presentation/Development/Tests/Record/GoalDetailDestinationTests.swift new file mode 100644 index 00000000..0542e85c --- /dev/null +++ b/Application/Presentation/Development/Tests/Record/GoalDetailDestinationTests.swift @@ -0,0 +1,59 @@ +// +// GoalDetailDestinationTests.swift +// DevelopmentTests +// +// Created by opfic on 9/19/26. +// + +import Domain +import Foundation +import PresentationShared +import Testing +@testable import Development + +@MainActor +struct GoalDetailDestinationTests { + @Test("새 기록과 초안은 편집 목적지를 표시한다") + func 새_기록과_초안은_편집_목적지를_표시한다() async throws { + let goal = try makeDevelopmentGoal() + let draft = try makeDevelopmentRecord(id: "draft") + let item = RecordTimelineItem(record: draft, currentVersion: nil) + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.items = [item] + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.uuid = .incrementing + } + + await store.send(.view(.addRecord)) { + $0.recordEditor = RecordEditorDestination(id: UUID(0), record: nil) + } + await store.send(.recordEditor(.dismiss)) { + $0.recordEditor = nil + } + await store.send(.view(.continueRecord)) { + $0.recordEditor = RecordEditorDestination(id: UUID(1), record: draft) + } + } + + @Test("확정 기록 선택은 상세 목적지를 표시한다") + func 확정_기록_선택은_상세_목적지를_표시한다() async throws { + let goal = try makeDevelopmentGoal() + let record = try makeConfirmedDevelopmentRecord(id: "record") + let version = try makeDevelopmentRecordVersion(recordId: record.id) + let item = RecordTimelineItem(record: record, currentVersion: version) + var state = GoalDetailFeature.State(goalId: goal.id) + state.goal = goal + state.items = [item] + let store = TestStore(initialState: state) { + GoalDetailFeature() + } + + await store.send(.view(.selectRecord(item))) { + $0.recordDetail = RecordDetailDestination(record: record) + } + } +} diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailTodoFeatureTests.swift b/Application/Presentation/Development/Tests/Record/GoalDetailTodoFeatureTests.swift index 79ee25a6..3e5d9bd6 100644 --- a/Application/Presentation/Development/Tests/Record/GoalDetailTodoFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/GoalDetailTodoFeatureTests.swift @@ -80,6 +80,20 @@ struct GoalDetailTodoFeatureTests { } } + @Test("연결된 Todo 선택은 상세 목적지를 표시한다") + func 연결된_Todo_선택은_상세_목적지를_표시한다() async { + let todo = makeTodo(id: "linked", number: 1, title: "기존", goalId: "goal") + var state = GoalDetailFeature.State(goalId: "goal") + state.linkedTodos = [todo] + let store = TestStore(initialState: state) { + GoalDetailFeature() + } + + await store.send(.view(.selectTodo(todo.id))) { + $0.todoDetail = TodoDetailDestination(todoId: todo.id) + } + } + @Test("Todo 조회 실패는 상세 화면과 분리된 재시도 상태를 만든다") func Todo_조회_실패는_상세_화면과_분리된_재시도_상태를_만든다() async { var state = GoalDetailFeature.State(goalId: "goal") From 16a858438c376b75436607616d1a8a5cb8c881d8 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 19 Sep 2026 23:25:44 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20GoalDetailView=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EC=B6=94=EB=A1=A0=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 | 3 + .../Record/GoalDetail/GoalDetailView.swift | 113 ++++++++++-------- 2 files changed, 65 insertions(+), 51 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift index c1915dbd..a9e465f4 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift @@ -333,16 +333,19 @@ extension GoalDetailFeature { } +@ObservableState struct RecordEditorDestination: Equatable, Identifiable { let id: UUID let record: DevelopmentRecord? } +@ObservableState struct RecordDetailDestination: Equatable, Identifiable { var id: String { record.id } let record: DevelopmentRecord } +@ObservableState struct TodoDetailDestination: Equatable, Identifiable { var id: String { todoId } let todoId: String diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift index 9e5ceac4..421e62d6 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -22,6 +22,15 @@ public struct GoalDetailView: View { } public var body: some View { + presentedContent + .overlay { + if store.isTransitioning { + LoadingView() + } + } + } + + private var mainContent: some View { ScrollView { LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) { Section { @@ -42,62 +51,48 @@ public struct GoalDetailView: View { } } .safeAreaInset(edge: .top, spacing: 0) { topBar } - .safeAreaInset(edge: .bottom, spacing: 0) { - if store.hasLoaded, store.allowsRecordMutation { - GoalDetailRecordActionBar( - hasDraft: draftRecord != nil, - isDisabled: store.isLoading || store.isTransitioning, - onAddRecord: { - store.send(.view(.addRecord)) - }, - onContinueRecord: { - store.send(.view(.continueRecord)) - } - ) - } - } + .safeAreaInset(edge: .bottom, spacing: 0) { recordActionBar } .background(Color.appBackground.ignoresSafeArea()) .toolbarVisibility(.hidden, for: .navigationBar) .onAppear { store.send(.view(.fetch)) } .prominentAlert(store, state: \.alert, action: \.alert) - .sheet(item: $store.scope(state: \.recordEditor, action: \.recordEditor)) { destination in - RecordEditorView( - goalId: store.goalId, - goalTitle: store.goalTitle, - record: destination.state.record, - onCompletion: { - store.send(.view(.refresh)) - } - ) - } - .sheet(item: $store.scope(state: \.todoLinkSheet, action: \.todoLinkSheet)) { - GoalTodoLinkSheet(store: $0) - } - .navigationDestination(item: $store.scope(state: \.recordDetail, action: \.recordDetail)) { destination in - RecordDetailView( - goalTitle: store.goalTitle, - record: destination.state.record, - allowsMutation: store.allowsRecordMutation, - onUpdate: { store.send(.view(.refresh)) } - ) - } - .navigationDestination(item: $store.scope(state: \.todoDetail, action: \.todoDetail)) { - TodoDetailView( - store: Store( - initialState: TodoDetailFeature.State( - todoId: $0.state.todoId, - showEditButton: false - ) - ) { - TodoDetailFeature() - } - ) - } - .overlay { - if store.isTransitioning { - LoadingView() + } + + private var presentedContent: some View { + mainContent + .sheet(item: $store.scope(state: \.recordEditor, action: \.recordEditor)) { destination in + RecordEditorView( + goalId: store.goalId, + goalTitle: store.goalTitle, + record: destination.record, + onCompletion: { + store.send(.view(.refresh)) + } + ) + } + .sheet(item: $store.scope(state: \.todoLinkSheet, action: \.todoLinkSheet)) { + GoalTodoLinkSheet(store: $0) + } + .navigationDestination(item: $store.scope(state: \.recordDetail, action: \.recordDetail)) { destination in + RecordDetailView( + goalTitle: store.goalTitle, + record: destination.record, + allowsMutation: store.allowsRecordMutation, + onUpdate: { store.send(.view(.refresh)) } + ) + } + .navigationDestination(item: $store.scope(state: \.todoDetail, action: \.todoDetail)) { + TodoDetailView( + store: Store( + initialState: TodoDetailFeature.State( + todoId: $0.todoId, + showEditButton: false + ) + ) { + TodoDetailFeature() + } + ) } - } } private var topBar: some View { @@ -207,6 +202,22 @@ public struct GoalDetailView: View { store.items.first(where: \.hasDraft)?.record } + @ViewBuilder + private var recordActionBar: some View { + if store.hasLoaded, store.allowsRecordMutation { + GoalDetailRecordActionBar( + hasDraft: draftRecord != nil, + isDisabled: store.isLoading || store.isTransitioning, + onAddRecord: { + store.send(.view(.addRecord)) + }, + onContinueRecord: { + store.send(.view(.continueRecord)) + } + ) + } + } + private func statusMenuItems(_ status: DevelopmentGoal.Status) -> [ProminentMenuItem] { switch status { case .inProgress: From a9f37be00da9d436b860e11218fb60d83af2c110 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sat, 19 Sep 2026 23:57:51 +0900 Subject: [PATCH 9/9] =?UTF-8?q?ui:=20chevron.right=EC=9D=B4=20=EC=A4=91?= =?UTF-8?q?=EC=95=99=20=EC=A0=95=EB=A0=AC=20(=EB=86=92=EC=9D=B4)=20?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GoalDetail/GoalDetailViewSupport.swift | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift index f3ee0a37..796e40e1 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift @@ -19,31 +19,32 @@ struct TimelineRow: View { Button { onSelect() } label: { - HStack(alignment: .top, spacing: 16) { - timelineIndicator - VStack(alignment: .leading, spacing: 5) { - Text(item.title) - .font(.body.weight(.semibold)) - .foregroundStyle(Color.primary) - .lineLimit(2) - status - if item.hasDraft { - RelativeTimeText( - date: item.date, - bodyFont: .caption, - bodyColor: .textTertiary - ) - } else { - Text(item.date, format: .dateTime.month().day()) - .font(.caption) - .foregroundStyle(Color.textTertiary) + HStack(spacing: 8) { + HStack(alignment: .top, spacing: 16) { + timelineIndicator + VStack(alignment: .leading, spacing: 5) { + Text(item.title) + .font(.body.weight(.semibold)) + .foregroundStyle(Color.primary) + .lineLimit(2) + status + if item.hasDraft { + RelativeTimeText( + date: item.date, + bodyFont: .caption, + bodyColor: .textTertiary + ) + } else { + Text(item.date, format: .dateTime.month().day()) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } } } Spacer(minLength: 8) Image(systemName: "chevron.right") .font(.callout.weight(.semibold)) .foregroundStyle(Color.border) - .padding(.top, 4) } .contentShape(.rect) .frame(minHeight: RecordTimelineLayout.rowHeight, alignment: .top)