diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index 7f787158..6929fa46 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -12,6 +12,7 @@ extension AppGraph { prepareDependencies { dependencies in prepareEntryDependencies(&dependencies) prepareDevelopmentDependencies(&dependencies) + prepareDevelopmentTabDependencies(&dependencies) prepareTodoDependencies(&dependencies) prepareHomeDependencies(&dependencies) prepareTodayDependencies(&dependencies) @@ -23,7 +24,7 @@ extension AppGraph { private extension AppGraph { func prepareDevelopmentDependencies(_ dependencies: inout DependencyValues) { - DevelopmentDependencyPreparation.prepareGoal( + DevelopmentTabPresentationDependencyPreparation.prepareGoal( &dependencies, createGoalUseCase: developmentGraphSet .developmentGoalUseCaseGraph @@ -35,7 +36,7 @@ private extension AppGraph { .developmentGoalUseCaseGraph .updateDevelopmentGoalStatusUseCase ) - DevelopmentDependencyPreparation.prepareQuery( + DevelopmentTabPresentationDependencyPreparation.prepareQuery( &dependencies, fetchRecordsUseCase: developmentGraphSet .developmentRecordQueryUseCaseGraph @@ -47,7 +48,7 @@ private extension AppGraph { .developmentRecordQueryUseCaseGraph .fetchDevelopmentRecordVersionUseCase ) - DevelopmentDependencyPreparation.prepareMutation( + DevelopmentTabPresentationDependencyPreparation.prepareMutation( &dependencies, createRecordUseCase: developmentGraphSet .developmentRecordMutationUseCaseGraph @@ -62,13 +63,25 @@ private extension AppGraph { .developmentRecordMutationUseCaseGraph .restoreDevelopmentRecordUseCase ) - DevelopmentDependencyPreparation.prepareTodo( + DevelopmentTabPresentationDependencyPreparation.prepareTodo( &dependencies, fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase, updateTodoGoalUseCase: todoGraphSet.todoGoalUseCaseGraph.updateTodoGoalUseCase ) } + func prepareDevelopmentTabDependencies(_ dependencies: inout DependencyValues) { + DevelopmentTabPresentationDependencyPreparation.prepareGoalList( + &dependencies, + goalsUseCase: developmentGraphSet + .developmentGoalUseCaseGraph + .fetchDevelopmentGoalsUseCase, + recentRecordUseCase: developmentGraphSet + .developmentRecordQueryUseCaseGraph + .fetchRecentDevelopmentRecordUseCase + ) + } + func prepareEntryDependencies(_ dependencies: inout DependencyValues) { PresentationDependencyPreparation.prepareRoot( &dependencies, @@ -133,12 +146,9 @@ private extension AppGraph { fetchGoalsUseCase: developmentGraphSet .developmentGoalUseCaseGraph .fetchDevelopmentGoalsUseCase, - fetchRecordsUseCase: developmentGraphSet - .developmentRecordQueryUseCaseGraph - .fetchDevelopmentRecordsUseCase, - fetchRecordVersionUseCase: developmentGraphSet + recentRecordUseCase: developmentGraphSet .developmentRecordQueryUseCaseGraph - .fetchDevelopmentRecordVersionUseCase, + .fetchRecentDevelopmentRecordUseCase, fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase ) HomePresentationDependencyPreparation.prepareTodoCategory( diff --git a/Application/Domain/Sources/Graph/DevelopmentRecordQueryUseCaseGraph.swift b/Application/Domain/Sources/Graph/DevelopmentRecordQueryUseCaseGraph.swift index 06e4a972..495f3785 100644 --- a/Application/Domain/Sources/Graph/DevelopmentRecordQueryUseCaseGraph.swift +++ b/Application/Domain/Sources/Graph/DevelopmentRecordQueryUseCaseGraph.swift @@ -22,6 +22,11 @@ public final class DevelopmentRecordQueryUseCaseGraph { FetchDevelopmentRecordsUseCaseImpl(input.repository) } + @Provide + private func makeFetchRecentDevelopmentRecordUseCase() -> FetchRecentDevelopmentRecordUseCase { + FetchRecentDevelopmentRecordUseCaseImpl(input.repository) + } + @Provide private func makeFetchDevelopmentRecordHistoryUseCase() -> FetchDevelopmentRecordHistoryUseCase { FetchDevelopmentRecordHistoryUseCaseImpl(input.repository) diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchRecentDevelopmentRecordUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchRecentDevelopmentRecordUseCase.swift new file mode 100644 index 00000000..5480782f --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchRecentDevelopmentRecordUseCase.swift @@ -0,0 +1,10 @@ +// +// FetchRecentDevelopmentRecordUseCase.swift +// Domain +// +// Created by opfic on 10/5/26. +// + +public protocol FetchRecentDevelopmentRecordUseCase { + func execute(goalId: String) async throws -> DevelopmentRecord.Version? +} diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchRecentDevelopmentRecordUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchRecentDevelopmentRecordUseCaseImpl.swift new file mode 100644 index 00000000..72b311b7 --- /dev/null +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Fetch/FetchRecentDevelopmentRecordUseCaseImpl.swift @@ -0,0 +1,38 @@ +// +// FetchRecentDevelopmentRecordUseCaseImpl.swift +// Domain +// +// Created by opfic on 10/5/26. +// + +public final class FetchRecentDevelopmentRecordUseCaseImpl: FetchRecentDevelopmentRecordUseCase { + private let repository: DevelopmentRecordRepository + + init(_ repository: DevelopmentRecordRepository) { + self.repository = repository + } + + public func execute(goalId: String) async throws -> DevelopmentRecord.Version? { + let records = try await repository.fetchRecords(goalId: goalId) + 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 { [repository] in + try await repository.fetchVersion( + goalId: goalId, + recordId: record.id, + versionId: currentVersion.id + ) + } + } + + for try await version in group { + versions.append(version) + } + } + + return versions.max { $0.confirmedAt < $1.confirmedAt } + } +} diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/FetchRecentDevelopmentRecordUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/FetchRecentDevelopmentRecordUseCaseTests.swift new file mode 100644 index 00000000..308615fe --- /dev/null +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/FetchRecentDevelopmentRecordUseCaseTests.swift @@ -0,0 +1,93 @@ +// +// FetchRecentDevelopmentRecordUseCaseTests.swift +// DomainTests +// +// Created by opfic on 10/5/26. +// + +import Foundation +import Testing +@testable import Domain + +struct FetchRecentDevelopmentRecordUseCaseTests { + @Test("각 기록의 현재 확정 버전 중 확정 시각이 가장 늦은 버전을 반환한다") + func latestConfirmedVersion() async throws { + let olderRecord = try makeRecentRecordFixture(id: "older-record", number: 2, confirmedAt: 300, createdAt: 0) + let newerRecord = try makeRecentRecordFixture(id: "newer-record", number: 1, confirmedAt: 200, createdAt: 100) + let spy = DevelopmentRecordRepositorySpy( + records: [newerRecord.record, olderRecord.record], + versions: [newerRecord.version, olderRecord.version] + ) + let useCase = FetchRecentDevelopmentRecordUseCaseImpl(spy) + + let version = try await useCase.execute(goalId: "goal-1") + + #expect(version == olderRecord.version) + #expect(await spy.recordQueries() == ["goal-1"]) + #expect(await spy.exactVersionQueries().sorted { $0.recordId < $1.recordId } == [ + .init(goalId: "goal-1", recordId: "newer-record", versionId: newerRecord.version.id), + .init(goalId: "goal-1", recordId: "older-record", versionId: olderRecord.version.id) + ]) + } + + @Test("현재 확정 버전이 없는 기록은 건너뛴다") + func skipsDrafts() async throws { + let record = try makeDevelopmentRecordInitialDraft(updatedAt: .now) + let confirmedRecord = try makeRecentRecordFixture(id: "confirmed", number: 1, confirmedAt: 100, createdAt: 0) + let spy = DevelopmentRecordRepositorySpy( + records: [record, confirmedRecord.record], + versions: [confirmedRecord.version] + ) + let useCase = FetchRecentDevelopmentRecordUseCaseImpl(spy) + + let version = try await useCase.execute(goalId: "goal-1") + + #expect(version == confirmedRecord.version) + #expect(await spy.exactVersionQueries() == [ + .init(goalId: "goal-1", recordId: "confirmed", versionId: confirmedRecord.version.id) + ]) + } + + @Test("버전 조회가 실패하면 성공한 다른 버전이 있어도 오류를 그대로 던진다") + func versionFailure() async throws { + let record = try makeDevelopmentRecordConfirmed() + let confirmedRecord = try makeRecentRecordFixture(id: "confirmed", number: 1, confirmedAt: 100, createdAt: 0) + let spy = DevelopmentRecordRepositorySpy( + records: [record, confirmedRecord.record], + versions: [confirmedRecord.version] + ) + let useCase = FetchRecentDevelopmentRecordUseCaseImpl(spy) + + await #expect(throws: DevelopmentRecordRepositorySpyError.self) { + try await useCase.execute(goalId: "goal-1") + } + } + + +} + +private func makeRecentRecordFixture( + id: String, + number: Int, + confirmedAt: TimeInterval, + createdAt: TimeInterval +) throws -> (record: DevelopmentRecord, version: DevelopmentRecord.Version) { + let version = try DevelopmentRecord.Version( + id: "\(id)-v\(number)", + recordId: id, + number: number, + title: "기록", + markdownContent: "본문", + kind: number == 1 ? .initial : .correction, + sourceVersionId: number == 1 ? nil : "\(id)-v\(number - 1)", + confirmedAt: Date(timeIntervalSince1970: confirmedAt) + ) + let record = try DevelopmentRecord( + id: id, + goalId: "goal-1", + currentVersion: .init(id: version.id, number: number), + draft: nil, + createdAt: Date(timeIntervalSince1970: createdAt) + ) + return (record, version) +} diff --git a/Application/Presentation/Development/Sources/.swiftlint.yml b/Application/Presentation/DevelopmentTab/Sources/.swiftlint.yml similarity index 100% rename from Application/Presentation/Development/Sources/.swiftlint.yml rename to Application/Presentation/DevelopmentTab/Sources/.swiftlint.yml diff --git a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift b/Application/Presentation/DevelopmentTab/Sources/Dependency/DevelopmentTabDependencyPreparation.swift similarity index 93% rename from Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift rename to Application/Presentation/DevelopmentTab/Sources/Dependency/DevelopmentTabDependencyPreparation.swift index ee4e6389..99c1bcf5 100644 --- a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift +++ b/Application/Presentation/DevelopmentTab/Sources/Dependency/DevelopmentTabDependencyPreparation.swift @@ -1,14 +1,23 @@ // -// DevelopmentDependencyPreparation.swift -// Development +// DevelopmentTabDependencyPreparation.swift +// DevelopmentTab // -// Created by opfic on 9/13/26. +// Created by opfic on 10/5/26. // import Domain import PresentationShared -public enum DevelopmentDependencyPreparation { +public enum DevelopmentTabDependencyPreparation { + public static func prepareGoalList( + _ dependencies: inout DependencyValues, + goalsUseCase: FetchDevelopmentGoalsUseCase, + recentRecordUseCase: FetchRecentDevelopmentRecordUseCase + ) { + dependencies.developmentTabFetchGoalsUseCase = goalsUseCase + dependencies.developmentTabFetchRecentRecordUseCase = recentRecordUseCase + } + public static func prepareGoal( _ dependencies: inout DependencyValues, createGoalUseCase: CreateDevelopmentGoalUseCase, diff --git a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateFeature.swift b/Application/Presentation/DevelopmentTab/Sources/Goal/Create/GoalCreateFeature.swift similarity index 100% rename from Application/Presentation/Development/Sources/Goal/Create/GoalCreateFeature.swift rename to Application/Presentation/DevelopmentTab/Sources/Goal/Create/GoalCreateFeature.swift diff --git a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateTodoSelectionFeature.swift b/Application/Presentation/DevelopmentTab/Sources/Goal/Create/GoalCreateTodoSelectionFeature.swift similarity index 100% rename from Application/Presentation/Development/Sources/Goal/Create/GoalCreateTodoSelectionFeature.swift rename to Application/Presentation/DevelopmentTab/Sources/Goal/Create/GoalCreateTodoSelectionFeature.swift diff --git a/Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift b/Application/Presentation/DevelopmentTab/Sources/Goal/Create/GoalCreateView.swift similarity index 100% rename from Application/Presentation/Development/Sources/Goal/Create/GoalCreateView.swift rename to Application/Presentation/DevelopmentTab/Sources/Goal/Create/GoalCreateView.swift diff --git a/Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift b/Application/Presentation/DevelopmentTab/Sources/Goal/GoalDescriptionCard.swift similarity index 100% rename from Application/Presentation/Development/Sources/Goal/GoalDescriptionCard.swift rename to Application/Presentation/DevelopmentTab/Sources/Goal/GoalDescriptionCard.swift diff --git a/Application/Presentation/Development/Sources/Goal/TodoSelection/GoalCreateTodoSelectionSheet.swift b/Application/Presentation/DevelopmentTab/Sources/Goal/TodoSelection/GoalCreateTodoSelectionSheet.swift similarity index 100% rename from Application/Presentation/Development/Sources/Goal/TodoSelection/GoalCreateTodoSelectionSheet.swift rename to Application/Presentation/DevelopmentTab/Sources/Goal/TodoSelection/GoalCreateTodoSelectionSheet.swift diff --git a/Application/Presentation/Development/Sources/Goal/TodoSelection/GoalTodoSelectionViews.swift b/Application/Presentation/DevelopmentTab/Sources/Goal/TodoSelection/GoalTodoSelectionViews.swift similarity index 100% rename from Application/Presentation/Development/Sources/Goal/TodoSelection/GoalTodoSelectionViews.swift rename to Application/Presentation/DevelopmentTab/Sources/Goal/TodoSelection/GoalTodoSelectionViews.swift diff --git a/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalCard.swift b/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalCard.swift new file mode 100644 index 00000000..fe408b99 --- /dev/null +++ b/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalCard.swift @@ -0,0 +1,67 @@ +// +// DevelopmentGoalCard.swift +// DevelopmentTab +// +// Created by opfic on 10/5/26. +// + +import Domain +import PresentationShared +import SwiftUI + +struct DevelopmentGoalCard: View { + let goal: DevelopmentGoal + let recentRecord: DevelopmentGoalListFeature.RecentRecord? + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + Text(goal.title) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.primary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + GoalStatusBadge(status: goal.status) + } + if !goal.description.isEmpty { + Text(goal.description) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + } + if case .loaded(let version?) = recentRecord { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "clock") + .foregroundStyle(Color.textTertiary) + VStack(alignment: .leading, spacing: 6) { + Text(String.localizedStringWithFormat( + String(localized: "development_goals_recent_record_format", bundle: PresentationResources.bundle), + version.title + )) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + .lineLimit(1) + TimelineView(.periodic(from: .now, by: 1.0)) { context in + Text(String.localizedStringWithFormat( + String(localized: "development_goals_recent_record_date_format", bundle: PresentationResources.bundle), + RelativeTime.text(from: version.confirmedAt, now: context.date), + version.confirmedAt.formatted(.dateTime.month().day().hour().minute()), + RecordPresentation.versionLabel(version.number) + )) + .font(.caption) + .foregroundStyle(Color.textTertiary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(Color.appBackground, in: .rect(cornerRadius: 12)) + } + } + .padding(18) + .background(Color.surface, in: .rect(cornerRadius: 24)) + .contentShape(.rect) + } +} diff --git a/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalListFeature+Dependencies.swift b/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalListFeature+Dependencies.swift new file mode 100644 index 00000000..fc6c7ad1 --- /dev/null +++ b/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalListFeature+Dependencies.swift @@ -0,0 +1,33 @@ +// +// DevelopmentGoalListFeature+Dependencies.swift +// DevelopmentTab +// +// Created by opfic on 10/5/26. +// + +import Domain +import PresentationShared + +extension DependencyValues { + var developmentTabFetchGoalsUseCase: FetchDevelopmentGoalsUseCase { + get { self[FetchDevelopmentGoalsUseCaseKey.self] } + set { self[FetchDevelopmentGoalsUseCaseKey.self] = newValue } + } + + var developmentTabFetchRecentRecordUseCase: FetchRecentDevelopmentRecordUseCase { + get { self[FetchRecentDevelopmentRecordUseCaseKey.self] } + set { self[FetchRecentDevelopmentRecordUseCaseKey.self] = newValue } + } +} + +private enum FetchDevelopmentGoalsUseCaseKey: DependencyKey { + static var liveValue: FetchDevelopmentGoalsUseCase { + preconditionFailure("FetchDevelopmentGoalsUseCase must be provided.") + } +} + +private enum FetchRecentDevelopmentRecordUseCaseKey: DependencyKey { + static var liveValue: FetchRecentDevelopmentRecordUseCase { + preconditionFailure("FetchRecentDevelopmentRecordUseCase must be provided.") + } +} diff --git a/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalListFeature.swift b/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalListFeature.swift new file mode 100644 index 00000000..5160d4d0 --- /dev/null +++ b/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalListFeature.swift @@ -0,0 +1,170 @@ +// +// DevelopmentGoalListFeature.swift +// DevelopmentTab +// +// Created by opfic on 10/5/26. +// + +import Domain +import Foundation +import PresentationShared + +@Reducer +struct DevelopmentGoalListFeature { + @ObservableState + struct State: Equatable { + @Presents var sheet: SheetState? + var goals = [DevelopmentGoal]() + var selectedStatus = DevelopmentGoal.Status.inProgress + var recentRecords = [String: RecentRecord]() + var visibleGoalIDs = Set() + var hasLoaded = false + var isLoading = false + var hasLoadFailure = false + var requestID = 0 + var recordRevision = 0 + + var filteredGoals: [DevelopmentGoal] { + goals.filter { $0.status == selectedStatus } + } + + func count(for status: DevelopmentGoal.Status) -> Int { + goals.count { $0.status == status } + } + } + + @ObservableState + @CasePathable + enum SheetState: Equatable { + case create + case detail(String) + } + + enum RecentRecord: Equatable { + case loading + case loaded(DevelopmentRecord.Version?) + case failed + } + + enum Action: Equatable { + case sheet(PresentationAction) + case selected + case refresh + case retry + case selectStatus(DevelopmentGoal.Status) + case showCreate + case showDetail(String) + case goalCreated(DevelopmentGoal) + case cardAppeared(String) + case cardDisappeared(String) + case goalsLoaded([DevelopmentGoal], requestID: Int) + case goalsFailed(requestID: Int) + case recentRecordLoaded(String, RecentRecord, revision: Int) + } + + @Dependency(\.developmentTabFetchGoalsUseCase) private var goalsUseCase + @Dependency(\.developmentTabFetchRecentRecordUseCase) private var recentRecordUseCase + + var body: some ReducerOf { + Reduce { state, action in + reduce(action, state: &state) + } + .ifLet(\.$sheet, action: \.sheet) { + EmptyReducer() + } + } + + private func reduce(_ action: Action, state: inout State) -> Effect { + switch action { + case .selected, .retry: + return fetchGoals(state: &state, showsLoading: !state.hasLoaded) + case .refresh: + return fetchGoals(state: &state, showsLoading: false) + case .sheet(.dismiss): + state.sheet = nil + return fetchGoals(state: &state, showsLoading: false) + case .selectStatus(let status): + state.selectedStatus = status + case .showCreate: + state.sheet = .create + case .showDetail(let goalID): + state.sheet = .detail(goalID) + case .sheet: + break + case .goalCreated(let goal): + state.requestID += 1 + state.isLoading = false + state.hasLoadFailure = false + state.hasLoaded = true + state.selectedStatus = .inProgress + state.goals.removeAll { $0.id == goal.id } + state.goals.append(goal) + state.goals = Self.sortedGoals(state.goals) + case .cardAppeared(let goalID): + state.visibleGoalIDs.insert(goalID) + guard state.recentRecords[goalID] == nil, + state.goals.contains(where: { $0.id == goalID }) else { break } + state.recentRecords[goalID] = .loading + return fetchRecentRecord(goalID, revision: state.recordRevision) + case .cardDisappeared(let goalID): + state.visibleGoalIDs.remove(goalID) + case .goalsLoaded(let goals, let requestID): + guard requestID == state.requestID else { break } + state.goals = Self.sortedGoals(goals) + state.hasLoaded = true + state.isLoading = false + state.hasLoadFailure = false + state.recordRevision += 1 + state.recentRecords.removeAll() + state.visibleGoalIDs.formIntersection(goals.map(\.id)) + let effects = state.filteredGoals.compactMap { goal -> Effect? in + guard state.visibleGoalIDs.contains(goal.id) else { return nil } + state.recentRecords[goal.id] = .loading + return fetchRecentRecord(goal.id, revision: state.recordRevision) + } + return .merge(effects) + case .goalsFailed(let requestID): + guard requestID == state.requestID else { break } + state.isLoading = false + state.hasLoadFailure = true + case .recentRecordLoaded(let goalID, let recentRecord, let revision): + guard revision == state.recordRevision, + state.goals.contains(where: { $0.id == goalID }) else { break } + state.recentRecords[goalID] = recentRecord + } + return .none + } + + private func fetchGoals(state: inout State, showsLoading: Bool) -> Effect { + state.requestID += 1 + state.isLoading = showsLoading + state.hasLoadFailure = false + let requestID = state.requestID + return .run { [goalsUseCase] send in + do { + let goals = try await goalsUseCase.execute(.init(status: nil)) + await send(.goalsLoaded(goals, requestID: requestID)) + } catch { + await send(.goalsFailed(requestID: requestID)) + } + } + } + + private func fetchRecentRecord(_ goalID: String, revision: Int) -> Effect { + .run { [recentRecordUseCase] send in + do { + let version = try await recentRecordUseCase.execute(goalId: goalID) + await send(.recentRecordLoaded(goalID, .loaded(version), revision: revision)) + } catch { + await send(.recentRecordLoaded(goalID, .failed, revision: revision)) + } + } + } + + private static func sortedGoals(_ goals: [DevelopmentGoal]) -> [DevelopmentGoal] { + goals.sorted { + if $0.createdAt == $1.createdAt { return $0.id < $1.id } + return $0.createdAt < $1.createdAt + } + } +} diff --git a/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalListView.swift b/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalListView.swift new file mode 100644 index 00000000..83505fa4 --- /dev/null +++ b/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalListView.swift @@ -0,0 +1,163 @@ +// +// DevelopmentGoalListView.swift +// DevelopmentTab +// +// Created by opfic on 10/5/26. +// + +import Domain +import PresentationShared +import SwiftUI + +public struct DevelopmentGoalListView: View { + @State private var store: StoreOf + private let isSelected: Bool + + public init(isSelected: Bool = true) { + self._store = State(initialValue: Store(initialState: DevelopmentGoalListFeature.State()) { + DevelopmentGoalListFeature() + }) + self.isSelected = isSelected + } + + public var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + if store.hasLoadFailure { + loadFailure + } + if store.hasLoaded { + if store.filteredGoals.isEmpty { + emptyState + } else { + ForEach(store.filteredGoals, id: \.id) { goal in + Button { + store.send(.showDetail(goal.id)) + } label: { + DevelopmentGoalCard( + goal: goal, + recentRecord: store.recentRecords[goal.id] + ) + } + .buttonStyle(.plain) + .onAppear { + store.send(.cardAppeared(goal.id)) + } + .onDisappear { + store.send(.cardDisappeared(goal.id)) + } + } + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 24) + } + .refreshable { + await store.send(.refresh).finish() + } + .safeAreaInset(edge: .top, spacing: 0) { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("nav_development_goals", bundle: PresentationResources.bundle) + .font(.largeTitle.bold()) + Spacer() + Button { + store.send(.showCreate) + } label: { + Image(systemName: "plus") + } + .topBarButtonStyle() + } + statusTabs + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + .background(Color.appBackground) + .toolbarBackground(Color.appBackground) + } + .background(Color.appBackground.ignoresSafeArea()) + .toolbarVisibility(.hidden, for: .navigationBar) + .sheet( + item: $store.scope(state: \.sheet, action: \.sheet) + .activePresentation(when: isSelected) + ) { sheetStore in + switch sheetStore.state { + case .create: + GoalCreateView { goal in + store.send(.goalCreated(goal)) + } + case .detail(let goalID): + GoalDetailView(goalId: goalID) + } + } + } + .onChange(of: isSelected, initial: true) { _, isSelected in + guard isSelected else { return } + store.send(.selected) + } + .overlay { + if store.isLoading { + LoadingView() + } + } + } + + private var statusTabs: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(DevelopmentGoal.Status.tabs, id: \.self) { status in + let isSelected = store.selectedStatus == status + Button { + store.send(.selectStatus(status)) + } label: { + HStack(spacing: 4) { + Text(status.title) + Text(store.state.count(for: status).formatted()) + .opacity(0.6) + } + .font(.callout) + .foregroundStyle(isSelected ? Color.onPrimaryContainer : Color.onControlBackground) + .adaptiveButtonStyle(color: isSelected ? .primaryContainer : .controlBackground) + } + } + } + } + .scrollIndicators(.hidden) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, -16) + .contentMargins(.horizontal, 16, for: .scrollContent) + } + + private var loadFailure: some View { + 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)) { + store.send(.retry) + } + .buttonStyle(.borderedProminent) + } + } + + private var emptyState: some View { + ContentUnavailableView { + Label(store.selectedStatus.emptyTitle, systemImage: "flag.checkered") + } actions: { + if store.selectedStatus == .inProgress { + Button { + store.send(.showCreate) + } label: { + Text("development_goals_create", bundle: PresentationResources.bundle) + } + .buttonStyle(.borderedProminent) + } + } + } +} diff --git a/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalStatus+Presentation.swift b/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalStatus+Presentation.swift new file mode 100644 index 00000000..41d4d78f --- /dev/null +++ b/Application/Presentation/DevelopmentTab/Sources/List/DevelopmentGoalStatus+Presentation.swift @@ -0,0 +1,25 @@ +// +// DevelopmentGoalStatus+Presentation.swift +// DevelopmentTab +// +// Created by opfic on 10/5/26. +// + +import Domain +import PresentationShared +import SwiftUI + +extension DevelopmentGoal.Status { + static let tabs = [Self.inProgress, .completed, .archived] + + var emptyTitle: String { + switch self { + case .inProgress: + String(localized: "development_goals_empty_in_progress", bundle: PresentationResources.bundle) + case .completed: + String(localized: "development_goals_empty_completed", bundle: PresentationResources.bundle) + case .archived: + String(localized: "development_goals_empty_archived", bundle: PresentationResources.bundle) + } + } +} diff --git a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift b/Application/Presentation/DevelopmentTab/Sources/Record/Detail/RecordDetailFeature.swift similarity index 100% rename from Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/Detail/RecordDetailFeature.swift diff --git a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift b/Application/Presentation/DevelopmentTab/Sources/Record/Detail/RecordDetailView.swift similarity index 100% rename from Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/Detail/RecordDetailView.swift diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift b/Application/Presentation/DevelopmentTab/Sources/Record/Editor/RecordEditorFeature.swift similarity index 100% rename from Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/Editor/RecordEditorFeature.swift diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift b/Application/Presentation/DevelopmentTab/Sources/Record/Editor/RecordEditorView.swift similarity index 100% rename from Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/Editor/RecordEditorView.swift diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature+Support.swift b/Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalDetailFeature+Support.swift similarity index 100% rename from Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature+Support.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalDetailFeature+Support.swift diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift b/Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalDetailFeature.swift similarity index 100% rename from Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailFeature.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalDetailFeature.swift diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailRecordActions.swift b/Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalDetailRecordActions.swift similarity index 100% rename from Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailRecordActions.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalDetailRecordActions.swift diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalDetailView.swift similarity index 100% rename from Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalDetailView.swift diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift b/Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalDetailViewSupport.swift similarity index 90% rename from Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalDetailViewSupport.swift index 796e40e1..17484d52 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailViewSupport.swift +++ b/Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalDetailViewSupport.swift @@ -89,7 +89,7 @@ struct GoalStatusBadge: View { let status: DevelopmentGoal.Status var body: some View { - Label(title, systemImage: systemImage) + Label(status.title, systemImage: systemImage) .font(.caption.weight(.semibold)) .foregroundStyle(foreground) .padding(.horizontal, 11) @@ -97,17 +97,6 @@ struct GoalStatusBadge: View { .background(background, in: .capsule) } - private var title: String { - switch status { - case .inProgress: - RecordPresentation.text("development_goal_status_in_progress") - case .completed: - RecordPresentation.text("development_goal_status_completed") - case .archived: - RecordPresentation.text("development_goal_status_archived") - } - } - private var systemImage: String { switch status { case .inProgress: diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalLinkedTodoCard.swift b/Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalLinkedTodoCard.swift similarity index 100% rename from Application/Presentation/Development/Sources/Record/GoalDetail/GoalLinkedTodoCard.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalLinkedTodoCard.swift diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkFeature.swift b/Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalTodoLinkFeature.swift similarity index 100% rename from Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkFeature.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalTodoLinkFeature.swift diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift b/Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift similarity index 100% rename from Application/Presentation/Development/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/GoalDetail/GoalTodoLinkSheet.swift diff --git a/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift b/Application/Presentation/DevelopmentTab/Sources/Record/History/RecordVersionDetailView.swift similarity index 100% rename from Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/History/RecordVersionDetailView.swift diff --git a/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift b/Application/Presentation/DevelopmentTab/Sources/Record/History/RecordVersionHistoryView.swift similarity index 100% rename from Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/History/RecordVersionHistoryView.swift diff --git a/Application/Presentation/Development/Sources/Record/RecordPresentation.swift b/Application/Presentation/DevelopmentTab/Sources/Record/RecordPresentation.swift similarity index 100% rename from Application/Presentation/Development/Sources/Record/RecordPresentation.swift rename to Application/Presentation/DevelopmentTab/Sources/Record/RecordPresentation.swift diff --git a/Application/Presentation/Development/Tests/.swiftlint.yml b/Application/Presentation/DevelopmentTab/Tests/.swiftlint.yml similarity index 100% rename from Application/Presentation/Development/Tests/.swiftlint.yml rename to Application/Presentation/DevelopmentTab/Tests/.swiftlint.yml diff --git a/Application/Presentation/DevelopmentTab/Tests/DevelopmentGoalListFeatureTests.swift b/Application/Presentation/DevelopmentTab/Tests/DevelopmentGoalListFeatureTests.swift new file mode 100644 index 00000000..174e31ad --- /dev/null +++ b/Application/Presentation/DevelopmentTab/Tests/DevelopmentGoalListFeatureTests.swift @@ -0,0 +1,92 @@ +// +// DevelopmentGoalListFeatureTests.swift +// DevelopmentTabTests +// +// Created by opfic on 10/5/26. +// + +import Testing +@testable import DevelopmentTab + +@MainActor +struct DevelopmentGoalListFeatureTests { + @Test("최근 기록은 카드가 나타날 때 조회하고 조회 중이거나 캐시가 있으면 중복 조회하지 않는다") + func lazyRecentRecordAndCache() async throws { + let goal = try makeDevelopmentGoal(id: "goal") + let version = try makeDevelopmentRecordVersion(recordID: "record") + let adapter = StoreTestAdapter( + goals: [goal], + recentRecords: ["goal": version] + ) + await adapter.load(.selected) + #expect(await adapter.recentRecordSpy.goalIDs.isEmpty) + + await adapter.recentRecordSpy.suspend() + await adapter.send(.cardAppeared("goal")) + #expect(adapter.state.recentRecords["goal"] == .loading) + await adapter.send(.cardAppeared("goal")) + #expect(adapter.state.recentRecords["goal"] == .loading) + await adapter.recentRecordSpy.resume() + await adapter.settle() + await adapter.send(.cardDisappeared("goal")) + await adapter.load(.cardAppeared("goal")) + #expect(adapter.state.recentRecords["goal"] == .loaded(version)) + #expect(await adapter.recentRecordSpy.goalIDs == ["goal"]) + } + + @Test("최근 기록 조회 실패는 해당 카드에만 반영하며 갱신하면 다시 조회한다") + func recordFailurePreservesList() async throws { + let goals = try [makeDevelopmentGoal(id: "a"), makeDevelopmentGoal(id: "b")] + let adapter = StoreTestAdapter(goals: goals) + await adapter.recentRecordSpy.setFailure(goalID: "a", fails: true) + await adapter.load(.selected) + await adapter.load(.cardAppeared("a")) + await adapter.load(.cardAppeared("b")) + #expect(adapter.state.goals == goals) + #expect(!adapter.state.hasLoadFailure) + #expect(adapter.state.recentRecords["a"] == .failed) + #expect(adapter.state.recentRecords["b"] == .loaded(nil)) + await adapter.load(.cardAppeared("a")) + #expect(await adapter.recentRecordSpy.goalIDs == ["a", "b"]) + + await adapter.recentRecordSpy.setFailure(goalID: "a", fails: false) + await adapter.load(.refresh) + #expect(adapter.state.recentRecords["a"] == .loaded(nil)) + #expect(await adapter.recentRecordSpy.goalIDs.count == 4) + } + + @Test("목표 상태 변경 후 목록과 개수를 갱신하고 이전 기록 응답은 무시한다") + func refreshUpdatesStatusAndRejectsStaleRecords() async throws { + let adapter = try StoreTestAdapter( + goals: [makeDevelopmentGoal(id: "goal")] + ) + await adapter.load(.selected) + await adapter.load(.cardAppeared("goal")) + let recordRevision = adapter.state.recordRevision + let goal = try makeDevelopmentGoal(id: "goal", status: .completed) + await adapter.goalsSpy.setResult(.success([goal])) + await adapter.send(.showDetail("goal")) + await adapter.load(.sheet(.dismiss)) + #expect(adapter.state.filteredGoals.isEmpty) + #expect(adapter.state.count(for: .completed) == 1) + await adapter.send(.recentRecordLoaded("goal", .failed, revision: recordRevision)) + #expect(adapter.state.recentRecords["goal"] == nil) + await adapter.send(.selectStatus(.completed)) + await adapter.load(.cardAppeared("goal")) + #expect(adapter.state.recentRecords["goal"] == .loaded(nil)) + } + + @Test("이전 목록 요청의 성공과 실패 응답은 최신 목록을 덮어쓰지 않는다") + func staleGoalResponses() async throws { + let adapter = try StoreTestAdapter( + goals: [makeDevelopmentGoal(id: "goal")] + ) + await adapter.load(.selected) + let requestID = adapter.state.requestID + await adapter.load(.refresh) + let state = adapter.state + await adapter.send(.goalsLoaded([], requestID: requestID)) + await adapter.send(.goalsFailed(requestID: requestID)) + #expect(adapter.state == state) + } +} diff --git a/Application/Presentation/DevelopmentTab/Tests/DevelopmentGoalListTestDoubles.swift b/Application/Presentation/DevelopmentTab/Tests/DevelopmentGoalListTestDoubles.swift new file mode 100644 index 00000000..ed86e15e --- /dev/null +++ b/Application/Presentation/DevelopmentTab/Tests/DevelopmentGoalListTestDoubles.swift @@ -0,0 +1,146 @@ +// +// DevelopmentGoalListTestDoubles.swift +// DevelopmentTabTests +// +// Created by opfic on 10/5/26. +// + +import Domain +import Foundation +import PresentationShared +@testable import DevelopmentTab + +@MainActor +struct StoreTestAdapter { + let goalsSpy: FetchDevelopmentGoalsUseCaseSpy + let recentRecordSpy: FetchRecentDevelopmentRecordUseCaseSpy + private let store: TestStoreOf + + var state: DevelopmentGoalListFeature.State { store.state } + + init( + goals: [DevelopmentGoal] = [], + recentRecords records: [String: DevelopmentRecord.Version] = [:] + ) { + let goalsSpy = FetchDevelopmentGoalsUseCaseSpy(goals: goals) + let recentRecordSpy = FetchRecentDevelopmentRecordUseCaseSpy(recentRecords: records) + self.goalsSpy = goalsSpy + self.recentRecordSpy = recentRecordSpy + store = TestStore(initialState: DevelopmentGoalListFeature.State()) { + DevelopmentGoalListFeature() + } withDependencies: { + DevelopmentTabDependencyPreparation.prepareGoalList( + &$0, + goalsUseCase: goalsSpy, + recentRecordUseCase: recentRecordSpy + ) + } + store.exhaustivity = .off(showSkippedAssertions: false) + } + + func send(_ action: DevelopmentGoalListFeature.Action) async { + await store.send(action) + } + + func load(_ action: DevelopmentGoalListFeature.Action) async { + await store.send(action) + await settle() + } + + func settle() async { + await store.finish() + await store.skipReceivedActions(strict: false) + } +} + +actor FetchDevelopmentGoalsUseCaseSpy: FetchDevelopmentGoalsUseCase { + private var result: Result<[DevelopmentGoal], Error> + + init(goals: [DevelopmentGoal]) { + result = .success(goals) + } + + func setResult(_ result: Result<[DevelopmentGoal], Error>) { + self.result = result + } + + func execute(_ query: DevelopmentGoal.Query) async throws -> [DevelopmentGoal] { + try result.get() + } +} + +actor FetchRecentDevelopmentRecordUseCaseSpy: FetchRecentDevelopmentRecordUseCase { + private(set) var goalIDs = [String]() + private var recentRecords: [String: DevelopmentRecord.Version] + private var failures = Set() + private var isSuspended = false + private var continuations = [CheckedContinuation]() + + init(recentRecords records: [String: DevelopmentRecord.Version]) { + self.recentRecords = records + } + + func setFailure(goalID: String, fails: Bool) { + if fails { + failures.insert(goalID) + } else { + failures.remove(goalID) + } + } + + func suspend() { + isSuspended = true + } + + func resume() { + isSuspended = false + let continuations = self.continuations + self.continuations.removeAll() + for continuation in continuations { + continuation.resume() + } + } + + func execute(goalId: String) async throws -> DevelopmentRecord.Version? { + goalIDs.append(goalId) + if isSuspended { + await withCheckedContinuation { continuations.append($0) } + } + if failures.contains(goalId) { throw DevelopmentGoalListTestError.failed } + return recentRecords[goalId] + } +} + +enum DevelopmentGoalListTestError: Error { + case failed +} + +func makeDevelopmentGoal( + id: String, + status: DevelopmentGoal.Status = .inProgress +) throws -> DevelopmentGoal { + try DevelopmentGoal( + id: id, + title: id, + description: "목표 설명", + status: status, + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0), + completedAt: nil + ) +} + +func makeDevelopmentRecordVersion( + recordID id: String +) throws -> DevelopmentRecord.Version { + try DevelopmentRecord.Version( + id: "\(id)-v1", + recordId: id, + number: 1, + title: "기록 \(id)", + markdownContent: "기록 내용", + kind: .initial, + sourceVersionId: nil, + confirmedAt: Date(timeIntervalSince1970: 0) + ) +} diff --git a/Application/Presentation/Development/Tests/Goal/Create/GoalCreateFeatureTests.swift b/Application/Presentation/DevelopmentTab/Tests/Goal/Create/GoalCreateFeatureTests.swift similarity index 99% rename from Application/Presentation/Development/Tests/Goal/Create/GoalCreateFeatureTests.swift rename to Application/Presentation/DevelopmentTab/Tests/Goal/Create/GoalCreateFeatureTests.swift index 31ebd5e9..143a0d43 100644 --- a/Application/Presentation/Development/Tests/Goal/Create/GoalCreateFeatureTests.swift +++ b/Application/Presentation/DevelopmentTab/Tests/Goal/Create/GoalCreateFeatureTests.swift @@ -8,7 +8,7 @@ import Testing import Domain import PresentationShared -@testable import Development +@testable import DevelopmentTab @MainActor struct GoalCreateFeatureTests { diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailDestinationTests.swift b/Application/Presentation/DevelopmentTab/Tests/Record/GoalDetailDestinationTests.swift similarity index 98% rename from Application/Presentation/Development/Tests/Record/GoalDetailDestinationTests.swift rename to Application/Presentation/DevelopmentTab/Tests/Record/GoalDetailDestinationTests.swift index 9a67715e..c923d010 100644 --- a/Application/Presentation/Development/Tests/Record/GoalDetailDestinationTests.swift +++ b/Application/Presentation/DevelopmentTab/Tests/Record/GoalDetailDestinationTests.swift @@ -9,7 +9,7 @@ import Domain import Foundation import PresentationShared import Testing -@testable import Development +@testable import DevelopmentTab @MainActor struct GoalDetailDestinationTests { diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift b/Application/Presentation/DevelopmentTab/Tests/Record/GoalDetailFeatureTests.swift similarity index 99% rename from Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift rename to Application/Presentation/DevelopmentTab/Tests/Record/GoalDetailFeatureTests.swift index 126906bf..a702b819 100644 --- a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift +++ b/Application/Presentation/DevelopmentTab/Tests/Record/GoalDetailFeatureTests.swift @@ -9,7 +9,7 @@ import Testing import Domain import Foundation import PresentationShared -@testable import Development +@testable import DevelopmentTab @MainActor struct GoalDetailFeatureTests { diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailTodoFeatureTests.swift b/Application/Presentation/DevelopmentTab/Tests/Record/GoalDetailTodoFeatureTests.swift similarity index 99% rename from Application/Presentation/Development/Tests/Record/GoalDetailTodoFeatureTests.swift rename to Application/Presentation/DevelopmentTab/Tests/Record/GoalDetailTodoFeatureTests.swift index 3e5d9bd6..73afbd8d 100644 --- a/Application/Presentation/Development/Tests/Record/GoalDetailTodoFeatureTests.swift +++ b/Application/Presentation/DevelopmentTab/Tests/Record/GoalDetailTodoFeatureTests.swift @@ -9,7 +9,7 @@ import Testing import Core import Domain import PresentationShared -@testable import Development +@testable import DevelopmentTab @MainActor struct GoalDetailTodoFeatureTests { diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailTodoTestSupport.swift b/Application/Presentation/DevelopmentTab/Tests/Record/GoalDetailTodoTestSupport.swift similarity index 100% rename from Application/Presentation/Development/Tests/Record/GoalDetailTodoTestSupport.swift rename to Application/Presentation/DevelopmentTab/Tests/Record/GoalDetailTodoTestSupport.swift diff --git a/Application/Presentation/Development/Tests/Record/GoalTodoLinkFeatureTests.swift b/Application/Presentation/DevelopmentTab/Tests/Record/GoalTodoLinkFeatureTests.swift similarity index 99% rename from Application/Presentation/Development/Tests/Record/GoalTodoLinkFeatureTests.swift rename to Application/Presentation/DevelopmentTab/Tests/Record/GoalTodoLinkFeatureTests.swift index 82f43908..4238e013 100644 --- a/Application/Presentation/Development/Tests/Record/GoalTodoLinkFeatureTests.swift +++ b/Application/Presentation/DevelopmentTab/Tests/Record/GoalTodoLinkFeatureTests.swift @@ -9,7 +9,7 @@ import Testing import Core import Domain import PresentationShared -@testable import Development +@testable import DevelopmentTab @MainActor struct GoalTodoLinkFeatureTests { diff --git a/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift b/Application/Presentation/DevelopmentTab/Tests/Record/RecordDetailFeatureTests.swift similarity index 99% rename from Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift rename to Application/Presentation/DevelopmentTab/Tests/Record/RecordDetailFeatureTests.swift index 946c1372..d07ad59b 100644 --- a/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift +++ b/Application/Presentation/DevelopmentTab/Tests/Record/RecordDetailFeatureTests.swift @@ -8,7 +8,7 @@ import Foundation import Testing import PresentationShared -@testable import Development +@testable import DevelopmentTab @MainActor struct RecordDetailFeatureTests { diff --git a/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift b/Application/Presentation/DevelopmentTab/Tests/Record/RecordEditorFeatureTests.swift similarity index 99% rename from Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift rename to Application/Presentation/DevelopmentTab/Tests/Record/RecordEditorFeatureTests.swift index cab50b00..38d514a0 100644 --- a/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift +++ b/Application/Presentation/DevelopmentTab/Tests/Record/RecordEditorFeatureTests.swift @@ -8,7 +8,7 @@ import Testing import Domain import PresentationShared -@testable import Development +@testable import DevelopmentTab @MainActor struct RecordEditorFeatureTests { diff --git a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift b/Application/Presentation/DevelopmentTab/Tests/Record/RecordFeatureTestSupport.swift similarity index 100% rename from Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift rename to Application/Presentation/DevelopmentTab/Tests/Record/RecordFeatureTestSupport.swift diff --git a/Application/Presentation/Entry/Sources/Dependency/PresentationDependencyPreparation.swift b/Application/Presentation/Entry/Sources/Dependency/PresentationDependencyPreparation.swift index cc787b6e..04826c6e 100644 --- a/Application/Presentation/Entry/Sources/Dependency/PresentationDependencyPreparation.swift +++ b/Application/Presentation/Entry/Sources/Dependency/PresentationDependencyPreparation.swift @@ -5,6 +5,7 @@ // Created by opfic on 9/7/26. // +import DevelopmentTab import Domain import HomeTab import NotificationTab @@ -45,3 +46,5 @@ public typealias HomePresentationDependencyPreparation = HomeDependencyPreparati public typealias NotificationDependencyPreparation = PushNotificationDependencyPreparation public typealias ProfilePresentationDependencyPreparation = ProfileDependencyPreparation public typealias TodayPresentationDependencyPreparation = TodayDependencyPreparation + +public typealias DevelopmentTabPresentationDependencyPreparation = DevelopmentTabDependencyPreparation diff --git a/Application/Presentation/Entry/Sources/Main/MainFeature.swift b/Application/Presentation/Entry/Sources/Main/MainFeature.swift index 18466735..4952241d 100644 --- a/Application/Presentation/Entry/Sources/Main/MainFeature.swift +++ b/Application/Presentation/Entry/Sources/Main/MainFeature.swift @@ -151,6 +151,8 @@ private extension MainTab { switch self { case .home: return "home" + case .developmentGoals: + return "development_goals" case .today: return "today" case .notification: diff --git a/Application/Presentation/Entry/Sources/Main/MainView.swift b/Application/Presentation/Entry/Sources/Main/MainView.swift index 721dab02..26667ab6 100644 --- a/Application/Presentation/Entry/Sources/Main/MainView.swift +++ b/Application/Presentation/Entry/Sources/Main/MainView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import DevelopmentTab import HomeTab import NotificationTab import ProfileTab @@ -52,6 +53,13 @@ struct MainView: View { ) { tabContent(.home) } + Tab( + MainTab.developmentGoals.title, + systemImage: MainTab.developmentGoals.symbolName, + value: MainTab.developmentGoals + ) { + tabContent(.developmentGoals) + } Tab( MainTab.today.title, systemImage: MainTab.today.symbolName, @@ -104,6 +112,8 @@ struct MainView: View { isSelected: isSelected, windowEvent: windowEvent ) + case .developmentGoals: + DevelopmentGoalListView(isSelected: isSelected) case .today: TodayView( isSelected: isSelected, diff --git a/Application/Presentation/Entry/Sources/Routing/MainTab.swift b/Application/Presentation/Entry/Sources/Routing/MainTab.swift index 0db4451f..245d33a4 100644 --- a/Application/Presentation/Entry/Sources/Routing/MainTab.swift +++ b/Application/Presentation/Entry/Sources/Routing/MainTab.swift @@ -10,6 +10,7 @@ import PresentationShared public enum MainTab: Hashable, CaseIterable { case home + case developmentGoals case today case notification case profile @@ -18,6 +19,8 @@ public enum MainTab: Hashable, CaseIterable { switch self { case .home: String(localized: "nav_home", bundle: PresentationResources.bundle) + case .developmentGoals: + String(localized: "nav_development_goals", bundle: PresentationResources.bundle) case .today: String(localized: "nav_today", bundle: PresentationResources.bundle) case .notification: @@ -31,6 +34,8 @@ public enum MainTab: Hashable, CaseIterable { switch self { case .home: "house.fill" + case .developmentGoals: + "flag.checkered" case .today: "sun.max.fill" case .notification: diff --git a/Application/Presentation/Entry/Tests/Main/MainFeatureTests.swift b/Application/Presentation/Entry/Tests/Main/MainFeatureTests.swift index 5e5a7392..9cf36418 100644 --- a/Application/Presentation/Entry/Tests/Main/MainFeatureTests.swift +++ b/Application/Presentation/Entry/Tests/Main/MainFeatureTests.swift @@ -40,25 +40,28 @@ struct MainFeatureTests { let expectedEffects = [ reference.reduce(.selectedTabChanged(.home)), + reference.reduce(.selectedTabChanged(.developmentGoals)), reference.reduce(.selectedTabChanged(.today)), reference.reduce(.selectedTabChanged(.notification)), reference.reduce(.selectedTabChanged(.profile)) ] await store.send(.view(.selectedTabChanged(.home))) + await store.send(.view(.selectedTabChanged(.developmentGoals))) await store.send(.view(.selectedTabChanged(.today))) await store.send(.view(.selectedTabChanged(.notification))) await store.send(.view(.selectedTabChanged(.profile))) await waitUntil { - trackAnalyticsEventUseCase.screenNames == ["home", "today", "profile"] + trackAnalyticsEventUseCase.screenNames == ["home", "development_goals", "today", "profile"] } #expect(expectedEffects == [ [.trackScreenView("home")], + [.trackScreenView("development_goals")], [.trackScreenView("today")], [], [.trackScreenView("profile")] ]) - #expect(trackAnalyticsEventUseCase.screenNames == ["home", "today", "profile"]) + #expect(trackAnalyticsEventUseCase.screenNames == ["home", "development_goals", "today", "profile"]) } @Test("MainFeature는 기존 Main 상태관리처럼 unread count를 갱신하고 badge 갱신을 요청한다") @@ -287,6 +290,8 @@ private extension MainTab { switch self { case .home: return "home" + case .developmentGoals: + return "development_goals" case .today: return "today" case .notification: diff --git a/Application/Presentation/HomeTab/Sources/Home/DevelopmentGoalSection.swift b/Application/Presentation/HomeTab/Sources/Home/DevelopmentGoalSection.swift index 78e3794c..658ff8c3 100644 --- a/Application/Presentation/HomeTab/Sources/Home/DevelopmentGoalSection.swift +++ b/Application/Presentation/HomeTab/Sources/Home/DevelopmentGoalSection.swift @@ -13,7 +13,6 @@ struct DevelopmentGoalSection: View { let isLoading: Bool let hasLoaded: Bool let hasLoadFailure: Bool - let onCreate: () -> Void let onSelect: (DevelopmentGoalItem) -> Void let onRetry: () -> Void @@ -95,11 +94,6 @@ struct DevelopmentGoalSection: View { .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) - } } } } diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Dependencies.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Dependencies.swift index f530eca1..a32c1538 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Dependencies.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Dependencies.swift @@ -14,14 +14,9 @@ extension DependencyValues { set { self[FetchDevelopmentGoalsUseCaseKey.self] = newValue } } - var homeFetchDevelopmentRecordsUseCase: FetchDevelopmentRecordsUseCase { - get { self[FetchDevelopmentRecordsUseCaseKey.self] } - set { self[FetchDevelopmentRecordsUseCaseKey.self] = newValue } - } - - var homeFetchDevelopmentRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase { - get { self[FetchDevelopmentRecordVersionKey.self] } - set { self[FetchDevelopmentRecordVersionKey.self] = newValue } + var homeFetchRecentDevelopmentRecordUseCase: FetchRecentDevelopmentRecordUseCase { + get { self[FetchRecentDevelopmentRecordUseCaseKey.self] } + set { self[FetchRecentDevelopmentRecordUseCaseKey.self] = newValue } } var homeUpdateTodoCategoryPreferencesUseCase: UpdateTodoCategoryPreferencesUseCase { @@ -45,22 +40,12 @@ private enum FetchDevelopmentGoalsUseCaseKey: DependencyKey { } } -private enum FetchDevelopmentRecordsUseCaseKey: DependencyKey { - static var liveValue: FetchDevelopmentRecordsUseCase { - preconditionFailure("FetchDevelopmentRecordsUseCase must be provided.") - } - - static var testValue: FetchDevelopmentRecordsUseCase { - liveValue - } -} - -private enum FetchDevelopmentRecordVersionKey: DependencyKey { - static var liveValue: FetchDevelopmentRecordVersionUseCase { - preconditionFailure("FetchDevelopmentRecordVersionUseCase must be provided.") +private enum FetchRecentDevelopmentRecordUseCaseKey: DependencyKey { + static var liveValue: FetchRecentDevelopmentRecordUseCase { + preconditionFailure("FetchRecentDevelopmentRecordUseCase must be provided.") } - static var testValue: FetchDevelopmentRecordVersionUseCase { + static var testValue: FetchRecentDevelopmentRecordUseCase { liveValue } } diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift index a57173f7..808f6555 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeFeature+Effects.swift @@ -14,6 +14,7 @@ import PresentationShared extension HomeFeature { private enum CancelID: Hashable { case delayedTodoEditor + case delayedDevelopmentGoalCreate case networkConnectivity } @@ -40,11 +41,9 @@ 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 + return .run { [goalsUseCase, recentRecordUseCase, todosUseCase] send in await send(.loading(.begin(target: LoadingTarget.developmentGoals.target, mode: .immediate))) do { let goals = try await goalsUseCase.execute(.init(status: .inProgress)) @@ -62,8 +61,7 @@ extension HomeFeature { ) let goalItems = try await Self.makeDevelopmentGoalItems( goals, - fetchRecordsUseCase: recordsUseCase, - fetchRecordVersionUseCase: versionUseCase + recentRecordUseCase: recentRecordUseCase ) let todoProgressByGoalID = Self.makeTodoProgressByGoalID((try await todos).items) let items = goalItems.map { item in @@ -107,6 +105,15 @@ extension HomeFeature { .cancellable(id: CancelID.delayedTodoEditor, cancelInFlight: true) } + func delayedDevelopmentGoalCreateEffect() -> Effect { + .run { [clock] send in + // 콘텐츠 선택 시트 dismiss 직후 개발 목표 생성 시트를 바로 올리지 않도록 하기 위해서 0.1초 딜레이 + try await clock.sleep(for: .seconds(0.1)) + await send(.view(.tapCreateDevelopmentGoal)) + } + .cancellable(id: CancelID.delayedDevelopmentGoalCreate, cancelInFlight: true) + } + static func setPresentation( _ state: inout State, presentation: Presentation, @@ -151,21 +158,15 @@ extension HomeFeature { static func makeDevelopmentGoalItems( _ goals: [DevelopmentGoal], - fetchRecordsUseCase: FetchDevelopmentRecordsUseCase, - fetchRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase + recentRecordUseCase useCase: FetchRecentDevelopmentRecordUseCase ) async throws -> [DevelopmentGoalItem] { var items = [DevelopmentGoalItem]() try await withThrowingTaskGroup(of: DevelopmentGoalItem.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 DevelopmentGoalItem(goal: goal, recentRecord: recentRecord) + let version = try await useCase.execute(goalId: goal.id) + return DevelopmentGoalItem(goal: goal, recentRecord: version) } } @@ -182,33 +183,6 @@ extension HomeFeature { } } - 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: DevelopmentGoalTodoProgress] { diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift b/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift index 072fb4d0..0f46f9fd 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeFeature.swift @@ -159,8 +159,7 @@ struct HomeFeature { @Dependency(\.fetchTodoCategoryPreferencesUseCase) var fetchPreferencesUseCase @Dependency(\.homeFetchDevelopmentGoalsUseCase) var fetchDevelopmentGoalsUseCase - @Dependency(\.homeFetchDevelopmentRecordsUseCase) var fetchDevelopmentRecordsUseCase - @Dependency(\.homeFetchDevelopmentRecordVersionUseCase) var fetchDevelopmentRecordVersionUseCase + @Dependency(\.homeFetchRecentDevelopmentRecordUseCase) var recentRecordUseCase @Dependency(\.homeFetchTodosUseCase) var fetchTodosUseCase @Dependency(\.homeUpdateTodoCategoryPreferencesUseCase) var updatePreferencesUseCase @Dependency(\.homeNetworkConnectivityUseCase) var networkConnectivityUseCase @@ -250,6 +249,10 @@ private extension HomeFeature { .send(.view(.fetchData)) ) case .tapCreateDevelopmentGoal: + if state.showContentPicker { + state.sheet = nil + return delayedDevelopmentGoalCreateEffect() + } state.sheet = .goalCreate case .tapDevelopmentGoal(let goalID): state.sheet = .goalDetail(goalID) diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift index b9ea1206..35454204 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift @@ -7,7 +7,7 @@ import SwiftUI import Combine -import Development +import DevelopmentTab import Domain import PresentationShared @@ -58,7 +58,6 @@ public struct HomeView: View { isLoading: store.isDevelopmentGoalsLoading, hasLoaded: store.hasDevelopmentGoalsLoaded, hasLoadFailure: store.hasDevelopmentGoalsLoadFailure, - onCreate: { store.send(.view(.tapCreateDevelopmentGoal)) }, onSelect: { store.send(.view(.tapDevelopmentGoal($0.id))) }, onRetry: { store.send(.view(.fetchData)) } ) @@ -278,8 +277,26 @@ public struct HomeView: View { .foregroundStyle(Color(.label)) } + Section { + Button { + store.send(.view(.tapCreateDevelopmentGoal)) + } label: { + labelImage( + text: String( + localized: "development_goal_create_title", + bundle: PresentationResources.bundle + ), + systemName: "flag.checkered", + imageColor: Color.accent + ) + } + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + } header: { + Text("development_goal_title", bundle: PresentationResources.bundle) + .foregroundStyle(Color(.label)) + } } - .navigationTitle(Text("TODO")) + .navigationTitle(Text(verbatim: "DevLog")) .navigationBarTitleDisplayMode(.inline) // 설정 안하면 섹션 위에 내비게이션 large 만큼 영역 먹음 .toolbar { ToolbarItem(placement: .topBarLeading) { diff --git a/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift b/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift index 569f84d7..ab5e649a 100644 --- a/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift +++ b/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift @@ -12,13 +12,11 @@ public enum HomeDependencyPreparation { public static func prepareDevelopmentGoal( _ dependencies: inout DependencyValues, fetchGoalsUseCase: FetchDevelopmentGoalsUseCase, - fetchRecordsUseCase: FetchDevelopmentRecordsUseCase, - fetchRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase, + recentRecordUseCase: FetchRecentDevelopmentRecordUseCase, fetchTodosUseCase: FetchTodosUseCase ) { dependencies.homeFetchDevelopmentGoalsUseCase = fetchGoalsUseCase - dependencies.homeFetchDevelopmentRecordsUseCase = fetchRecordsUseCase - dependencies.homeFetchDevelopmentRecordVersionUseCase = fetchRecordVersionUseCase + dependencies.homeFetchRecentDevelopmentRecordUseCase = recentRecordUseCase dependencies.homeFetchTodosUseCase = fetchTodosUseCase } diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift index a3bb70dc..df60b9f8 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestAssertions.swift @@ -86,20 +86,6 @@ func makeDevelopmentGoal( ) } -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, diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift index 32c407b4..7fa000cd 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSpies.swift @@ -59,23 +59,17 @@ final class FetchDevelopmentGoalsUseCaseSpy: FetchDevelopmentGoalsUseCase { } } -final class FetchDevelopmentRecordsUseCaseSpy: FetchDevelopmentRecordsUseCase { - var resultByGoalID = [String: Result<[DevelopmentRecord], Error>]() +actor FetchRecentDevelopmentRecordUseCaseSpy: FetchRecentDevelopmentRecordUseCase { + private(set) var goalIDs = [String]() + private var resultByGoalID = [String: Result]() - func execute(goalId: String) async throws -> [DevelopmentRecord] { - try resultByGoalID[goalId, default: .success([])].get() + func setResult(_ result: Result, goalID: String) { + resultByGoalID[goalID] = result } -} - -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(DevelopmentGoalTestError.notFound)].get() + func execute(goalId: String) async throws -> DevelopmentRecord.Version? { + goalIDs.append(goalId) + return try resultByGoalID[goalId, default: .success(nil)].get() } } @@ -91,5 +85,4 @@ final class FetchTodosUseCaseSpy: FetchTodosUseCase { enum DevelopmentGoalTestError: Error { case failed - case notFound } diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift index c8491cd3..a7dd6ef3 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTestSupport.swift @@ -36,9 +36,7 @@ struct StoreTestAdapter { updatePreferencesUseCase: UpdateTodoCategoryPreferencesUseCase = UpdateTodoCategoryPreferencesUseCaseSpy(), networkConnectivityUseCase: ObserveNetworkConnectivityUseCase = ObserveNetworkConnectivityUseCaseSpy(), fetchDevelopmentGoalsUseCase: FetchDevelopmentGoalsUseCase = FetchDevelopmentGoalsUseCaseSpy(), - fetchDevelopmentRecordsUseCase: FetchDevelopmentRecordsUseCase = FetchDevelopmentRecordsUseCaseSpy(), - fetchDevelopmentRecordVersionUseCase: FetchDevelopmentRecordVersionUseCase = - FetchDevelopmentRecordVersionUseCaseSpy(), + recentRecordUseCase: FetchRecentDevelopmentRecordUseCase = FetchRecentDevelopmentRecordUseCaseSpy(), fetchTodosUseCase: FetchTodosUseCase = FetchTodosUseCaseSpy(), trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase = TrackAnalyticsEventUseCaseSpy(), configureDependencies: ((inout DependencyValues) -> Void)? = nil @@ -52,8 +50,7 @@ struct StoreTestAdapter { $0.homeUpdateTodoCategoryPreferencesUseCase = updatePreferencesUseCase $0.homeNetworkConnectivityUseCase = networkConnectivityUseCase $0.homeFetchDevelopmentGoalsUseCase = fetchDevelopmentGoalsUseCase - $0.homeFetchDevelopmentRecordsUseCase = fetchDevelopmentRecordsUseCase - $0.homeFetchDevelopmentRecordVersionUseCase = fetchDevelopmentRecordVersionUseCase + $0.homeFetchRecentDevelopmentRecordUseCase = recentRecordUseCase $0.homeFetchTodosUseCase = fetchTodosUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase $0.continuousClock = clock diff --git a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift index d83185b2..02b97a07 100644 --- a/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift +++ b/Application/Presentation/HomeTab/Tests/Home/HomeFeatureTests.swift @@ -26,39 +26,19 @@ struct HomeFeatureTests { @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( + let version = try makeDevelopmentRecordVersion( id: "recent-version", - recordID: recentRecord.id, - title: "Recent Record", + recordID: "recent-record", + title: "최근 기록", 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 recentRecordSpy = FetchRecentDevelopmentRecordUseCaseSpy() + await recentRecordSpy.setResult(.success(version), goalID: goal.id) let adapter = StoreTestAdapter( fetchDevelopmentGoalsUseCase: goalsSpy, - fetchDevelopmentRecordsUseCase: recordsSpy, - fetchDevelopmentRecordVersionUseCase: versionsSpy + recentRecordUseCase: recentRecordSpy ) await adapter.fetchData() @@ -66,8 +46,9 @@ struct HomeFeatureTests { await waitUntil { adapter.hasDevelopmentGoalsLoaded } #expect(goalsSpy.queries == [.init(status: .inProgress)]) + #expect(await recentRecordSpy.goalIDs == [goal.id]) #expect(adapter.developmentGoalItems.map(\.id) == [goal.id]) - #expect(adapter.developmentGoalItems.first?.recentRecord == recentVersion) + #expect(adapter.developmentGoalItems.first?.recentRecord == version) } @Test("HomeFeature fetchData는 연결 Todo 완료 수로 목표 진행률을 계산한다") diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 32893f59..7614e770 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -1433,6 +1433,108 @@ } } }, + "development_goals_create" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a Goal" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 목표 만들기" + } + } + } + }, + "development_goals_empty_archived" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No archived development goals." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관한 개발 목표가 없어요." + } + } + } + }, + "development_goals_empty_completed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No completed development goals." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료한 개발 목표가 없어요." + } + } + } + }, + "development_goals_empty_in_progress" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No development goals in progress." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 중인 개발 목표가 없어요." + } + } + } + }, + "development_goals_recent_record_date_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Written %1$@ · %2$@ (%3$@)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 작성됨 · %2$@ (%3$@)" + } + } + } + }, + "development_goals_recent_record_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latest record: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최근 기록: %@" + } + } + } + }, "development_record_add" : { "extractionState" : "manual", "localizations" : { @@ -2691,6 +2793,23 @@ } } }, + "nav_development_goals" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Development Goals" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개발 목표" + } + } + } + }, "nav_home" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Presentation/PresentationShared/Sources/Common/Component/RelativeTimeText.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/RelativeTimeText.swift index 0f97acef..f220b4e6 100644 --- a/Application/Presentation/PresentationShared/Sources/Common/Component/RelativeTimeText.swift +++ b/Application/Presentation/PresentationShared/Sources/Common/Component/RelativeTimeText.swift @@ -27,15 +27,17 @@ public struct RelativeTimeText: View { Text( String.localizedStringWithFormat( String(localized: "relative_time_updated_format", bundle: PresentationResources.bundle), - relativeTimeText(from: date, now: context.date) + RelativeTime.text(from: date, now: context.date) ) ) .font(bodyFont) .foregroundStyle(bodyColor) } } +} - private func relativeTimeText(from date: Date, now: Date) -> String { +public enum RelativeTime { + public static func text(from date: Date, now: Date) -> String { let seconds = Int(now.timeIntervalSince(date)) if seconds < 60 { diff --git a/Application/Presentation/PresentationShared/Sources/Extension/DevelopmentGoalStatus+Title.swift b/Application/Presentation/PresentationShared/Sources/Extension/DevelopmentGoalStatus+Title.swift new file mode 100644 index 00000000..d583636b --- /dev/null +++ b/Application/Presentation/PresentationShared/Sources/Extension/DevelopmentGoalStatus+Title.swift @@ -0,0 +1,22 @@ +// +// DevelopmentGoalStatus+Title.swift +// PresentationShared +// +// Created by opfic on 10/5/26. +// + +import Domain +import Foundation + +public extension DevelopmentGoal.Status { + var title: String { + switch self { + case .inProgress: + String(localized: "development_goal_status_in_progress", bundle: PresentationResources.bundle) + case .completed: + String(localized: "development_goal_status_completed", bundle: PresentationResources.bundle) + case .archived: + String(localized: "development_goal_status_archived", bundle: PresentationResources.bundle) + } + } +} diff --git a/Application/Presentation/ProfileTab/Sources/Profile/GoalSummaryCard.swift b/Application/Presentation/ProfileTab/Sources/Profile/GoalSummaryCard.swift index 15deedb1..e0862b68 100644 --- a/Application/Presentation/ProfileTab/Sources/Profile/GoalSummaryCard.swift +++ b/Application/Presentation/ProfileTab/Sources/Profile/GoalSummaryCard.swift @@ -78,19 +78,19 @@ struct GoalSummaryCard: View { : AnyLayout(HStackLayout(spacing: 8)) return layout { statusTile( - title: "development_goal_status_in_progress", + title: DevelopmentGoal.Status.inProgress.title, symbol: "clock", color: .accent, count: counts.inProgress ) statusTile( - title: "development_goal_status_completed", + title: DevelopmentGoal.Status.completed.title, symbol: "checkmark", color: .success, count: counts.completed ) statusTile( - title: "development_goal_status_archived", + title: DevelopmentGoal.Status.archived.title, symbol: "archivebox", color: .textSecondary, count: counts.archived @@ -98,13 +98,13 @@ struct GoalSummaryCard: View { } } - private func statusTile(title: LocalizedStringKey, symbol: String, color: Color, count: Int) -> some View { + private func statusTile(title: String, symbol: String, color: Color, count: Int) -> some View { VStack(spacing: 8) { Image(systemName: symbol) .font(.subheadline.weight(.semibold)) .frame(width: 36, height: 36) .iconStyle(color: color, in: Circle()) - Text(title, bundle: PresentationResources.bundle) + Text(title) .font(.caption) .foregroundStyle(Color.textSecondary) Text(verbatim: String(count)) diff --git a/Application/Presentation/Project.swift b/Application/Presentation/Project.swift index 779a86fb..89853291 100644 --- a/Application/Presentation/Project.swift +++ b/Application/Presentation/Project.swift @@ -89,96 +89,96 @@ let project = Project( ) ), .target( - name: "Development", + name: "HomeTab", destinations: .iOS, product: .staticFramework, - bundleId: "com.opfic.DevLog.Development", + bundleId: "com.opfic.DevLog.HomeTab", infoPlist: .file(path: frameworkInfoPlistPath), - sources: ["Development/Sources/**/*.swift"], + sources: ["HomeTab/Sources/**/*.swift"], scripts: [ DevLogScripts.swiftLint( - sourcePath: "Development/Sources", - configPath: "Development/Sources/.swiftlint.yml" + sourcePath: "HomeTab/Sources", + configPath: "HomeTab/Sources/.swiftlint.yml" ) ], dependencies: [ .project(target: "Domain", path: "../Domain"), .project(target: "Core", path: "../Core"), + .target(name: "DevelopmentTab"), .target(name: "PresentationShared") ], settings: frameworkBuildSettings ), .target( - name: "DevelopmentTests", + name: "HomeTabTests", destinations: .iOS, product: .unitTests, - bundleId: "com.opfic.DevLog.DevelopmentTests", + bundleId: "com.opfic.DevLog.HomeTabTests", infoPlist: .file(path: testsInfoPlistPath), - sources: ["Development/Tests/**/*.swift"], + sources: ["HomeTab/Tests/**/*.swift"], scripts: [ DevLogScripts.swiftLint( - sourcePath: "Development/Tests", - configPath: "Development/Tests/.swiftlint.yml" + sourcePath: "HomeTab/Tests", + configPath: "HomeTab/Tests/.swiftlint.yml" ) ], dependencies: [ - .project(target: "Domain", path: "../Domain"), - .project(target: "Core", path: "../Core"), - .target(name: "Development"), + .target(name: "HomeTab"), + .target(name: "DevelopmentTab"), .target(name: "PresentationShared"), thirdPartyDependency, ], settings: .devlog( base: [ "ENABLE_USER_SCRIPT_SANDBOXING": "NO", - "TEST_TARGET_NAME": "Development" + "TEST_TARGET_NAME": "HomeTab" ] ) ), .target( - name: "HomeTab", + name: "DevelopmentTab", destinations: .iOS, product: .staticFramework, - bundleId: "com.opfic.DevLog.HomeTab", + bundleId: "com.opfic.DevLog.DevelopmentTab", infoPlist: .file(path: frameworkInfoPlistPath), - sources: ["HomeTab/Sources/**/*.swift"], + sources: ["DevelopmentTab/Sources/**/*.swift"], scripts: [ DevLogScripts.swiftLint( - sourcePath: "HomeTab/Sources", - configPath: "HomeTab/Sources/.swiftlint.yml" + sourcePath: "DevelopmentTab/Sources", + configPath: "DevelopmentTab/Sources/.swiftlint.yml" ) ], dependencies: [ .project(target: "Domain", path: "../Domain"), .project(target: "Core", path: "../Core"), - .target(name: "Development"), .target(name: "PresentationShared") ], settings: frameworkBuildSettings ), .target( - name: "HomeTabTests", + name: "DevelopmentTabTests", destinations: .iOS, product: .unitTests, - bundleId: "com.opfic.DevLog.HomeTabTests", + bundleId: "com.opfic.DevLog.DevelopmentTabTests", infoPlist: .file(path: testsInfoPlistPath), - sources: ["HomeTab/Tests/**/*.swift"], + sources: ["DevelopmentTab/Tests/**/*.swift"], scripts: [ DevLogScripts.swiftLint( - sourcePath: "HomeTab/Tests", - configPath: "HomeTab/Tests/.swiftlint.yml" + sourcePath: "DevelopmentTab/Tests", + configPath: "DevelopmentTab/Tests/.swiftlint.yml" ) ], dependencies: [ - .target(name: "HomeTab"), - .target(name: "Development"), + .project(target: "Domain", path: "../Domain"), + .project(target: "Core", path: "../Core"), + .target(name: "DevelopmentTab"), .target(name: "PresentationShared"), thirdPartyDependency, ], settings: .devlog( base: [ "ENABLE_USER_SCRIPT_SANDBOXING": "NO", - "TEST_TARGET_NAME": "HomeTab" + "TEST_TARGET_NAME": "DevelopmentTab" ] ) ), @@ -338,6 +338,7 @@ let project = Project( .project(target: "Domain", path: "../Domain"), .project(target: "Core", path: "../Core"), .target(name: "HomeTab"), + .target(name: "DevelopmentTab"), .target(name: "TodayTab"), .target(name: "NotificationTab"), .target(name: "ProfileTab"), @@ -387,7 +388,7 @@ let project = Project( ], dependencies: [ .target(name: "Entry"), - .target(name: "Development"), + .target(name: "DevelopmentTab"), .target(name: "PresentationShared") ], settings: frameworkBuildSettings diff --git a/Application/Presentation/Sources/EntryExports.swift b/Application/Presentation/Sources/EntryExports.swift index b4ffa6d5..2aeb24b5 100644 --- a/Application/Presentation/Sources/EntryExports.swift +++ b/Application/Presentation/Sources/EntryExports.swift @@ -6,5 +6,5 @@ // @_exported import Entry -@_exported import Development +@_exported import DevelopmentTab @_exported import PresentationShared diff --git a/README.md b/README.md index 1865854c..7060e1f2 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ - `Presentation` target은 `App`의 기존 import를 유지하는 re-export 역할 - `Entry`: root/auth/tab shell/window 흐름 소유 - `PresentationShared`: 공통 Todo/Search/Loading 흐름 소유 - - `Development`: 개발 목표와 개발 기록 흐름 소유 + - `DevelopmentTab`: 개발 목표 탭과 개발 목표, 개발 기록 화면 소유 - `HomeTab`, `TodayTab`, `NotificationTab`, `ProfileTab`: 탭 단위 흐름 소유 - `MarkdownRenderer` target은 SwiftUI 공개 화면과 참조 값, 내부 `WKWebView` 연결, HTML/JavaScript/CSS 자원과 TypeScript Tooling을 소유함 - `PresentationShared`에서는 `TodoMarkdownContentView`만 `MarkdownRenderer`를 직접 import하며 재노출하지 않음 @@ -247,8 +247,8 @@ DevLog_iOS/ │ │ ├── Sources/ # Presentation target re-export source │ │ ├── Entry/ # root/auth/tab shell/window target │ │ ├── PresentationShared/ # 공통 Todo/Search/Loading UI, 공통 presentation structure -│ │ ├── Development/ # 개발 목표, 개발 기록 화면과 테스트 │ │ ├── HomeTab/ # Home 탭 화면, feature, coordinator, 테스트 +│ │ ├── DevelopmentTab/ # 개발 목표 탭과 목표 생성, 상세 및 기록 화면, 테스트 │ │ ├── TodayTab/ # Today 탭 화면, feature, coordinator, 테스트 │ │ ├── NotificationTab/ # Notification 탭 화면, feature, coordinator, 테스트 │ │ └── ProfileTab/ # Profile/Settings 탭 화면, feature, coordinator, 테스트 diff --git a/docs/graph.png b/docs/graph.png index bfee0c3f..a86e9d76 100644 Binary files a/docs/graph.png and b/docs/graph.png differ diff --git a/docs/home.png b/docs/home.png index 55e9ac14..9be81b62 100644 Binary files a/docs/home.png and b/docs/home.png differ