Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ private extension AppGraph {
func prepareDevelopmentDependencies(_ dependencies: inout DependencyValues) {
DevelopmentDependencyPreparation.prepareGoal(
&dependencies,
createGoalUseCase: developmentGraphSet
.developmentGoalUseCaseGraph
.createDevelopmentGoalUseCase,
fetchGoalUseCase: developmentGraphSet
.developmentGoalUseCaseGraph
.fetchDevelopmentGoalUseCase,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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.")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Action.Alert>?
@Presents var todoSelection: GoalCreateTodoSelectionFeature.State?
var title = ""
var markdownContent = ""
var selectedTab = EditorTab.write
var selectedTodoIDs = Set<String>()
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<Alert>)
case binding(BindingAction<State>)
case todoSelection(PresentationAction<GoalCreateTodoSelectionFeature.Action>)
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<Self> {
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<Action> {
.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<String>) -> Effect<Action> {
.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<Action.Alert> {
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<Action.Alert> {
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
))
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String>
var searchText = ""
var isLoading = false
var hasLoadFailure = false

init(selectedTodoIDs: Set<String>) {
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<State>)
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<String>)
}
}

@Dependency(\.developmentFetchTodosUseCase) private var fetchTodosUseCase

var body: some ReducerOf<Self> {
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<Action> {
.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))
}
}
}
}
Loading
Loading