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/Create/GoalCreateView.swift b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift new file mode 100644 index 00000000..87ca4f58 --- /dev/null +++ b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift @@ -0,0 +1,333 @@ +// +// 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, + onClose: dismiss.callAsFunction + ) + } + .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 onClose: () -> Void + + var body: some View { + ZStack { + Text(RecordPresentation.text("development_goal_create_title")) + .font(.headline) + + 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() + } + } + .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(16) + .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 { + 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) + + } + .padding(.horizontal) + .padding(.vertical, 12) + .background(Color.surface, ignoresSafeAreaEdges: .bottom) + } +} + +private enum GoalCreateField: Hashable { + case title + case content +} diff --git a/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift b/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift new file mode 100644 index 00000000..daa11523 --- /dev/null +++ b/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift @@ -0,0 +1,37 @@ +// +// 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)) + } +} 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/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/Editor/RecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift index d678adf3..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) } @@ -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: "개발 기록의 버전 이력 완성" + ) +} 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/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/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/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 diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 1c941734..fdbe50ef 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -424,6 +424,132 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 목표" } } } }, + "development_goal_create_description_label" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "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_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" : "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_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" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Save Goal" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 저장" } } + } + }, "development_goal_linked_todos" : { "extractionState" : "manual", "localizations" : { 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) }