From 74228e2cca15656a62b79069192c9f0fc5162935 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 20 Sep 2026 01:25:59 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20RecordEditorView=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=B9=B4=EB=93=9C=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Record/Editor/RecordEditorView.swift | 76 ++++++++++--------- 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift index d678adf3..1bf459ff 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift @@ -136,7 +136,33 @@ public struct RecordEditorView: View { VStack(spacing: 16) { ModePicker(store: store, focusedField: _focusedField) - editorContent + switch store.selectedTab { + case .write: + TextEditor(text: $store.markdownContent) + .focused($focusedField, equals: .content) + .font(.body) + .scrollContentBackground(.hidden) + .padding(12) + .frame(minHeight: 340, alignment: .topLeading) + .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) + case .preview: + Group { + if store.markdownContent.isEmpty { + ContentUnavailableView( + RecordPresentation.text("development_record_preview_empty_title"), + systemImage: "doc.text.magnifyingglass", + description: Text( + RecordPresentation.text("development_record_preview_empty_message") + ) + ) + } else { + MarkdownContentView(content: store.markdownContent) + .padding(.vertical, 16) + } + } + .frame(minHeight: 340) + .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) + } Text(RecordPresentation.text("development_record_markdown_hint")) .font(.caption) @@ -151,37 +177,6 @@ public struct RecordEditorView: View { .disabled(store.isLoading) } - @ViewBuilder - private var editorContent: some View { - switch store.selectedTab { - case .write: - TextEditor(text: $store.markdownContent) - .focused($focusedField, equals: .content) - .font(.body) - .scrollContentBackground(.hidden) - .padding(12) - .frame(minHeight: 340, alignment: .topLeading) - .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) - case .preview: - Group { - if store.markdownContent.isEmpty { - ContentUnavailableView( - RecordPresentation.text("development_record_preview_empty_title"), - systemImage: "doc.text.magnifyingglass", - description: Text( - RecordPresentation.text("development_record_preview_empty_message") - ) - ) - } else { - MarkdownContentView(content: store.markdownContent) - .padding(.vertical, 16) - } - } - .frame(minHeight: 340) - .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) - } - } - private var confirmBar: some View { VStack(spacing: 8) { Button { @@ -236,11 +231,11 @@ private struct FieldCard: View { Text(title) .font(.headline) content - } - .padding(20) - .background { - RoundedRectangle(cornerRadius: 24) - .fill(Color.surface) + .padding(20) + .background { + RoundedRectangle(cornerRadius: 24) + .fill(Color.surface) + } } } } @@ -302,3 +297,10 @@ private enum Field: Hashable { case title case content } + +#Preview("새 개발 기록") { + RecordEditorView( + goalId: "preview-goal", + goalTitle: "개발 기록의 버전 이력 완성" + ) +} From b53e3ddde73f263c9a95afd94be8543a9f82b949 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 20 Sep 2026 09:44:46 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EC=83=9D=EC=84=B1=20=ED=9D=90=EB=A6=84=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppGraph+PresentationDependencies.swift | 3 + .../DevelopmentDependencyPreparation.swift | 13 ++ .../Goal/Create/GoalCreateFeature.swift | 190 ++++++++++++++++++ .../GoalCreateTodoSelectionFeature.swift | 124 ++++++++++++ .../GoalTodoSelectionViews.swift | 106 ++++++++++ .../GoalDetail/GoalTodoLinkFeature.swift | 22 +- .../Goal/Create/GoalCreateFeatureTests.swift | 137 +++++++++++++ .../Record/RecordFeatureTestSupport.swift | 23 +++ 8 files changed, 598 insertions(+), 20 deletions(-) create mode 100644 Application/Presentation/Development/Sources/Goal/Create/GoalCreateFeature.swift create mode 100644 Application/Presentation/Development/Sources/Goal/Create/GoalCreateTodoSelectionFeature.swift create mode 100644 Application/Presentation/Development/Sources/Goal/TodoSelection/GoalTodoSelectionViews.swift create mode 100644 Application/Presentation/Development/Tests/Goal/Create/GoalCreateFeatureTests.swift diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index 91407be8..3195aa5c 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -25,6 +25,9 @@ private extension AppGraph { func prepareDevelopmentDependencies(_ dependencies: inout DependencyValues) { DevelopmentDependencyPreparation.prepareGoal( &dependencies, + createGoalUseCase: developmentGraphSet + .developmentGoalUseCaseGraph + .createDevelopmentGoalUseCase, fetchGoalUseCase: developmentGraphSet .developmentGoalUseCaseGraph .fetchDevelopmentGoalUseCase, diff --git a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift index dabde203..e58ffecf 100644 --- a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift +++ b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift @@ -11,9 +11,11 @@ import PresentationShared public enum DevelopmentDependencyPreparation { public static func prepareGoal( _ dependencies: inout DependencyValues, + createGoalUseCase: CreateDevelopmentGoalUseCase, fetchGoalUseCase: FetchDevelopmentGoalUseCase, updateGoalStatusUseCase: UpdateDevelopmentGoalStatusUseCase ) { + dependencies.developmentCreateGoalUseCase = createGoalUseCase dependencies.developmentFetchGoalUseCase = fetchGoalUseCase dependencies.developmentUpdateGoalStatusUseCase = updateGoalStatusUseCase } @@ -53,6 +55,11 @@ public enum DevelopmentDependencyPreparation { } extension DependencyValues { + var developmentCreateGoalUseCase: CreateDevelopmentGoalUseCase { + get { self[DevelopmentCreateGoalUseCaseKey.self] } + set { self[DevelopmentCreateGoalUseCaseKey.self] = newValue } + } + var developmentFetchGoalUseCase: FetchDevelopmentGoalUseCase { get { self[DevelopmentFetchGoalUseCaseKey.self] } set { self[DevelopmentFetchGoalUseCaseKey.self] = newValue } @@ -109,6 +116,12 @@ extension DependencyValues { } } +private enum DevelopmentCreateGoalUseCaseKey: DependencyKey { + static var liveValue: CreateDevelopmentGoalUseCase { + preconditionFailure("CreateDevelopmentGoalUseCase must be provided.") + } +} + private enum DevelopmentFetchGoalUseCaseKey: DependencyKey { static var liveValue: FetchDevelopmentGoalUseCase { preconditionFailure("FetchDevelopmentGoalUseCase must be provided.") diff --git a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateFeature.swift b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateFeature.swift new file mode 100644 index 00000000..dedc56c2 --- /dev/null +++ b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateFeature.swift @@ -0,0 +1,190 @@ +// +// GoalCreateFeature.swift +// Development +// +// Created by opfic on 9/20/26. +// + +import Core +import Domain +import Foundation +import PresentationShared + +@Reducer +struct GoalCreateFeature { + @ObservableState + struct State: Equatable { + @Presents var alert: AlertState? + @Presents var todoSelection: GoalCreateTodoSelectionFeature.State? + var title = "" + var markdownContent = "" + var selectedTab = EditorTab.write + var selectedTodoIDs = Set() + var pendingGoal: DevelopmentGoal? + var result: DevelopmentGoal? + var isSaving = false + + var isReadyToSave: Bool { + !isSaving && !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + enum EditorTab: Equatable { + case write + case preview + } + + enum Action: BindableAction, Equatable { + case alert(PresentationAction) + case binding(BindingAction) + case todoSelection(PresentationAction) + case view(ViewAction) + case store(StoreAction) + case delegate(Delegate) + + enum Alert: Equatable { + case completeAfterTodoLinkFailure + } + + enum ViewAction: Equatable { + case save + case selectTodos + } + + enum StoreAction: Equatable { + case created(DevelopmentGoal) + case linkedTodos + case todoLinkFailed + case failed + } + + enum Delegate: Equatable { + case saved(DevelopmentGoal) + } + } + + @Dependency(\.developmentCreateGoalUseCase) private var createGoalUseCase + @Dependency(\.developmentUpdateTodoGoalUseCase) private var updateTodoGoalUseCase + + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .alert(.presented(.completeAfterTodoLinkFailure)): + guard let goal = state.pendingGoal else { break } + state.alert = nil + state.pendingGoal = nil + state.result = goal + return .send(.delegate(.saved(goal))) + case .alert, .binding, .delegate: + break + case .todoSelection(.dismiss): + state.todoSelection = nil + case .todoSelection(.presented(.delegate(.close))): + state.todoSelection = nil + case .todoSelection(.presented(.delegate(.selected(let todoIDs)))): + state.selectedTodoIDs = todoIDs + state.todoSelection = nil + case .todoSelection: + break + case .view(.save): + guard state.isReadyToSave else { break } + state.isSaving = true + state.pendingGoal = nil + state.result = nil + return createGoalEffect(title: state.title, description: state.markdownContent) + case .view(.selectTodos): + guard !state.isSaving, state.todoSelection == nil else { break } + state.todoSelection = GoalCreateTodoSelectionFeature.State( + selectedTodoIDs: state.selectedTodoIDs + ) + case .store(.created(let goal)): + state.pendingGoal = goal + guard !state.selectedTodoIDs.isEmpty else { + state.pendingGoal = nil + state.result = goal + state.isSaving = false + return .send(.delegate(.saved(goal))) + } + return linkTodosEffect(goalId: goal.id, todoIDs: state.selectedTodoIDs) + case .store(.linkedTodos): + guard let goal = state.pendingGoal else { break } + state.pendingGoal = nil + state.result = goal + state.isSaving = false + return .send(.delegate(.saved(goal))) + case .store(.todoLinkFailed): + state.isSaving = false + state.alert = Self.todoLinkFailureAlert + case .store(.failed): + state.isSaving = false + state.alert = Self.errorAlert + } + + return .none + } + .ifLet(\.$alert, action: \.alert) + .ifLet(\.$todoSelection, action: \.todoSelection) { + GoalCreateTodoSelectionFeature() + } + } +} + +private extension GoalCreateFeature { + func createGoalEffect(title: String, description: String) -> Effect { + .run { [createGoalUseCase] send in + do { + let goal = try await createGoalUseCase.execute( + title: title, + description: description + ) + await send(.store(.created(goal))) + } catch { + await send(.store(.failed)) + } + } + } + + func linkTodosEffect(goalId: String, todoIDs: Set) -> Effect { + .run { [updateTodoGoalUseCase] send in + do { + for todoID in todoIDs { + try await updateTodoGoalUseCase.execute(todoId: todoID, goalId: goalId) + } + await send(.store(.linkedTodos)) + } catch { + await send(.store(.todoLinkFailed)) + } + } + } + + 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_goal_create_error_message", + bundle: PresentationResources.bundle + )) + } + } + + static var todoLinkFailureAlert: AlertState { + AlertState { + TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) + } actions: { + ButtonState(action: .completeAfterTodoLinkFailure) { + TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) + } + } message: { + TextState(String( + localized: "development_goal_create_todo_link_error_message", + bundle: PresentationResources.bundle + )) + } + } +} diff --git a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateTodoSelectionFeature.swift b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateTodoSelectionFeature.swift new file mode 100644 index 00000000..cec6cb95 --- /dev/null +++ b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateTodoSelectionFeature.swift @@ -0,0 +1,124 @@ +// +// GoalCreateTodoSelectionFeature.swift +// Development +// +// Created by opfic on 9/20/26. +// + +import Core +import Domain +import PresentationShared + +@Reducer +struct GoalCreateTodoSelectionFeature { + @ObservableState + struct State: Equatable { + var todos = [Todo]() + var selectedTodoIDs: Set + var searchText = "" + var isLoading = false + var hasLoadFailure = false + + init(selectedTodoIDs: Set) { + self.selectedTodoIDs = selectedTodoIDs + } + + 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: [GoalTodoSelectionSectionItem] { + goalTodoSelectionSections(from: filteredTodos) + } + } + + enum Action: BindableAction, Equatable { + case binding(BindingAction) + case view(ViewAction) + case store(StoreAction) + case delegate(Delegate) + + enum ViewAction: Equatable { + case clearSelection + case close + case fetch + case retry + case save + case toggleTodo(String) + } + + enum StoreAction: Equatable { + case loadedTodos([Todo]) + case todosFailed + } + + enum Delegate: Equatable { + case close + case selected(Set) + } + } + + @Dependency(\.developmentFetchTodosUseCase) private var fetchTodosUseCase + + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .binding, .delegate: + break + case .view(.clearSelection): + state.selectedTodoIDs.removeAll() + case .view(.close): + return .send(.delegate(.close)) + case .view(.fetch), .view(.retry): + guard !state.isLoading else { break } + state.isLoading = true + state.hasLoadFailure = false + return fetchTodosEffect() + case .view(.save): + return .send(.delegate(.selected(state.selectedTodoIDs))) + case .view(.toggleTodo(let todoID)): + guard 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)): + state.todos = todos + state.isLoading = false + state.hasLoadFailure = false + case .store(.todosFailed): + state.isLoading = false + state.hasLoadFailure = true + } + + return .none + } + } +} + +private extension GoalCreateTodoSelectionFeature { + 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)) + } + } + } +} diff --git a/Application/Presentation/Development/Sources/Goal/TodoSelection/GoalTodoSelectionViews.swift b/Application/Presentation/Development/Sources/Goal/TodoSelection/GoalTodoSelectionViews.swift new file mode 100644 index 00000000..25591b78 --- /dev/null +++ b/Application/Presentation/Development/Sources/Goal/TodoSelection/GoalTodoSelectionViews.swift @@ -0,0 +1,106 @@ +// +// GoalTodoSelectionViews.swift +// Development +// +// Created by opfic on 9/20/26. +// + +import SwiftUI +import Domain +import PresentationShared + +struct GoalTodoSelectionSectionItem: Equatable, Identifiable { + let category: TodoCategoryItem + var todos: [Todo] + + var id: String { category.id } +} + +func goalTodoSelectionSections(from todos: [Todo]) -> [GoalTodoSelectionSectionItem] { + var sectionIndexByID = [String: Int]() + var sections = [GoalTodoSelectionSectionItem]() + for todo in todos { + 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(GoalTodoSelectionSectionItem(category: category, todos: [todo])) + } + } + return sections +} + +struct GoalTodoSelectionSection: View { + let section: GoalTodoSelectionSectionItem + 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 + GoalTodoSelectionRow( + todo: todo, + isSelected: selectedTodoIDs.contains(todo.id) + ) { + onToggle(todo.id) + } + } + } + .background(Color.surface) + .compositingGroup() + .clipShape(.rect(cornerRadius: 20)) + } + } +} + +struct GoalTodoSelectionRow: View { + let todo: Todo + let isSelected: Bool + let onToggle: () -> Void + + var body: some View { + Button(action: onToggle) { + 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) + } + .buttonStyle(.plain) + } +} diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkFeature.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkFeature.swift index 67c53e4e..0954058d 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkFeature.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkFeature.swift @@ -15,13 +15,6 @@ struct TodoGoalLinkUpdate: Equatable, Sendable { let goalId: String? } -struct GoalTodoLinkSectionItem: Equatable, Identifiable { - let category: TodoCategoryItem - var todos: [Todo] - - var id: String { category.id } -} - @Reducer struct GoalTodoLinkFeature { @ObservableState @@ -51,19 +44,8 @@ struct GoalTodoLinkFeature { } } - 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 sections: [GoalTodoSelectionSectionItem] { + goalTodoSelectionSections(from: filteredTodos) } var canClearSelection: Bool { diff --git a/Application/Presentation/Development/Tests/Goal/Create/GoalCreateFeatureTests.swift b/Application/Presentation/Development/Tests/Goal/Create/GoalCreateFeatureTests.swift new file mode 100644 index 00000000..31ebd5e9 --- /dev/null +++ b/Application/Presentation/Development/Tests/Goal/Create/GoalCreateFeatureTests.swift @@ -0,0 +1,137 @@ +// +// GoalCreateFeatureTests.swift +// DevelopmentTests +// +// Created by opfic on 9/20/26. +// + +import Testing +import Domain +import PresentationShared +@testable import Development + +@MainActor +struct GoalCreateFeatureTests { + @Test("Todo를 선택하지 않으면 목표 생성 뒤 즉시 완료한다") + func Todo를_선택하지_않으면_목표_생성_뒤_즉시_완료한다() async throws { + let goal = try makeDevelopmentGoal(title: "버전 이력 완성") + let spy = CreateDevelopmentGoalUseCaseSpy(result: .success(goal)) + var state = GoalCreateFeature.State() + state.title = goal.title + state.markdownContent = "# 목표" + let store = TestStore(initialState: state) { + GoalCreateFeature() + } withDependencies: { + $0.developmentCreateGoalUseCase = spy + } + + await store.send(.view(.save)) { + $0.isSaving = true + } + await store.receive(.store(.created(goal))) { + $0.result = goal + $0.isSaving = false + } + await store.receive(.delegate(.saved(goal))) + + #expect(await spy.requests() == [ + .init(title: goal.title, description: "# 목표") + ]) + } + + @Test("선택한 Todo는 생성된 목표 ID로 연결한다") + func 선택한_Todo는_생성된_목표_ID로_연결한다() async throws { + let goal = try makeDevelopmentGoal() + let createSpy = CreateDevelopmentGoalUseCaseSpy(result: .success(goal)) + let todoSpy = UpdateTodoGoalUseCaseSpy(results: [.success(())]) + var state = GoalCreateFeature.State() + state.title = goal.title + state.selectedTodoIDs = ["todo"] + let store = TestStore(initialState: state) { + GoalCreateFeature() + } withDependencies: { + $0.developmentCreateGoalUseCase = createSpy + $0.developmentUpdateTodoGoalUseCase = todoSpy + } + + await store.send(.view(.save)) { + $0.isSaving = true + } + await store.receive(.store(.created(goal))) { + $0.pendingGoal = goal + } + await store.receive(.store(.linkedTodos)) { + $0.pendingGoal = nil + $0.result = goal + $0.isSaving = false + } + await store.receive(.delegate(.saved(goal))) + + #expect(await todoSpy.requests() == [ + .init(todoId: "todo", goalId: goal.id) + ]) + } + + @Test("Todo 연결 실패 뒤에도 생성된 목표를 완료 결과로 전달한다") + func Todo_연결_실패_뒤에도_생성된_목표를_완료_결과로_전달한다() async throws { + let goal = try makeDevelopmentGoal() + let createSpy = CreateDevelopmentGoalUseCaseSpy(result: .success(goal)) + let todoSpy = UpdateTodoGoalUseCaseSpy(results: [.failure(RecordTestError.failed)]) + var state = GoalCreateFeature.State() + state.title = goal.title + state.selectedTodoIDs = ["todo"] + let store = TestStore(initialState: state) { + GoalCreateFeature() + } withDependencies: { + $0.developmentCreateGoalUseCase = createSpy + $0.developmentUpdateTodoGoalUseCase = todoSpy + } + + await store.send(.view(.save)) { + $0.isSaving = true + } + await store.receive(.store(.created(goal))) { + $0.pendingGoal = goal + } + await store.receive(.store(.todoLinkFailed)) { + $0.isSaving = false + $0.alert = makeTodoLinkFailureAlert() + } + await store.send(.alert(.presented(.completeAfterTodoLinkFailure))) { + $0.alert = nil + $0.pendingGoal = nil + $0.result = goal + } + await store.receive(.delegate(.saved(goal))) + } + + @Test("Todo 선택 화면의 결과를 저장 상태에 반영한다") + func Todo_선택_화면의_결과를_저장_상태에_반영한다() async { + let store = TestStore(initialState: GoalCreateFeature.State()) { + GoalCreateFeature() + } + + await store.send(.view(.selectTodos)) { + $0.todoSelection = GoalCreateTodoSelectionFeature.State(selectedTodoIDs: []) + } + await store.send(.todoSelection(.presented(.delegate(.selected(["todo"])))) ) { + $0.selectedTodoIDs = ["todo"] + $0.todoSelection = nil + } + } + + private func makeTodoLinkFailureAlert() -> AlertState { + AlertState { + TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) + } actions: { + ButtonState(action: .completeAfterTodoLinkFailure) { + TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) + } + } message: { + TextState(String( + localized: "development_goal_create_todo_link_error_message", + bundle: PresentationResources.bundle + )) + } + } +} diff --git a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift index 754f2b4e..0ee9637c 100644 --- a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift +++ b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift @@ -35,6 +35,29 @@ struct FetchDevelopmentGoalUseCaseStub: FetchDevelopmentGoalUseCase { } } +actor CreateDevelopmentGoalUseCaseSpy: CreateDevelopmentGoalUseCase { + struct Request: Equatable { + let title: String + let description: String + } + + private let result: Result + private var recordedRequests = [Request]() + + init(result: Result) { + self.result = result + } + + func execute(title: String, description: String) async throws -> DevelopmentGoal { + recordedRequests.append(.init(title: title, description: description)) + return try result.get() + } + + func requests() -> [Request] { + recordedRequests + } +} + actor UpdateDevelopmentGoalStatusUseCaseSpy: UpdateDevelopmentGoalStatusUseCase { struct Request: Equatable { let goalId: String From 8954ac25621711ef96ee3f7fd059021553e01672 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 20 Sep 2026 09:45:14 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EC=83=9D=EC=84=B1=20=ED=99=94=EB=A9=B4=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Goal/Create/GoalCreateView.swift | 358 ++++++++++++++++++ .../GoalCreateTodoSelectionSheet.swift | 130 +++++++ .../Record/GoalDetail/GoalTodoLinkSheet.swift | 74 +--- .../Resources/Localizable.xcstrings | 119 ++++++ 4 files changed, 608 insertions(+), 73 deletions(-) create mode 100644 Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift create mode 100644 Application/Presentation/Development/Sources/Goal/TodoSelection/GoalCreateTodoSelectionSheet.swift diff --git a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift new file mode 100644 index 00000000..7c434e83 --- /dev/null +++ b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift @@ -0,0 +1,358 @@ +// +// GoalCreateView.swift +// Development +// +// Created by opfic on 9/20/26. +// + +import SwiftUI +import Domain +import PresentationShared + +public struct GoalCreateView: View { + @Environment(\.dismiss) private var dismiss + @State private var store: StoreOf + @FocusState private var focusedField: GoalCreateField? + @ScaledMetric(relativeTo: .title) private var iconSize = UIFont.preferredFont( + forTextStyle: .title2, + compatibleWith: UITraitCollection(preferredContentSizeCategory: .large) + ).lineHeight + private let onCompletion: (DevelopmentGoal) -> Void + + public init(onCompletion: @escaping (DevelopmentGoal) -> Void = { _ in }) { + self._store = State(initialValue: Store( + initialState: GoalCreateFeature.State() + ) { + GoalCreateFeature() + }) + self.onCompletion = onCompletion + } + + public var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading, spacing: 24) { + GoalCreateTitleField(store: store, focusedField: _focusedField) + GoalCreateDescriptionEditor(store: store, focusedField: _focusedField) + GoalCreateStatusField() + GoalCreateTodoField(store: store) + } + .padding(.horizontal) + .padding(.bottom, 20) + } + .safeAreaInset(edge: .top, spacing: 0) { + GoalCreateTopBar( + iconSize: iconSize, + isSaving: store.isSaving, + isSaveEnabled: store.isReadyToSave, + onClose: dismiss.callAsFunction, + onSave: save + ) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + GoalCreateSaveBar( + isSaving: store.isSaving, + isSaveEnabled: store.isReadyToSave, + onSave: save + ) + } + .background(Color.appBackground.ignoresSafeArea()) + .toolbarVisibility(.hidden, for: .navigationBar) + .prominentAlert(store, state: \.alert, action: \.alert) + .sheet(item: $store.scope(state: \.todoSelection, action: \.todoSelection)) { + GoalCreateTodoSelectionSheet(store: $0) + } + .onChange(of: store.result) { _, result in + guard let result else { return } + onCompletion(result) + dismiss() + } + } + .interactiveDismissDisabled(store.isSaving) + } + + private func save() { + focusedField = nil + store.send(.view(.save)) + } +} + +private struct GoalCreateTopBar: View { + let iconSize: CGFloat + let isSaving: Bool + let isSaveEnabled: Bool + let onClose: () -> Void + let onSave: () -> Void + + var body: some View { + HStack { + Button(action: onClose) { + if #available(iOS 26.0, *) { + Image(systemName: "xmark") + .frame(width: iconSize, height: iconSize) + } else { + Text(RecordPresentation.text("common_close")) + } + } + .font(.title) + .topBarButtonStyle() + .disabled(isSaving) + + Spacer() + + Text(RecordPresentation.text("development_goal_create_title")) + .font(.headline) + + Spacer() + + Button(action: onSave) { + if #available(iOS 26.0, *) { + Image(systemName: "checkmark") + .frame(width: iconSize, height: iconSize) + .foregroundStyle(Color.primary) + } else { + Text(RecordPresentation.text("development_goal_save")) + .foregroundStyle(Color.accent) + } + } + .font(.title) + .topBarButtonStyle(color: Color.surface) + .disabled(!isSaveEnabled) + } + .padding(.horizontal) + .padding(.vertical, 12) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) + } +} + +private struct GoalCreateTitleField: View { + @Bindable var store: StoreOf + @FocusState var focusedField: GoalCreateField? + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(RecordPresentation.text("development_goal_create_title_label")) + .font(.headline) + + TextField( + RecordPresentation.text("development_goal_create_title_placeholder"), + text: $store.title + ) + .focused($focusedField, equals: .title) + .font(.body) + .padding() + .background(Color.surface, in: .rect(cornerRadius: 24)) + } + .disabled(store.isSaving) + } +} + +private struct GoalCreateDescriptionEditor: View { + @Bindable var store: StoreOf + @FocusState var focusedField: GoalCreateField? + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(RecordPresentation.text("development_goal_create_description_label")) + .font(.headline) + + VStack(spacing: 16) { + GoalCreateModePicker(store: store, focusedField: _focusedField) + + switch store.selectedTab { + case .write: + TextEditor(text: $store.markdownContent) + .focused($focusedField, equals: .content) + .font(.body) + .scrollContentBackground(.hidden) + .padding(12) + .frame(minHeight: 340, alignment: .topLeading) + .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) + case .preview: + Group { + if store.markdownContent.isEmpty { + ContentUnavailableView( + RecordPresentation.text("development_goal_create_preview_empty_title"), + systemImage: "doc.text.magnifyingglass", + description: Text( + RecordPresentation.text( + "development_goal_create_preview_empty_message" + ) + ) + ) + } else { + MarkdownContentView(content: store.markdownContent) + .padding(.vertical, 16) + } + } + .frame(minHeight: 340) + .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) + } + + Text(RecordPresentation.text("development_goal_create_markdown_hint")) + .font(.caption) + .foregroundStyle(Color.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(20) + .background(Color.surface, in: .rect(cornerRadius: 24)) + } + .disabled(store.isSaving) + } +} + +private struct GoalCreateModePicker: View { + @Bindable var store: StoreOf + @FocusState var focusedField: GoalCreateField? + + var body: some View { + HStack(spacing: 0) { + GoalCreateModeButton( + title: RecordPresentation.text("development_record_write"), + isSelected: store.selectedTab == .write + ) { + store.send(.binding(.set(\.selectedTab, .write))) + focusedField = .content + } + GoalCreateModeButton( + title: RecordPresentation.text("development_record_preview"), + isSelected: store.selectedTab == .preview + ) { + focusedField = nil + store.send(.binding(.set(\.selectedTab, .preview))) + } + } + .padding(2) + .background(Color.border, in: .rect(cornerRadius: 16)) + } +} + +private struct GoalCreateModeButton: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.body) + .foregroundStyle(isSelected ? Color.accent : Color.textSecondary) + .frame(maxWidth: .infinity, minHeight: 36) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 14) + .fill(Color.surface) + .shadow(color: Color.textSecondary.opacity(0.08), radius: 2, y: 2) + } + } + .contentShape(.rect) + } + .buttonStyle(.plain) + } +} + +private struct GoalCreateStatusField: View { + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(RecordPresentation.text("development_goal_create_status")) + .font(.headline) + + HStack { + GoalStatusBadge(status: .inProgress) + Spacer() + } + .padding(20) + .background(Color.surface, in: .rect(cornerRadius: 24)) + } + } +} + +private struct GoalCreateTodoField: View { + @Bindable var store: StoreOf + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(RecordPresentation.text("development_goal_create_todo_label")) + .font(.headline) + + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(RecordPresentation.text("development_goal_create_todo_optional")) + .font(.callout) + .foregroundStyle(Color.textSecondary) + Text(RecordPresentation.text("development_goal_create_todo_hint")) + .font(.footnote) + .foregroundStyle(Color.textTertiary) + } + Spacer(minLength: 12) + Button { + store.send(.view(.selectTodos)) + } label: { + Text(todoButtonTitle) + .font(.callout.weight(.semibold)) + .foregroundStyle(Color.accent) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(Color.primaryContainer, in: .capsule) + } + .buttonStyle(.plain) + } + .padding(20) + .background(Color.surface, in: .rect(cornerRadius: 24)) + } + .disabled(store.isSaving) + } + + private var todoButtonTitle: String { + guard !store.selectedTodoIDs.isEmpty else { + return RecordPresentation.text("development_goal_create_todo_select") + } + return String.localizedStringWithFormat( + RecordPresentation.text("development_goal_create_todo_selected_format"), + store.selectedTodoIDs.count + ) + } +} + +private struct GoalCreateSaveBar: View { + let isSaving: Bool + let isSaveEnabled: Bool + let onSave: () -> Void + + var body: some View { + VStack(spacing: 8) { + Button(action: onSave) { + Group { + if isSaving { + ProgressView() + .tint(Color.white) + } else { + Text(RecordPresentation.text("development_goal_save")) + } + } + .font(.headline) + .foregroundStyle(Color.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .accent) + .disabled(!isSaveEnabled) + + Text(RecordPresentation.text("development_goal_create_save_hint")) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + .padding(.horizontal) + .padding(.vertical, 12) + .background(Color.surface, ignoresSafeAreaEdges: .bottom) + } +} + +private enum GoalCreateField: Hashable { + case title + case content +} + +#Preview("새 개발 목표") { + GoalCreateView() +} diff --git a/Application/Presentation/Development/Sources/Goal/TodoSelection/GoalCreateTodoSelectionSheet.swift b/Application/Presentation/Development/Sources/Goal/TodoSelection/GoalCreateTodoSelectionSheet.swift new file mode 100644 index 00000000..a0e9f0bc --- /dev/null +++ b/Application/Presentation/Development/Sources/Goal/TodoSelection/GoalCreateTodoSelectionSheet.swift @@ -0,0 +1,130 @@ +// +// GoalCreateTodoSelectionSheet.swift +// Development +// +// Created by opfic on 9/20/26. +// + +import SwiftUI +import PresentationShared + +struct GoalCreateTodoSelectionSheet: 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) { + 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) + } + + 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 + GoalTodoSelectionSection( + section: section, + selectedTodoIDs: store.selectedTodoIDs, + onToggle: { store.send(.view(.toggleTodo($0))) } + ) + } + } + + 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) { + 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) + Spacer() + 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) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) + } + .scrollDismissesKeyboard(.interactively) + .background(Color.appBackground.ignoresSafeArea()) + .onAppear { store.send(.view(.fetch)) } + .presentationDragIndicator(.visible) + } +} diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift index 5a4073f0..5721e6f2 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift @@ -159,7 +159,7 @@ struct GoalTodoLinkSheet: View { ContentUnavailableView.search(text: store.searchText) } else { ForEach(store.sections) { section in - GoalTodoLinkSection( + GoalTodoSelectionSection( section: section, selectedTodoIDs: store.selectedTodoIDs, onToggle: { store.send(.view(.toggleTodo($0))) } @@ -172,75 +172,3 @@ struct GoalTodoLinkSheet: View { 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/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 1c941734..74513f2b 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -424,6 +424,125 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 목표" } } } }, + "development_goal_create_description_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Goal Description" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 설명" } } + } + }, + "development_goal_create_error_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The development goal could not be created. Please try again." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 목표를 만들지 못했어요. 다시 시도해주세요." } } + } + }, + "development_goal_create_markdown_hint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Markdown is supported." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "Markdown을 지원해요." } } + } + }, + "development_goal_create_preview_empty_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Write a description to preview it." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "설명을 작성하면 미리볼 수 있어요." } } + } + }, + "development_goal_create_preview_empty_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No description yet" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "작성한 설명이 없어요" } } + } + }, + "development_goal_create_save_hint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Not saved yet" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "저장되지 않음" } } + } + }, + "development_goal_create_status" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Status" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "상태" } } + } + }, + "development_goal_create_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "New Development Goal" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 개발 목표" } } + } + }, + "development_goal_create_title_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Goal Title" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 제목" } } + } + }, + "development_goal_create_title_placeholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Enter a goal title" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 제목을 입력하세요" } } + } + }, + "development_goal_create_todo_hint" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "You can link Todos later." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "나중에 연결할 수 있어요." } } + } + }, + "development_goal_create_todo_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Linked Todo" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "연결된 Todo" } } + } + }, + "development_goal_create_todo_link_error_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The goal was created, but some Todo links could not be saved. You can link them again from the goal details." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표는 만들었지만 일부 Todo를 연결하지 못했어요. 목표 상세에서 다시 연결할 수 있어요." } } + } + }, + "development_goal_create_todo_optional" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Optional" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "선택" } } + } + }, + "development_goal_create_todo_select" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Select Todos" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo 선택" } } + } + }, + "development_goal_create_todo_selected_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld selected" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld개 선택됨" } } + } + }, + "development_goal_save" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Save Goal" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 저장" } } + } + }, "development_goal_linked_todos" : { "extractionState" : "manual", "localizations" : { From 21717db42452e7d1b6232cd57ed7e4cd430849dc Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 20 Sep 2026 09:45:35 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EC=84=A4=EB=AA=85=20=ED=91=9C=EC=8B=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Goal/GoalDescriptionCard.swift | 53 +++++++++++++++++++ .../Record/GoalDetail/GoalDetailView.swift | 4 ++ .../Resources/Localizable.xcstrings | 14 +++++ 3 files changed, 71 insertions(+) create mode 100644 Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift diff --git a/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift b/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift new file mode 100644 index 00000000..a6b5ed52 --- /dev/null +++ b/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift @@ -0,0 +1,53 @@ +// +// GoalDescriptionCard.swift +// Development +// +// Created by opfic on 9/20/26. +// + +import SwiftUI +import PresentationShared + +struct GoalDescriptionCard: View { + let description: String + let isLoading: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(RecordPresentation.text("development_goal_description_title")) + .font(.title3.bold()) + + if isLoading { + ProgressView() + .tint(Color.accent) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } else if description.isEmpty { + Text(RecordPresentation.text("development_goal_description_empty_message")) + .font(.body) + .foregroundStyle(Color.textSecondary) + } else { + MarkdownContentView(content: description) + .frame(minHeight: 120) + } + } + .padding(20) + .background(Color.surface, in: .rect(cornerRadius: 24)) + } +} + +#Preview("목표 설명") { + GoalDescriptionCard( + description: """ + 개발 기록을 여러 버전으로 안전하게 관리하고, + 확정된 기록의 이력을 보존합니다. + + - 초안과 확정 분리 + - 정정은 새 버전으로 저장 + - 이전 버전으로 되돌리기 + """, + isLoading: false + ) + .padding() + .background(Color.appBackground) +} diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift index 421e62d6..5e86515e 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -34,6 +34,10 @@ public struct GoalDetailView: View { ScrollView { LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) { Section { + GoalDescriptionCard( + description: store.goal?.description ?? "", + isLoading: !store.hasLoaded && store.isLoading + ) timelineCard GoalLinkedTodoCard( todos: store.linkedTodos, diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 74513f2b..7f2e45b3 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -536,6 +536,20 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld개 선택됨" } } } }, + "development_goal_description_empty_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No goal description has been written." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "작성된 목표 설명이 없어요." } } + } + }, + "development_goal_description_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Goal Description" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 설명" } } + } + }, "development_goal_save" : { "extractionState" : "manual", "localizations" : { From ba473905cf0ab748deec89951ccfac8695af02eb Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 20 Sep 2026 11:04:14 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=EC=83=9D=EC=84=B1=20=ED=99=94=EB=A9=B4=EA=B3=BC=20?= =?UTF-8?q?=ED=83=91=EB=B0=94=20=ED=91=9C=EC=8B=9C=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Goal/Create/GoalCreateView.swift | 55 ++++++------------- .../Record/Editor/RecordEditorView.swift | 4 +- .../Resources/Localizable.xcstrings | 15 ++--- .../Sources/Todo/Editor/TodoEditorView.swift | 4 +- 4 files changed, 25 insertions(+), 53 deletions(-) diff --git a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift index 7c434e83..535971db 100644 --- a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift +++ b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift @@ -44,9 +44,7 @@ public struct GoalCreateView: View { GoalCreateTopBar( iconSize: iconSize, isSaving: store.isSaving, - isSaveEnabled: store.isReadyToSave, - onClose: dismiss.callAsFunction, - onSave: save + onClose: dismiss.callAsFunction ) } .safeAreaInset(edge: .bottom, spacing: 0) { @@ -80,44 +78,28 @@ public struct GoalCreateView: View { private struct GoalCreateTopBar: View { let iconSize: CGFloat let isSaving: Bool - let isSaveEnabled: Bool let onClose: () -> Void - let onSave: () -> Void var body: some View { - HStack { - Button(action: onClose) { - if #available(iOS 26.0, *) { - Image(systemName: "xmark") - .frame(width: iconSize, height: iconSize) - } else { - Text(RecordPresentation.text("common_close")) - } - } - .font(.title) - .topBarButtonStyle() - .disabled(isSaving) - - Spacer() - + ZStack { Text(RecordPresentation.text("development_goal_create_title")) .font(.headline) - Spacer() - - Button(action: onSave) { - if #available(iOS 26.0, *) { - Image(systemName: "checkmark") - .frame(width: iconSize, height: iconSize) - .foregroundStyle(Color.primary) - } else { - Text(RecordPresentation.text("development_goal_save")) - .foregroundStyle(Color.accent) + HStack { + Button(action: onClose) { + if #available(iOS 26.0, *) { + Image(systemName: "xmark") + .frame(width: iconSize, height: iconSize) + .font(.title) + } else { + Text(RecordPresentation.text("common_close")) + } } + .topBarButtonStyle() + .disabled(isSaving) + + Spacer() } - .font(.title) - .topBarButtonStyle(color: Color.surface) - .disabled(!isSaveEnabled) } .padding(.horizontal) .padding(.vertical, 12) @@ -194,7 +176,7 @@ private struct GoalCreateDescriptionEditor: View { .foregroundStyle(Color.textTertiary) .frame(maxWidth: .infinity, alignment: .leading) } - .padding(20) + .padding(16) .background(Color.surface, in: .rect(cornerRadius: 24)) } .disabled(store.isSaving) @@ -320,7 +302,7 @@ private struct GoalCreateSaveBar: View { let onSave: () -> Void var body: some View { - VStack(spacing: 8) { + VStack { Button(action: onSave) { Group { if isSaving { @@ -338,9 +320,6 @@ private struct GoalCreateSaveBar: View { .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .accent) .disabled(!isSaveEnabled) - Text(RecordPresentation.text("development_goal_create_save_hint")) - .font(.caption) - .foregroundStyle(Color.textTertiary) } .padding(.horizontal) .padding(.vertical, 12) diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift index 1bf459ff..5dbf09a6 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift @@ -72,11 +72,11 @@ public struct RecordEditorView: View { if #available(iOS 26.0, *) { Image(systemName: "xmark") .frame(width: iconSize, height: iconSize) + .font(.title) } else { Text(RecordPresentation.text("common_close")) } } - .font(.title) .topBarButtonStyle() .disabled(store.isLoading) Spacer() @@ -95,12 +95,12 @@ public struct RecordEditorView: View { Image(systemName: "checkmark") .frame(width: iconSize, height: iconSize) .foregroundStyle(Color.primary) + .font(.title) } else { Text(RecordPresentation.text("development_record_save")) .foregroundStyle(Color.accent) } } - .font(.title) .topBarButtonStyle(color: Color.surface) .disabled(!store.isReadyToSave) } diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 7f2e45b3..fdbe50ef 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -427,8 +427,8 @@ "development_goal_create_description_label" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Goal Description" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 설명" } } + "en" : { "stringUnit" : { "state" : "translated", "value" : "Description" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "설명" } } } }, "development_goal_create_error_message" : { @@ -459,13 +459,6 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "작성한 설명이 없어요" } } } }, - "development_goal_create_save_hint" : { - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Not saved yet" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "저장되지 않음" } } - } - }, "development_goal_create_status" : { "extractionState" : "manual", "localizations" : { @@ -483,8 +476,8 @@ "development_goal_create_title_label" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Goal Title" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 제목" } } + "en" : { "stringUnit" : { "state" : "translated", "value" : "Title" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "제목" } } } }, "development_goal_create_title_placeholder" : { diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index f48a4478..cf5db971 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -180,11 +180,11 @@ private struct ToolBar: View { if #available(iOS 26.0, *) { Image(systemName: "xmark") .frame(width: iconSize, height: iconSize) + .font(.title) } else { Text(String(localized: "common_close", bundle: PresentationResources.bundle)) } } - .font(.title) .adaptiveButtonStyle(shape: .circle, glassEffect: .enabled) Spacer() Text(store.navigationTitle) @@ -230,12 +230,12 @@ struct EditorToolbarActions: View { Image(systemName: "checkmark") .frame(width: iconSize, height: iconSize) .foregroundStyle(Color.primary) + .font(.title) } else { Text(String(localized: "todo_manage_save", bundle: PresentationResources.bundle)) .foregroundStyle(Color.primary) } } - .font(.title) .adaptiveButtonStyle(shape: .circle, color: Color.surface, glassEffect: .enabled) .disabled(!store.isReadyToSubmit) } From 01242d0824a0cc0bf011f7e515594283c910ab8b Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 20 Sep 2026 11:04:54 +0900 Subject: [PATCH 6/6] =?UTF-8?q?chore:=20=EA=B0=9C=EB=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=20=ED=99=94=EB=A9=B4=20=ED=94=84=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Goal/Create/GoalCreateView.swift | 4 ---- .../Sources/Goal/GoalDescriptionCard.swift | 16 ---------------- 2 files changed, 20 deletions(-) diff --git a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift index 535971db..87ca4f58 100644 --- a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift +++ b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift @@ -331,7 +331,3 @@ private enum GoalCreateField: Hashable { case title case content } - -#Preview("새 개발 목표") { - GoalCreateView() -} diff --git a/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift b/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift index a6b5ed52..daa11523 100644 --- a/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift +++ b/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift @@ -35,19 +35,3 @@ struct GoalDescriptionCard: View { .background(Color.surface, in: .rect(cornerRadius: 24)) } } - -#Preview("목표 설명") { - GoalDescriptionCard( - description: """ - 개발 기록을 여러 버전으로 안전하게 관리하고, - 확정된 기록의 이력을 보존합니다. - - - 초안과 확정 분리 - - 정정은 새 버전으로 저장 - - 이전 버전으로 되돌리기 - """, - isLoading: false - ) - .padding() - .background(Color.appBackground) -}