diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index 3195aa5c..134d97a1 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -128,6 +128,19 @@ private extension AppGraph { } func prepareHomeDependencies(_ dependencies: inout DependencyValues) { + HomePresentationDependencyPreparation.prepareDevelopmentGoal( + &dependencies, + fetchGoalsUseCase: developmentGraphSet + .developmentGoalUseCaseGraph + .fetchDevelopmentGoalsUseCase, + fetchRecordsUseCase: developmentGraphSet + .developmentRecordQueryUseCaseGraph + .fetchDevelopmentRecordsUseCase, + fetchRecordVersionUseCase: developmentGraphSet + .developmentRecordQueryUseCaseGraph + .fetchDevelopmentRecordVersionUseCase, + fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase + ) HomePresentationDependencyPreparation.prepareTodoCategory( &dependencies, updateTodoCategoryPreferencesUseCase: todoGraphSet diff --git a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift index 87ca4f58..cef89aa8 100644 --- a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift +++ b/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift @@ -141,35 +141,34 @@ private struct GoalCreateDescriptionEditor: View { 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) + Group { + switch store.selectedTab { + case .write: + TextEditor(text: $store.markdownContent) + .focused($focusedField, equals: .content) + .font(.body) + .scrollContentBackground(.hidden) + .padding(12) + case .preview: + Group { + if store.markdownContent.isEmpty { + ContentUnavailableView { + Text(RecordPresentation.text("development_goal_create_preview_empty_title")) + .bold() + } description: { + Text(RecordPresentation.text("development_goal_create_preview_empty_message")) + } + .frame(maxWidth: .infinity) + .frame(height: 120) + } else { + MarkdownContentView(content: store.markdownContent) + .padding(.vertical, 16) + } } } - .frame(minHeight: 340) - .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) } + .frame(minHeight: 120) + .background(Color.surfaceSecondary, in: .rect(cornerRadius: 16)) Text(RecordPresentation.text("development_goal_create_markdown_hint")) .font(.caption) diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift index 5dbf09a6..51cf7c8c 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift @@ -297,10 +297,3 @@ private enum Field: Hashable { case title case content } - -#Preview("새 개발 기록") { - RecordEditorView( - goalId: "preview-goal", - goalTitle: "개발 기록의 버전 이력 완성" - ) -} diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalItem.swift b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalItem.swift new file mode 100644 index 00000000..d511bd21 --- /dev/null +++ b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalItem.swift @@ -0,0 +1,47 @@ +// +// HomeDevelopmentGoalItem.swift +// HomeTab +// +// Created by opfic on 9/20/26. +// + +import Domain + +struct HomeDevelopmentGoalItem: Equatable, Identifiable { + let goal: DevelopmentGoal + let recentRecord: DevelopmentRecord.Version? + let todoProgress: HomeDevelopmentGoalTodoProgress + + var id: String { goal.id } + + init( + goal: DevelopmentGoal, + recentRecord: DevelopmentRecord.Version?, + todoProgress: HomeDevelopmentGoalTodoProgress = .empty + ) { + self.goal = goal + self.recentRecord = recentRecord + self.todoProgress = todoProgress + } +} + +struct HomeDevelopmentGoalTodoProgress: Equatable { + let completedCount: Int + let totalCount: Int + + static let empty = Self(completedCount: 0, totalCount: 0) + + var fraction: Double { + guard totalCount != 0 else { return 0 } + return Double(completedCount) / Double(totalCount) + } + + var percentage: Int { + guard totalCount != 0 else { return 0 } + return (completedCount * 100 + totalCount / 2) / totalCount + } + + var isEmpty: Bool { + totalCount == 0 + } +} diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalSection.swift b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalSection.swift new file mode 100644 index 00000000..cdb40e85 --- /dev/null +++ b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentGoalSection.swift @@ -0,0 +1,247 @@ +// +// HomeDevelopmentGoalSection.swift +// HomeTab +// +// Created by opfic on 9/20/26. +// + +import SwiftUI +import PresentationShared + +struct HomeDevelopmentGoalSection: View { + let items: [HomeDevelopmentGoalItem] + let isLoading: Bool + let hasLoaded: Bool + let hasLoadFailure: Bool + let onCreate: () -> Void + let onSelect: (HomeDevelopmentGoalItem) -> Void + let onRetry: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + content + } + .padding(.top, 8) + } + + @ViewBuilder + private var content: some View { + if !hasLoaded && isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } else if !hasLoaded && hasLoadFailure { + ContentUnavailableView { + Label( + String(localized: "common_error_title", bundle: PresentationResources.bundle), + systemImage: "exclamationmark.triangle" + ) + } description: { + Text(String(localized: "common_error_message", bundle: PresentationResources.bundle)) + } actions: { + Button(String(localized: "development_record_timeline_retry", bundle: PresentationResources.bundle)) { + onRetry() + } + .buttonStyle(.borderedProminent) + } + } else if items.isEmpty { + ContentUnavailableView { + Label( + String( + localized: "home_development_goal_empty_title", + bundle: PresentationResources.bundle + ), + systemImage: "flag.checkered" + ) + } description: { + Text( + String( + localized: "home_development_goal_empty_message", + bundle: PresentationResources.bundle + ) + ) + } + } else { + LazyVStack(spacing: 16) { + ForEach(items) { item in + Button { + onSelect(item) + } label: { + HomeDevelopmentGoalCard(item: item) + } + .buttonStyle(.plain) + } + } + } + } + + private var header: some View { + HStack(spacing: 8) { + Text("home_development_goal_section_title", bundle: PresentationResources.bundle) + .font(.title2) + .foregroundStyle(Color.primary) + if hasLoaded { + Text( + String.localizedStringWithFormat( + String(localized: "home_development_goal_count_format", bundle: PresentationResources.bundle), + items.count + ) + ) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.accent) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.accent.opacity(0.1), in: .capsule) + } + Spacer() + Button(action: onCreate) { + Text("home_development_goal_create", bundle: PresentationResources.bundle) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.textSecondary) + } + .accessibilityLabel( + String(localized: "development_goal_create_title", bundle: PresentationResources.bundle) + ) + } + .accessibilityElement(children: .combine) + } +} + +private struct HomeDevelopmentGoalCard: View { + let item: HomeDevelopmentGoalItem + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + HomeDevelopmentGoalStatusBadge() + Spacer(minLength: 12) + if let recentRecord = item.recentRecord { + Text(recentRecord.confirmedAt, format: .dateTime.month().day().hour().minute()) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + } + + Text(item.goal.title) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.primary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + + if !item.goal.description.isEmpty { + Text(item.goal.description) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + + HomeDevelopmentTodoProgressView(progress: item.todoProgress) + + Divider() + + if let recentRecord = item.recentRecord { + VStack(alignment: .leading, spacing: 3) { + Text("home_development_goal_recent_record", bundle: PresentationResources.bundle) + .font(.caption) + .foregroundStyle(Color.textTertiary) + Text(recentRecord.title) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.primary) + .lineLimit(1) + } + } else { + Text("development_record_empty_title", bundle: PresentationResources.bundle) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + } + .padding(18) + .background(Color.surface, in: .rect(cornerRadius: 24)) + .contentShape(.rect) + .accessibilityElement(children: .combine) + } +} + +struct HomeDevelopmentTodoProgressView: View { + let progress: HomeDevelopmentGoalTodoProgress + + var body: some View { + if progress.isEmpty { + Text("home_development_goal_todo_empty", bundle: PresentationResources.bundle) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } else { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text( + String.localizedStringWithFormat( + String( + localized: "home_development_goal_todo_progress_format", + bundle: PresentationResources.bundle + ), + progress.completedCount, + progress.totalCount + ) + ) + .font(.caption.weight(.medium)) + .foregroundStyle(Color.textSecondary) + Spacer(minLength: 8) + Text( + String.localizedStringWithFormat( + String( + localized: "home_development_goal_todo_progress_percent_format", + bundle: PresentationResources.bundle + ), + progress.percentage + ) + ) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.accent) + } + + GeometryReader { proxy in + Capsule() + .fill(Color.border.opacity(0.6)) + .overlay(alignment: .leading) { + Capsule() + .fill(Color.accent) + .frame(width: proxy.size.width * progress.fraction) + } + } + .frame(height: 8) + } + .accessibilityElement(children: .combine) + } + } +} + +private struct HomeDevelopmentGoalStatusBadge: View { + var body: some View { + Label { + Text("development_goal_status_in_progress", bundle: PresentationResources.bundle) + } icon: { + Image(systemName: "clock") + } + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.accent) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.accent.opacity(0.1), in: .capsule) + } +} + +enum HomeGoalPresentation: Identifiable { + case create + case detail(String) + + var id: String { + switch self { + case .create: + "create" + case .detail(let goalID): + goalID + } + } +} diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentSummaryCard.swift b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentSummaryCard.swift new file mode 100644 index 00000000..bb1f93f9 --- /dev/null +++ b/Application/Presentation/HomeTab/Sources/Home/HomeDevelopmentSummaryCard.swift @@ -0,0 +1,106 @@ +// +// HomeDevelopmentSummaryCard.swift +// HomeTab +// +// Created by opfic on 9/20/26. +// + +import Domain +import SwiftUI +import PresentationShared + +struct HomeDevelopmentSummaryCard: View { + let items: [HomeDevelopmentGoalItem] + let isLoading: Bool + let hasLoaded: Bool + let hasLoadFailure: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text("home_development_summary_eyebrow", bundle: PresentationResources.bundle) + .font(.subheadline) + .foregroundStyle(Color.textTertiary) + summaryTitle + } + Spacer(minLength: 16) + Image(systemName: "flag.checkered") + .font(.title2.weight(.semibold)) + .foregroundStyle(Color.accent) + .frame(width: 64, height: 64) + .background(Color.accent.opacity(0.1), in: .rect(cornerRadius: 20)) + } + + summaryContent + } + .padding(20) + .background(Color.surface, in: .rect(cornerRadius: 28)) + .accessibilityElement(children: .combine) + } + + private var summaryTitle: some View { + Text( + String.localizedStringWithFormat( + String( + localized: "home_development_summary_title_format", + bundle: PresentationResources.bundle + ), + items.count + ) + ) + .font(.title2.weight(.semibold)) + .foregroundStyle(Color.primary) + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder + private var summaryContent: some View { + if !hasLoaded && isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } else if !hasLoaded && hasLoadFailure { + Text("common_error_message", bundle: PresentationResources.bundle) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + } else if items.isEmpty { + Text("home_development_summary_empty_message", bundle: PresentationResources.bundle) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + } else { + VStack(alignment: .leading, spacing: 8) { + HomeDevelopmentTodoProgressView(progress: todoProgress) + + if let recentRecord { + VStack(alignment: .leading, spacing: 8) { + Text("home_development_summary_recent_record", bundle: PresentationResources.bundle) + .font(.caption.weight(.medium)) + .foregroundStyle(Color.textTertiary) + HStack(spacing: 12) { + Text(recentRecord.title) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Color.primary) + .lineLimit(1) + Spacer(minLength: 12) + Text(recentRecord.confirmedAt, format: .dateTime.month().day().hour().minute()) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + } + } + } + } + } + + private var recentRecord: DevelopmentRecord.Version? { + items.compactMap(\.recentRecord).max { $0.confirmedAt < $1.confirmedAt } + } + + private var todoProgress: HomeDevelopmentGoalTodoProgress { + HomeDevelopmentGoalTodoProgress( + completedCount: items.reduce(0) { $0 + $1.todoProgress.completedCount }, + totalCount: items.reduce(0) { $0 + $1.todoProgress.totalCount } + ) + } +} diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Dependencies.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Dependencies.swift index f004334b..0aebc592 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Dependencies.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Dependencies.swift @@ -9,6 +9,21 @@ import PresentationShared import Domain extension DependencyValues { + var homeFetchDevelopmentGoalsUseCase: FetchDevelopmentGoalsUseCase { + get { self[HomeFetchDevelopmentGoalsUseCaseKey.self] } + set { self[HomeFetchDevelopmentGoalsUseCaseKey.self] = newValue } + } + + var homeFetchDevelopmentRecordsUseCase: FetchDevelopmentRecordsUseCase { + get { self[HomeFetchDevelopmentRecordsUseCaseKey.self] } + set { self[HomeFetchDevelopmentRecordsUseCaseKey.self] = newValue } + } + + var homeFetchDevelopmentRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase { + get { self[HomeFetchDevelopmentRecordVersionKey.self] } + set { self[HomeFetchDevelopmentRecordVersionKey.self] = newValue } + } + var homeUpdateTodoCategoryPreferencesUseCase: UpdateTodoCategoryPreferencesUseCase { get { self[HomeUpdatePreferencesUseCaseKey.self] } set { self[HomeUpdatePreferencesUseCaseKey.self] = newValue } @@ -20,6 +35,36 @@ extension DependencyValues { } } +private enum HomeFetchDevelopmentGoalsUseCaseKey: DependencyKey { + static var liveValue: FetchDevelopmentGoalsUseCase { + preconditionFailure("FetchDevelopmentGoalsUseCase must be provided.") + } + + static var testValue: FetchDevelopmentGoalsUseCase { + liveValue + } +} + +private enum HomeFetchDevelopmentRecordsUseCaseKey: DependencyKey { + static var liveValue: FetchDevelopmentRecordsUseCase { + preconditionFailure("FetchDevelopmentRecordsUseCase must be provided.") + } + + static var testValue: FetchDevelopmentRecordsUseCase { + liveValue + } +} + +private enum HomeFetchDevelopmentRecordVersionKey: DependencyKey { + static var liveValue: FetchDevelopmentRecordVersionUseCase { + preconditionFailure("FetchDevelopmentRecordVersionUseCase must be provided.") + } + + static var testValue: FetchDevelopmentRecordVersionUseCase { + liveValue + } +} + private enum HomeUpdatePreferencesUseCaseKey: DependencyKey { static var liveValue: UpdateTodoCategoryPreferencesUseCase { preconditionFailure("UpdateTodoCategoryPreferencesUseCase must be provided.") diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift index a7f4a15d..e834475b 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift @@ -38,6 +38,50 @@ extension HomeFeature { } } + func fetchDevelopmentGoalsEffect() -> Effect { + let goalsUseCase = fetchDevelopmentGoalsUseCase + let recordsUseCase = fetchDevelopmentRecordsUseCase + let versionUseCase = fetchDevelopmentRecordVersionUseCase + let todosUseCase = fetchTodosUseCase + + return .run { [goalsUseCase, recordsUseCase, versionUseCase, todosUseCase] send in + await send(.loading(.begin(target: LoadingTarget.developmentGoals.target, mode: .immediate))) + do { + let goals = try await goalsUseCase.execute(.init(status: .inProgress)) + if goals.isEmpty { + await send(.store(.developmentGoalsLoaded([]))) + } else { + async let todos = todosUseCase.execute( + TodoQuery( + sortTarget: .updatedAt, + sortOrder: .latest, + pageSize: 100, + fetchAllPages: true + ), + cursor: nil + ) + let goalItems = try await Self.makeDevelopmentGoalItems( + goals, + fetchRecordsUseCase: recordsUseCase, + fetchRecordVersionUseCase: versionUseCase + ) + let todoProgressByGoalID = Self.makeTodoProgressByGoalID((try await todos).items) + let items = goalItems.map { item in + HomeDevelopmentGoalItem( + goal: item.goal, + recentRecord: item.recentRecord, + todoProgress: todoProgressByGoalID[item.goal.id] ?? .empty + ) + } + await send(.store(.developmentGoalsLoaded(items))) + } + } catch { + await send(.store(.developmentGoalsLoadFailed)) + } + await send(.loading(.end(target: LoadingTarget.developmentGoals.target, mode: .immediate))) + } + } + func trackTodoCreateEffect() -> Effect { .run { [trackAnalyticsEventUseCase] _ in trackAnalyticsEventUseCase.execute(.todoCreate) @@ -105,4 +149,81 @@ extension HomeFeature { } } + static func makeDevelopmentGoalItems( + _ goals: [DevelopmentGoal], + fetchRecordsUseCase: FetchDevelopmentRecordsUseCase, + fetchRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase + ) async throws -> [HomeDevelopmentGoalItem] { + var items = [HomeDevelopmentGoalItem]() + + try await withThrowingTaskGroup(of: HomeDevelopmentGoalItem.self) { group in + for goal in goals { + group.addTask { + let records = try await fetchRecordsUseCase.execute(goalId: goal.id) + let recentRecord = try await makeRecentRecord( + goalId: goal.id, + records: records, + fetchRecordVersionUseCase: fetchRecordVersionUseCase + ) + return HomeDevelopmentGoalItem(goal: goal, recentRecord: recentRecord) + } + } + + for try await item in group { + items.append(item) + } + } + + return items.sorted { lhs, rhs in + if lhs.goal.createdAt == rhs.goal.createdAt { + return lhs.id < rhs.id + } + return lhs.goal.createdAt < rhs.goal.createdAt + } + } + + static func makeRecentRecord( + goalId: String, + records: [DevelopmentRecord], + fetchRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase + ) async throws -> DevelopmentRecord.Version? { + var versions = [DevelopmentRecord.Version]() + + try await withThrowingTaskGroup(of: DevelopmentRecord.Version.self) { group in + for record in records { + guard let currentVersion = record.currentVersion else { continue } + group.addTask { + try await fetchRecordVersionUseCase.execute( + goalId: goalId, + recordId: record.id, + versionId: currentVersion.id + ) + } + } + + for try await version in group { + versions.append(version) + } + } + + return versions.max { $0.confirmedAt < $1.confirmedAt } + } + + static func makeTodoProgressByGoalID( + _ todos: [Todo] + ) -> [String: HomeDevelopmentGoalTodoProgress] { + var progressByGoalID = [String: HomeDevelopmentGoalTodoProgress]() + + for todo in todos { + guard let goalID = todo.goalId else { continue } + let currentProgress = progressByGoalID[goalID] ?? .empty + progressByGoalID[goalID] = HomeDevelopmentGoalTodoProgress( + completedCount: currentProgress.completedCount + (todo.isCompleted ? 1 : 0), + totalCount: currentProgress.totalCount + 1 + ) + } + + return progressByGoalID + } + } diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift index 20a646ce..95ad3e68 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift @@ -16,6 +16,9 @@ struct HomeFeature { @Presents var alert: AlertState? @Presents var sheet: SheetState? @Presents var fullScreenCover: FullScreenCoverState? + var developmentGoalItems = [HomeDevelopmentGoalItem]() + var hasDevelopmentGoalsLoaded = false + var hasDevelopmentGoalsLoadFailure = false var preferences = [TodoCategoryItem]() var isTodoCategoryExpanded = false var isNetworkConnected = true @@ -37,6 +40,10 @@ struct HomeFeature { loading.visibleTargets.contains(LoadingTarget.preferences.target) } + var isDevelopmentGoalsLoading: Bool { + loading.visibleTargets.contains(LoadingTarget.developmentGoals.target) + } + } enum Action: BindableAction, Equatable { @@ -62,6 +69,8 @@ struct HomeFeature { case setSheet(SheetState?) case setPresentation(Presentation, Bool) case setAlert(isPresented: Bool) + case developmentGoalsLoaded([HomeDevelopmentGoalItem]) + case developmentGoalsLoadFailed case setTodoCategory([TodoCategoryItem]) } } @@ -123,16 +132,23 @@ struct HomeFeature { enum LoadingTarget: Hashable { case preferences + case developmentGoals var target: LoadingFeature.Target { switch self { case .preferences: return LoadingFeature.Target("home.preferences") + case .developmentGoals: + return LoadingFeature.Target("home.developmentGoals") } } } @Dependency(\.fetchTodoCategoryPreferencesUseCase) var fetchPreferencesUseCase + @Dependency(\.homeFetchDevelopmentGoalsUseCase) var fetchDevelopmentGoalsUseCase + @Dependency(\.homeFetchDevelopmentRecordsUseCase) var fetchDevelopmentRecordsUseCase + @Dependency(\.homeFetchDevelopmentRecordVersionUseCase) var fetchDevelopmentRecordVersionUseCase + @Dependency(\.homeFetchTodosUseCase) var fetchTodosUseCase @Dependency(\.homeUpdateTodoCategoryPreferencesUseCase) var updatePreferencesUseCase @Dependency(\.homeNetworkConnectivityUseCase) var networkConnectivityUseCase @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase @@ -203,7 +219,10 @@ private extension HomeFeature { case .startObserving: return observeNetworkConnectivityEffect() case .fetchData: - return fetchTodoCategoryPreferencesEffect() + return .merge( + fetchTodoCategoryPreferencesEffect(), + fetchDevelopmentGoalsEffect() + ) case .todoEditorCreated: state.fullScreenCover = nil state.selectedTodoCategory = nil @@ -246,6 +265,12 @@ private extension HomeFeature { Self.setPresentation(&state, presentation: presentation, isPresented: isPresented) case .setAlert(let isPresented): Self.setAlert(&state, isPresented: isPresented) + case .developmentGoalsLoaded(let items): + state.developmentGoalItems = items + state.hasDevelopmentGoalsLoaded = true + state.hasDevelopmentGoalsLoadFailure = false + case .developmentGoalsLoadFailed: + state.hasDevelopmentGoalsLoadFailure = true case .setTodoCategory(let preferences): state.preferences = preferences } diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift index cef29b41..f67b457f 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift @@ -7,6 +7,7 @@ import SwiftUI import Combine +import Development import Domain import PresentationShared @@ -18,6 +19,7 @@ public struct HomeView: View { @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) @ScaledMetric(relativeTo: .title2) private var categoryIconSize = CGFloat(64) @State private var path = [HomeRoute]() + @State private var goalPresentation: HomeGoalPresentation? @State private var searchStore: StoreOf @State private var store: StoreOf private let isSelected: Bool @@ -47,10 +49,25 @@ public struct HomeView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { Section { + HomeDevelopmentSummaryCard( + items: store.developmentGoalItems, + isLoading: store.isDevelopmentGoalsLoading, + hasLoaded: store.hasDevelopmentGoalsLoaded, + hasLoadFailure: store.hasDevelopmentGoalsLoadFailure + ) todoSection } header: { topBar } + HomeDevelopmentGoalSection( + items: store.developmentGoalItems, + isLoading: store.isDevelopmentGoalsLoading, + hasLoaded: store.hasDevelopmentGoalsLoaded, + hasLoadFailure: store.hasDevelopmentGoalsLoadFailure, + onCreate: { goalPresentation = .create }, + onSelect: { goalPresentation = .detail($0.id) }, + onRetry: { store.send(.view(.fetchData)) } + ) } .padding(.horizontal, 16) } @@ -76,6 +93,14 @@ public struct HomeView: View { .activePresentation(when: isSelected), content: sheetContent ) + .sheet(item: $goalPresentation, onDismiss: refreshDevelopmentGoals) { presentation in + switch presentation { + case .create: + GoalCreateView() + case .detail(let goalID): + GoalDetailView(goalId: goalID) + } + } .fullScreenCover( item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover) .activePresentation(when: isSelected), @@ -350,7 +375,6 @@ public struct HomeView: View { store.send(.view(.tapTodoCategory(todoCategory))) } } - private func categoryIconBackground(_ item: TodoCategoryItem) -> Color { if colorScheme == .dark { return item.color @@ -358,7 +382,6 @@ public struct HomeView: View { return item.color.opacity(0.12) } - private func categoryIconForeground(_ item: TodoCategoryItem) -> Color { if colorScheme == .dark { return .white @@ -366,6 +389,9 @@ public struct HomeView: View { return item.color } + private func refreshDevelopmentGoals() { + store.send(.view(.fetchData)) + } } public enum HomeRoute: Hashable { diff --git a/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift b/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift index b2f571e5..40e57a0a 100644 --- a/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift +++ b/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift @@ -9,6 +9,19 @@ import Domain import PresentationShared public enum HomeDependencyPreparation { + public static func prepareDevelopmentGoal( + _ dependencies: inout DependencyValues, + fetchGoalsUseCase: FetchDevelopmentGoalsUseCase, + fetchRecordsUseCase: FetchDevelopmentRecordsUseCase, + fetchRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase, + fetchTodosUseCase: FetchTodosUseCase + ) { + dependencies.homeFetchDevelopmentGoalsUseCase = fetchGoalsUseCase + dependencies.homeFetchDevelopmentRecordsUseCase = fetchRecordsUseCase + dependencies.homeFetchDevelopmentRecordVersionUseCase = fetchRecordVersionUseCase + dependencies.homeFetchTodosUseCase = fetchTodosUseCase + } + public static func prepareTodoCategory( _ dependencies: inout DependencyValues, updateTodoCategoryPreferencesUseCase: UpdateTodoCategoryPreferencesUseCase @@ -36,12 +49,23 @@ public enum HomeDependencyPreparation { } extension DependencyValues { + var homeFetchTodosUseCase: FetchTodosUseCase { + get { self[HomeFetchTodosUseCaseKey.self] } + set { self[HomeFetchTodosUseCaseKey.self] = newValue } + } + var homeFetchRecentSearchQueriesUseCase: FetchRecentSearchQueriesUseCase { get { self[HomeFetchRecentSearchQueriesUseCaseKey.self] } set { self[HomeFetchRecentSearchQueriesUseCaseKey.self] = newValue } } } +private enum HomeFetchTodosUseCaseKey: DependencyKey { + static var liveValue: FetchTodosUseCase { + preconditionFailure("FetchTodosUseCase must be provided.") + } +} + private enum HomeFetchRecentSearchQueriesUseCaseKey: DependencyKey { static var liveValue: FetchRecentSearchQueriesUseCase { preconditionFailure("FetchRecentSearchQueriesUseCase must be provided.") diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift index 460511cd..4486d4c4 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift @@ -71,6 +71,77 @@ struct HomeFetchDataContext { let fetchPreferencesUseCaseSpy: FetchTodoCategoryPreferencesUseCaseSpy } +func makeDevelopmentGoal( + id: String, + createdAt: TimeInterval +) throws -> DevelopmentGoal { + try DevelopmentGoal( + id: id, + title: "Goal \(id)", + description: "Description", + status: .inProgress, + createdAt: Date(timeIntervalSinceReferenceDate: createdAt), + updatedAt: Date(timeIntervalSinceReferenceDate: createdAt), + completedAt: nil + ) +} + +func makeDevelopmentRecord( + id: String, + goalId: String, + versionID: String +) throws -> DevelopmentRecord { + try DevelopmentRecord( + id: id, + goalId: goalId, + currentVersion: .init(id: versionID, number: 1), + draft: nil, + createdAt: .now + ) +} + +func makeDevelopmentRecordVersion( + id: String, + recordID: String, + title: String, + confirmedAt: TimeInterval +) throws -> DevelopmentRecord.Version { + try DevelopmentRecord.Version( + id: id, + recordId: recordID, + number: 1, + title: title, + markdownContent: "", + kind: .initial, + sourceVersionId: nil, + confirmedAt: Date(timeIntervalSinceReferenceDate: confirmedAt) + ) +} + +func makeHomeTodo( + id: String, + goalID: String?, + isCompleted: Bool +) -> Todo { + Todo( + id: id, + isPinned: false, + isCompleted: isCompleted, + isChecked: false, + number: 1, + title: "Todo \(id)", + content: "", + createdAt: .now, + updatedAt: .now, + completedAt: isCompleted ? .now : nil, + deletedAt: nil, + dueDate: nil, + tags: [], + category: .system(.feature), + goalId: goalID + ) +} + func makeHomeFetchDataContext() -> HomeFetchDataContext { let fetchPreferencesUseCaseSpy = FetchTodoCategoryPreferencesUseCaseSpy() fetchPreferencesUseCaseSpy.todoCategoryPreferences = [ diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift index 6304acc2..32cccdfe 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift @@ -48,3 +48,48 @@ final class ObserveNetworkConnectivityUseCaseSpy: ObserveNetworkConnectivityUseC currentValueSubject.eraseToAnyPublisher() } } + +final class FetchDevelopmentGoalsUseCaseSpy: FetchDevelopmentGoalsUseCase { + private(set) var queries = [DevelopmentGoal.Query]() + var result: Result<[DevelopmentGoal], Error> = .success([]) + + func execute(_ query: DevelopmentGoal.Query) async throws -> [DevelopmentGoal] { + queries.append(query) + return try result.get() + } +} + +final class FetchDevelopmentRecordsUseCaseSpy: FetchDevelopmentRecordsUseCase { + var resultByGoalID = [String: Result<[DevelopmentRecord], Error>]() + + func execute(goalId: String) async throws -> [DevelopmentRecord] { + try resultByGoalID[goalId, default: .success([])].get() + } +} + +final class FetchDevelopmentRecordVersionUseCaseSpy: FetchDevelopmentRecordVersionUseCase { + var resultByRecordID = [String: Result]() + + func execute( + goalId: String, + recordId: String, + versionId: String + ) async throws -> DevelopmentRecord.Version { + try resultByRecordID[recordId, default: .failure(HomeDevelopmentGoalTestError.notFound)].get() + } +} + +final class HomeFetchTodosUseCaseSpy: FetchTodosUseCase { + private(set) var queries = [TodoQuery]() + var page = TodoPage(items: [], nextCursor: nil) + + func execute(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { + queries.append(query) + return page + } +} + +enum HomeDevelopmentGoalTestError: Error { + case failed + case notFound +} diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift index 007850a7..83ab1904 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift @@ -20,6 +20,9 @@ struct HomeStoreTestAdapter { var preferences: [TodoCategoryItem] { store.state.preferences } var isTodoCategoryExpanded: Bool { store.state.isTodoCategoryExpanded } + var developmentGoalItems: [HomeDevelopmentGoalItem] { store.state.developmentGoalItems } + var hasDevelopmentGoalsLoaded: Bool { store.state.hasDevelopmentGoalsLoaded } + var hasDevelopmentGoalsLoadFailure: Bool { store.state.hasDevelopmentGoalsLoadFailure } var isNetworkConnected: Bool { store.state.isNetworkConnected } var showContentPicker: Bool { store.state.showContentPicker } var showCategoryManage: Bool { @@ -31,6 +34,11 @@ struct HomeStoreTestAdapter { fetchPreferencesUseCase: FetchTodoCategoryPreferencesUseCase = FetchTodoCategoryPreferencesUseCaseSpy(), updatePreferencesUseCase: UpdateTodoCategoryPreferencesUseCase = UpdateTodoCategoryPreferencesUseCaseSpy(), networkConnectivityUseCase: ObserveNetworkConnectivityUseCase = ObserveNetworkConnectivityUseCaseSpy(), + fetchDevelopmentGoalsUseCase: FetchDevelopmentGoalsUseCase = FetchDevelopmentGoalsUseCaseSpy(), + fetchDevelopmentRecordsUseCase: FetchDevelopmentRecordsUseCase = FetchDevelopmentRecordsUseCaseSpy(), + fetchDevelopmentRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase = + FetchDevelopmentRecordVersionUseCaseSpy(), + fetchTodosUseCase: FetchTodosUseCase = HomeFetchTodosUseCaseSpy(), trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = HomeTrackAnalyticsEventUseCaseSpy(), configureDependencies: ((inout DependencyValues) -> Void)? = nil ) { @@ -42,6 +50,10 @@ struct HomeStoreTestAdapter { $0.fetchTodoCategoryPreferencesUseCase = fetchPreferencesUseCase $0.homeUpdateTodoCategoryPreferencesUseCase = updatePreferencesUseCase $0.homeNetworkConnectivityUseCase = networkConnectivityUseCase + $0.homeFetchDevelopmentGoalsUseCase = fetchDevelopmentGoalsUseCase + $0.homeFetchDevelopmentRecordsUseCase = fetchDevelopmentRecordsUseCase + $0.homeFetchDevelopmentRecordVersionUseCase = fetchDevelopmentRecordVersionUseCase + $0.homeFetchTodosUseCase = fetchTodosUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase $0.continuousClock = clock configureDependencies?(&$0) diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift index fb3188a2..e01610be 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift @@ -6,6 +6,7 @@ // import Testing +import Core import Domain import PresentationShared @testable import HomeTab @@ -22,6 +23,106 @@ struct HomeFeatureTests { try await verifyHomeFetchData(adapter: adapter) } + @Test("HomeFeature fetchData는 진행 중 목표의 최신 확정 기록을 갱신한다") + func HomeFeature_fetchData는_진행_중_목표의_최신_확정_기록을_갱신한다() async throws { + let goal = try makeDevelopmentGoal(id: "goal", createdAt: 1) + let olderRecord = try makeDevelopmentRecord( + id: "older-record", + goalId: goal.id, + versionID: "older-version" + ) + let recentRecord = try makeDevelopmentRecord( + id: "recent-record", + goalId: goal.id, + versionID: "recent-version" + ) + let olderVersion = try makeDevelopmentRecordVersion( + id: "older-version", + recordID: olderRecord.id, + title: "Earlier Record", + confirmedAt: 1 + ) + let recentVersion = try makeDevelopmentRecordVersion( + id: "recent-version", + recordID: recentRecord.id, + title: "Recent Record", + confirmedAt: 2 + ) + let goalsSpy = FetchDevelopmentGoalsUseCaseSpy() + goalsSpy.result = .success([goal]) + let recordsSpy = FetchDevelopmentRecordsUseCaseSpy() + recordsSpy.resultByGoalID[goal.id] = .success([olderRecord, recentRecord]) + let versionsSpy = FetchDevelopmentRecordVersionUseCaseSpy() + versionsSpy.resultByRecordID[olderRecord.id] = .success(olderVersion) + versionsSpy.resultByRecordID[recentRecord.id] = .success(recentVersion) + let adapter = HomeStoreTestAdapter( + fetchDevelopmentGoalsUseCase: goalsSpy, + fetchDevelopmentRecordsUseCase: recordsSpy, + fetchDevelopmentRecordVersionUseCase: versionsSpy + ) + + await adapter.fetchData() + + await waitUntil { adapter.hasDevelopmentGoalsLoaded } + + #expect(goalsSpy.queries == [.init(status: .inProgress)]) + #expect(adapter.developmentGoalItems.map(\.id) == [goal.id]) + #expect(adapter.developmentGoalItems.first?.recentRecord == recentVersion) + } + + @Test("HomeFeature fetchData는 연결 Todo 완료 수로 목표 진행률을 계산한다") + func HomeFeature_fetchData는_연결_Todo_완료_수로_목표_진행률을_계산한다() async throws { + let goal = try makeDevelopmentGoal(id: "goal", createdAt: 1) + let todosSpy = HomeFetchTodosUseCaseSpy() + todosSpy.page = TodoPage( + items: [ + makeHomeTodo(id: "completed", goalID: goal.id, isCompleted: true), + makeHomeTodo(id: "incomplete", goalID: goal.id, isCompleted: false), + makeHomeTodo(id: "other", goalID: "other-goal", isCompleted: true) + ], + nextCursor: nil + ) + let goalsSpy = FetchDevelopmentGoalsUseCaseSpy() + goalsSpy.result = .success([goal]) + let adapter = HomeStoreTestAdapter( + fetchDevelopmentGoalsUseCase: goalsSpy, + fetchTodosUseCase: todosSpy + ) + + await adapter.fetchData() + + await waitUntil { adapter.hasDevelopmentGoalsLoaded } + + #expect(adapter.developmentGoalItems.first?.todoProgress == .init(completedCount: 1, totalCount: 2)) + #expect(todosSpy.queries == [ + TodoQuery( + sortTarget: .updatedAt, + sortOrder: .latest, + pageSize: 100, + fetchAllPages: true + ) + ]) + } + + @Test("HomeFeature fetchData 실패 뒤 재시도는 진행 중 목표를 갱신한다") + func HomeFeature_fetchData_실패_뒤_재시도는_진행_중_목표를_갱신한다() async throws { + let goalsSpy = FetchDevelopmentGoalsUseCaseSpy() + goalsSpy.result = .failure(HomeDevelopmentGoalTestError.failed) + let adapter = HomeStoreTestAdapter(fetchDevelopmentGoalsUseCase: goalsSpy) + + await adapter.fetchData() + + await waitUntil { adapter.hasDevelopmentGoalsLoadFailure } + #expect(!adapter.hasDevelopmentGoalsLoaded) + + goalsSpy.result = .success([]) + await adapter.fetchData() + + await waitUntil { adapter.hasDevelopmentGoalsLoaded } + #expect(adapter.developmentGoalItems.isEmpty) + #expect(!adapter.hasDevelopmentGoalsLoadFailure) + } + @Test("HomeFeature tapTodoCategory는 editor를 지연 표시한다") func HomeFeature_tapTodoCategory는_editor를_지연_표시한다() async throws { let adapter = HomeStoreTestAdapter() diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index aede72b2..3d98961c 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -424,6 +424,97 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 목표" } } } }, + "home_development_summary_eyebrow" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Ongoing Development" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "이어가는 개발" } } + } + }, + "home_development_summary_empty_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Create a development goal to start recording your progress." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 목표를 만들고 개발 과정을 기록해보세요." } } + } + }, + "home_development_summary_recent_record" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Latest Record" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "최근 기록" } } + } + }, + "home_development_summary_title_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld goals are in progress" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중인 목표가 %lld개 있어요" } } + } + }, + "home_development_goal_count_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld in progress" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중 %lld개" } } + } + }, + "home_development_goal_create" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "New Goal" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 목표" } } + } + }, + "home_development_goal_empty_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Create a development goal to start recording your progress." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 목표를 만들고 개발 과정을 기록해보세요." } } + } + }, + "home_development_goal_empty_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No goals in progress" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중인 목표가 없어요" } } + } + }, + "home_development_goal_recent_record" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Latest Record" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "최근 기록" } } + } + }, + "home_development_goal_section_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Goals in Progress" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중인 개발" } } + } + }, + "home_development_goal_todo_empty" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No linked Todos" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "연결된 Todo 없음" } } + } + }, + "home_development_goal_todo_progress_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld of %lld Todos complete" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo %lld/%lld 완료" } } + } + }, + "home_development_goal_todo_progress_percent_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld%%" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "%lld%%" } } + } + }, "development_goal_create_description_label" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift index 67b3a63f..695eb114 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorFeature.swift @@ -48,6 +48,7 @@ public struct TodoEditorFeature { let number: Int? let createdAt: Date? let deletedAt: Date? + let goalId: String? let originalDraft: TodoDraft? var isValidToSave: Bool { @@ -86,6 +87,7 @@ public struct TodoEditorFeature { self.number = nil self.createdAt = nil self.deletedAt = nil + self.goalId = nil self.originalDraft = nil self.category = TodoCategoryItem(from: category) self.categories = [TodoCategoryItem(from: category)] @@ -97,6 +99,7 @@ public struct TodoEditorFeature { self.number = todo.number self.createdAt = todo.createdAt self.deletedAt = todo.deletedAt + self.goalId = todo.goalId self.originalDraft = TodoDraft(todo: todo) self.isCompleted = todo.isCompleted self.completedAt = todo.completedAt @@ -368,7 +371,8 @@ private extension TodoEditorFeature.State { completedAt: completedAt, dueDate: dueDate, tags: Array(tags), - category: category.category + category: category.category, + goalId: goalId ) } @@ -388,7 +392,8 @@ private extension TodoEditorFeature.State { deletedAt: deletedAt, dueDate: dueDate, tags: Array(tags), - category: category.category + category: category.category, + goalId: goalId ) } } diff --git a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift index c154b912..59a372cf 100644 --- a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift +++ b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTestDoubles.swift @@ -330,7 +330,8 @@ func makeTodoEditorTodo( deletedAt: Date? = nil, dueDate: Date? = nil, tags: [String] = [], - category: TodoCategory = .system(.doc) + category: TodoCategory = .system(.doc), + goalId: String? = nil ) -> Todo { Todo( id: id, @@ -346,7 +347,8 @@ func makeTodoEditorTodo( deletedAt: deletedAt, dueDate: dueDate, tags: tags, - category: category + category: category, + goalId: goalId ) } diff --git a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift index 6a2c8535..c5245f32 100644 --- a/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift +++ b/Application/Presentation/PresentationShared/Tests/Todo/TodoEditorFeatureTests.swift @@ -288,6 +288,28 @@ struct TodoEditorFeatureTests { #expect(adapter.saveResult == .updated(updated)) } + @Test("연결된 Todo를 완료 처리해도 개발 목표 연결을 유지한다") + func 연결된_Todo를_완료_처리해도_개발_목표_연결을_유지한다() async throws { + let upsertSpy = TodoEditorUpsertTodoUseCaseSpy() + upsertSpy.shouldSuspend = true + let todo = makeTodoEditorTodo(goalId: "goal-1") + let adapter = TodoEditorStoreTestAdapter(todo: todo, upsertTodoUseCase: upsertSpy) + + await adapter.setCompleted(true) + await adapter.upsertTodo() + + let updated = try #require(upsertSpy.todos.first) + + #expect(updated.goalId == "goal-1") + #expect(updated.isCompleted) + #expect(updated.completedAt == todoEditorNow) + + upsertSpy.resume() + await adapter.receiveUpdateSucceeded(updated) + await adapter.receiveUpdatedDelegate(updated) + await adapter.drainReceivedActions() + } + @Test("저장 실패는 공통 에러 알림을 표시하고 로딩을 해제한다") func 저장_실패는_공통_에러_알림을_표시하고_로딩을_해제한다() async { let upsertSpy = TodoEditorUpsertTodoUseCaseSpy() diff --git a/Application/Presentation/Project.swift b/Application/Presentation/Project.swift index bc587cce..779a86fb 100644 --- a/Application/Presentation/Project.swift +++ b/Application/Presentation/Project.swift @@ -151,6 +151,7 @@ let project = Project( dependencies: [ .project(target: "Domain", path: "../Domain"), .project(target: "Core", path: "../Core"), + .target(name: "Development"), .target(name: "PresentationShared") ], settings: frameworkBuildSettings @@ -170,6 +171,7 @@ let project = Project( ], dependencies: [ .target(name: "HomeTab"), + .target(name: "Development"), .target(name: "PresentationShared"), thirdPartyDependency, ],