diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index 5b5fc36c..639d4b7c 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -51,7 +51,10 @@ private extension AppGraph { .saveDevelopmentRecordDraftUseCase, confirmRecordUseCase: developmentGraphSet .developmentRecordMutationUseCaseGraph - .confirmDevelopmentRecordUseCase + .confirmDevelopmentRecordUseCase, + restoreRecordUseCase: developmentGraphSet + .developmentRecordMutationUseCaseGraph + .restoreDevelopmentRecordUseCase ) } diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCase.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCase.swift index 324828f1..b6232968 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCase.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCase.swift @@ -9,6 +9,7 @@ public protocol RestoreDevelopmentRecordUseCase { func execute( goalId: String, recordId: String, + versionId: String, sourceVersionId: String ) async throws -> DevelopmentRecord.Version } diff --git a/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCaseImpl.swift b/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCaseImpl.swift index c3fc8c91..15b9b89b 100644 --- a/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCaseImpl.swift +++ b/Application/Domain/Sources/UseCase/DevelopmentRecord/Restore/RestoreDevelopmentRecordUseCaseImpl.swift @@ -5,34 +5,39 @@ // Created by opfic on 8/27/26. // -import Foundation - public final class RestoreDevelopmentRecordUseCaseImpl: RestoreDevelopmentRecordUseCase { private let repository: DevelopmentRecordRepository private let goalRepository: DevelopmentGoalRepository - private let idProvider: () -> String init( _ repository: DevelopmentRecordRepository, - _ goalRepository: DevelopmentGoalRepository, - idProvider: @escaping () -> String = { UUID().uuidString } + _ goalRepository: DevelopmentGoalRepository ) { self.repository = repository self.goalRepository = goalRepository - self.idProvider = idProvider } public func execute( goalId: String, recordId: String, + versionId: String, sourceVersionId: String ) async throws -> DevelopmentRecord.Version { + let record = try await repository.fetchRecord(goalId: goalId, recordId: recordId) + if record.currentVersion?.id == versionId { + return try await restoredVersion( + record: record, + goalId: goalId, + recordId: recordId, + versionId: versionId, + sourceVersionId: sourceVersionId + ) + } + let goal = try await goalRepository.fetchGoal(goalId) guard goal.status == .inProgress else { throw DomainLayerError.developmentGoalIsNotInProgress } - - let record = try await repository.fetchRecord(goalId: goalId, recordId: recordId) guard record.id == recordId, record.goalId == goalId else { throw DomainLayerError.invalidData(context: "developmentRecord") } @@ -52,11 +57,52 @@ public final class RestoreDevelopmentRecordUseCaseImpl: RestoreDevelopmentRecord throw DomainLayerError.developmentRecordVersionNotFound } - return try await repository.restoreVersion( + do { + return try await repository.restoreVersion( + goalId: goalId, + recordId: recordId, + versionId: versionId, + sourceVersionId: sourceVersionId + ) + } catch { + guard let record = try? await repository.fetchRecord( + goalId: goalId, + recordId: recordId + ), let version = try? await restoredVersion( + record: record, + goalId: goalId, + recordId: recordId, + versionId: versionId, + sourceVersionId: sourceVersionId + ) else { + throw error + } + return version + } + } +} + +private extension RestoreDevelopmentRecordUseCaseImpl { + func restoredVersion( + record: DevelopmentRecord, + goalId: String, + recordId: String, + versionId: String, + sourceVersionId: String + ) async throws -> DevelopmentRecord.Version { + guard record.currentVersion?.id == versionId else { + throw DomainLayerError.developmentRecordVersionNotFound + } + let version = try await repository.fetchVersion( goalId: goalId, recordId: recordId, - versionId: idProvider(), - sourceVersionId: sourceVersionId + versionId: versionId ) + guard version.recordId == recordId, + version.kind == .rollback, + version.sourceVersionId == sourceVersionId else { + throw DomainLayerError.developmentRecordVersionNotFound + } + return version } } diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift index 67964dcb..be085e3b 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTestSupport.swift @@ -92,6 +92,7 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { private let confirmedVersion: DevelopmentRecord.Version? private let confirmError: Error? private let restoredVersion: DevelopmentRecord.Version? + private let restoreError: Error? private var recordedCreateRequests = [CreateRequest]() private var recordedRecordQueries = [String]() private var recordedVersionQueries = [RecordQuery]() @@ -109,7 +110,8 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { savedRecord: DevelopmentRecord? = nil, confirmedVersion: DevelopmentRecord.Version? = nil, confirmError: Error? = nil, - restoredVersion: DevelopmentRecord.Version? = nil + restoredVersion: DevelopmentRecord.Version? = nil, + restoreError: Error? = nil ) { self.createResult = createResult self.records = records @@ -119,6 +121,7 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { self.confirmedVersion = confirmedVersion self.confirmError = confirmError self.restoredVersion = restoredVersion + self.restoreError = restoreError } func createRecord( @@ -215,6 +218,9 @@ actor DevelopmentRecordRepositorySpy: DevelopmentRecordRepository { .init( goalId: goalId, recordId: recordId, versionId: versionId, sourceVersionId: sourceVersionId) ) + if let restoreError { + throw restoreError + } return try requiredDevelopmentRecordRepositoryResult(restoredVersion) } diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift index e6fd6e93..39ac238b 100644 --- a/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/DevelopmentRecordUseCaseTests.swift @@ -237,124 +237,4 @@ struct DevelopmentRecordUseCaseTests { ]) } - @Test("같은 기록의 과거 버전은 rollback 버전으로 되돌린다") - func 같은_기록의_과거_버전은_rollback_버전으로_되돌린다() async throws { - let goal = try makeDevelopmentRecordGoal() - let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-3", number: 3) - let record = try makeDevelopmentRecord(currentVersion: currentVersion, draft: nil) - let initialVersion = try makeDevelopmentRecordInitialVersion() - let correctionVersion = try makeDevelopmentRecordCorrectionVersion( - id: "version-2", - sourceVersionId: "version-1" - ) - let currentRecordVersion = try makeDevelopmentRecordCorrectionVersion( - id: "version-3", - number: 3, - sourceVersionId: "version-2" - ) - let rollbackVersion = try DevelopmentRecord.Version( - id: "version-4", - recordId: "record-1", - number: 4, - title: "되돌린 기록", - markdownContent: "본문", - kind: .rollback, - sourceVersionId: "version-1", - confirmedAt: .distantFuture - ) - let repository = DevelopmentRecordRepositorySpy( - record: record, - versions: [initialVersion, correctionVersion, currentRecordVersion], - restoredVersion: rollbackVersion - ) - let useCase = RestoreDevelopmentRecordUseCaseImpl( - repository, - DevelopmentRecordGoalRepositorySpy(goal: goal), - idProvider: { "version-4" } - ) - let result = try await useCase.execute( - goalId: "goal-1", - recordId: "record-1", - sourceVersionId: "version-1" - ) - #expect(result == rollbackVersion) - #expect(await repository.restoreRequests() == [ - .init( - goalId: "goal-1", - recordId: "record-1", - versionId: "version-4", - sourceVersionId: "version-1" - ) - ]) - } - - @Test("현재 또는 다른 기록의 버전은 되돌리기 대상으로 거부한다") - func 현재_또는_다른_기록의_버전은_되돌리기_대상으로_거부한다() async throws { - let goal = try makeDevelopmentRecordGoal() - let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-2", number: 2) - let record = try makeDevelopmentRecord(currentVersion: currentVersion, draft: nil) - let currentRecordVersion = try makeDevelopmentRecordCorrectionVersion( - id: "version-2", - number: 2, - sourceVersionId: "version-1" - ) - let otherRecordVersion = try DevelopmentRecord.Version( - id: "other-version-1", - recordId: "record-2", - number: 1, - title: "다른 기록", - markdownContent: "본문", - kind: .initial, - sourceVersionId: nil, - confirmedAt: .distantPast - ) - for sourceVersionId in [currentRecordVersion.id, otherRecordVersion.id] { - let repository = DevelopmentRecordRepositorySpy( - record: record, - versions: [currentRecordVersion, otherRecordVersion] - ) - let useCase = RestoreDevelopmentRecordUseCaseImpl( - repository, - DevelopmentRecordGoalRepositorySpy(goal: goal) - ) - - await expectDevelopmentRecordDomainError(.developmentRecordVersionNotFound) { - try await useCase.execute( - goalId: "goal-1", - recordId: "record-1", - sourceVersionId: sourceVersionId - ) - } - #expect(await repository.restoreRequests().isEmpty) - } - } - - @Test("Draft가 있으면 버전 되돌리기를 거부한다") - func Draft가_있으면_버전_되돌리기를_거부한다() async throws { - let goal = try makeDevelopmentRecordGoal() - let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) - let record = try makeDevelopmentRecord( - currentVersion: currentVersion, - draft: DevelopmentRecord.Draft( - title: "정정 초안", - markdownContent: "본문", - baseVersionId: currentVersion.id, - updatedAt: .distantPast - ) - ) - let repository = DevelopmentRecordRepositorySpy(record: record) - let useCase = RestoreDevelopmentRecordUseCaseImpl( - repository, - DevelopmentRecordGoalRepositorySpy(goal: goal) - ) - - await expectDevelopmentRecordDomainError(.developmentRecordDraftConflict) { - try await useCase.execute( - goalId: "goal-1", - recordId: "record-1", - sourceVersionId: "version-1" - ) - } - #expect(await repository.restoreRequests().isEmpty) - } } diff --git a/Application/Domain/Tests/UseCase/DevelopmentRecord/RestoreDevelopmentRecordUseCaseImplTests.swift b/Application/Domain/Tests/UseCase/DevelopmentRecord/RestoreDevelopmentRecordUseCaseImplTests.swift new file mode 100644 index 00000000..4e64a8da --- /dev/null +++ b/Application/Domain/Tests/UseCase/DevelopmentRecord/RestoreDevelopmentRecordUseCaseImplTests.swift @@ -0,0 +1,192 @@ +// +// RestoreDevelopmentRecordUseCaseImplTests.swift +// DomainTests +// +// Created by opfic on 9/14/26. +// + +import Foundation +import Testing +@testable import Domain + +struct RestoreDevelopmentRecordUseCaseImplTests { + @Test("같은 기록의 과거 버전은 rollback 버전으로 되돌린다") + func 같은_기록의_과거_버전은_rollback_버전으로_되돌린다() async throws { + let goal = try makeDevelopmentRecordGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-3", number: 3) + let record = try makeDevelopmentRecord(currentVersion: currentVersion, draft: nil) + let initialVersion = try makeDevelopmentRecordInitialVersion() + let correctionVersion = try makeDevelopmentRecordCorrectionVersion( + id: "version-2", + sourceVersionId: "version-1" + ) + let currentRecordVersion = try makeDevelopmentRecordCorrectionVersion( + id: "version-3", + number: 3, + sourceVersionId: "version-2" + ) + let rollbackVersion = try DevelopmentRecord.Version( + id: "version-4", + recordId: "record-1", + number: 4, + title: "되돌린 기록", + markdownContent: "본문", + kind: .rollback, + sourceVersionId: "version-1", + confirmedAt: .distantFuture + ) + let repository = DevelopmentRecordRepositorySpy( + record: record, + versions: [initialVersion, correctionVersion, currentRecordVersion], + restoredVersion: rollbackVersion + ) + let useCase = RestoreDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + let result = try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-4", + sourceVersionId: "version-1" + ) + #expect(result == rollbackVersion) + #expect(await repository.restoreRequests() == [ + .init( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-4", + sourceVersionId: "version-1" + ) + ]) + } + + @Test("되돌리기 응답이 유실되면 같은 버전 ID로 성공 결과를 복구한다") + func 되돌리기_응답이_유실되면_같은_버전_ID로_성공_결과를_복구한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-3", number: 3) + let restoredCurrentVersion = try DevelopmentRecord.CurrentVersion(id: "version-4", number: 4) + let record = try makeDevelopmentRecord(currentVersion: currentVersion, draft: nil) + let restoredRecord = try makeDevelopmentRecord( + currentVersion: restoredCurrentVersion, + draft: nil + ) + let initialVersion = try makeDevelopmentRecordInitialVersion() + let rollbackVersion = try DevelopmentRecord.Version( + id: restoredCurrentVersion.id, + recordId: "record-1", + number: restoredCurrentVersion.number, + title: initialVersion.title, + markdownContent: initialVersion.markdownContent, + kind: .rollback, + sourceVersionId: initialVersion.id, + confirmedAt: .distantFuture + ) + let repository = DevelopmentRecordRepositorySpy( + record: record, + subsequentRecords: [restoredRecord], + versions: [initialVersion, rollbackVersion], + restoreError: DevelopmentRecordRepositorySpyError.unconfigured + ) + let useCase = RestoreDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + + let result = try await useCase.execute( + goalId: "goal-1", + recordId: record.id, + versionId: rollbackVersion.id, + sourceVersionId: initialVersion.id + ) + + #expect(result == rollbackVersion) + #expect(await repository.restoreRequests() == [ + .init( + goalId: "goal-1", + recordId: record.id, + versionId: rollbackVersion.id, + sourceVersionId: initialVersion.id + ) + ]) + #expect(await repository.exactVersionQueries() == [ + .init( + goalId: "goal-1", + recordId: record.id, + versionId: rollbackVersion.id + ) + ]) + } + + @Test("현재 또는 다른 기록의 버전은 되돌리기 대상으로 거부한다") + func 현재_또는_다른_기록의_버전은_되돌리기_대상으로_거부한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-2", number: 2) + let record = try makeDevelopmentRecord(currentVersion: currentVersion, draft: nil) + let currentRecordVersion = try makeDevelopmentRecordCorrectionVersion( + id: "version-2", + number: 2, + sourceVersionId: "version-1" + ) + let otherRecordVersion = try DevelopmentRecord.Version( + id: "other-version-1", + recordId: "record-2", + number: 1, + title: "다른 기록", + markdownContent: "본문", + kind: .initial, + sourceVersionId: nil, + confirmedAt: .distantPast + ) + for sourceVersionId in [currentRecordVersion.id, otherRecordVersion.id] { + let repository = DevelopmentRecordRepositorySpy( + record: record, + versions: [currentRecordVersion, otherRecordVersion] + ) + let useCase = RestoreDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + + await expectDevelopmentRecordDomainError(.developmentRecordVersionNotFound) { + try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-3", + sourceVersionId: sourceVersionId + ) + } + #expect(await repository.restoreRequests().isEmpty) + } + } + + @Test("Draft가 있으면 버전 되돌리기를 거부한다") + func Draft가_있으면_버전_되돌리기를_거부한다() async throws { + let goal = try makeDevelopmentRecordGoal() + let currentVersion = try DevelopmentRecord.CurrentVersion(id: "version-1", number: 1) + let record = try makeDevelopmentRecord( + currentVersion: currentVersion, + draft: DevelopmentRecord.Draft( + title: "정정 초안", + markdownContent: "본문", + baseVersionId: currentVersion.id, + updatedAt: .distantPast + ) + ) + let repository = DevelopmentRecordRepositorySpy(record: record) + let useCase = RestoreDevelopmentRecordUseCaseImpl( + repository, + DevelopmentRecordGoalRepositorySpy(goal: goal) + ) + + await expectDevelopmentRecordDomainError(.developmentRecordDraftConflict) { + try await useCase.execute( + goalId: "goal-1", + recordId: "record-1", + versionId: "version-2", + sourceVersionId: "version-1" + ) + } + #expect(await repository.restoreRequests().isEmpty) + } +} diff --git a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift index eb8225c1..e0208c85 100644 --- a/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift +++ b/Application/Presentation/Development/Sources/Dependency/DevelopmentDependencyPreparation.swift @@ -31,11 +31,13 @@ public enum DevelopmentDependencyPreparation { _ dependencies: inout DependencyValues, createRecordUseCase: CreateDevelopmentRecordUseCase, saveRecordDraftUseCase: SaveDevelopmentRecordDraftUseCase, - confirmRecordUseCase: ConfirmDevelopmentRecordUseCase + confirmRecordUseCase: ConfirmDevelopmentRecordUseCase, + restoreRecordUseCase: RestoreDevelopmentRecordUseCase ) { dependencies.developmentCreateRecordUseCase = createRecordUseCase dependencies.developmentSaveRecordDraftUseCase = saveRecordDraftUseCase dependencies.developmentConfirmRecordUseCase = confirmRecordUseCase + dependencies.developmentRestoreRecordUseCase = restoreRecordUseCase } } @@ -74,6 +76,11 @@ extension DependencyValues { get { self[DevelopmentConfirmRecordUseCaseKey.self] } set { self[DevelopmentConfirmRecordUseCaseKey.self] = newValue } } + + var developmentRestoreRecordUseCase: RestoreDevelopmentRecordUseCase { + get { self[DevelopmentRestoreRecordUseCaseKey.self] } + set { self[DevelopmentRestoreRecordUseCaseKey.self] = newValue } + } } private enum DevelopmentFetchGoalUseCaseKey: DependencyKey { @@ -117,3 +124,9 @@ private enum DevelopmentConfirmRecordUseCaseKey: DependencyKey { preconditionFailure("ConfirmDevelopmentRecordUseCase must be provided.") } } + +private enum DevelopmentRestoreRecordUseCaseKey: DependencyKey { + static var liveValue: RestoreDevelopmentRecordUseCase { + preconditionFailure("RestoreDevelopmentRecordUseCase must be provided.") + } +} diff --git a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift index 21a2d3a9..8a787dff 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailFeature.swift @@ -12,18 +12,28 @@ import PresentationShared struct RecordDetailFeature { @ObservableState struct State: Equatable { - @Presents var alert: AlertState? + @Presents var alert: AlertState? let goalTitle: String - let record: DevelopmentRecord + var record: DevelopmentRecord + var versions = [DevelopmentRecord.Version]() var contentState: ContentState + var currentVersionID: String? + var isRestoring = false + var restoreRequest: RestoreRequest? + var restoredSourceVersionID: String? var isLoading: Bool { contentState == .idle || contentState == .loading } + var currentVersion: DevelopmentRecord.Version? { + versions.first { $0.id == currentVersionID } + } + init(goalTitle: String, record: DevelopmentRecord) { self.goalTitle = goalTitle self.record = record + self.currentVersionID = record.currentVersion?.id if record.currentVersion == nil, let draft = record.draft { self.contentState = .draft(draft) } else { @@ -36,42 +46,102 @@ struct RecordDetailFeature { case idle case loading case draft(DevelopmentRecord.Draft) - case confirmed(DevelopmentRecord.Version) + case loaded case failed } + struct RestoreRequest: Equatable { + let versionID: String + let sourceVersionID: String + } + enum Action: Equatable { - case alert(PresentationAction) + case alert(PresentationAction) case view(ViewAction) case store(StoreAction) + enum Alert: Equatable { + case confirmRestore(DevelopmentRecord.Version) + } + enum ViewAction: Equatable { case fetch + case restore(DevelopmentRecord.Version) } enum StoreAction: Equatable { - case loaded(DevelopmentRecord.Version) - case failed + case loaded(DevelopmentRecord, [DevelopmentRecord.Version]) + case restored(DevelopmentRecord.Version) + case loadFailed + case restoreFailed } } + @Dependency(\.developmentFetchRecordsUseCase) private var fetchRecordsUseCase @Dependency(\.developmentFetchRecordHistoryUseCase) private var fetchRecordHistoryUseCase + @Dependency(\.developmentRestoreRecordUseCase) private var restoreRecordUseCase + @Dependency(\.uuid) private var uuid var body: some ReducerOf { Reduce { state, action in switch action { + case .alert(.presented(.confirmRestore(let version))): + state.alert = nil + guard !state.isRestoring, + state.record.draft == nil, + version.id != state.currentVersionID else { break } + let request = state.restoreRequest?.sourceVersionID == version.id + ? state.restoreRequest + : RestoreRequest( + versionID: uuid().uuidString, + sourceVersionID: version.id + ) + guard let request else { break } + state.isRestoring = true + state.restoreRequest = request + state.restoredSourceVersionID = nil + return restoreEffect( + goalID: state.record.goalId, + recordID: state.record.id, + request: request + ) case .alert: break case .view(.fetch): - guard state.contentState == .idle || state.contentState == .failed else { break } + guard state.contentState != .loading, + !state.isRestoring, + state.record.currentVersion != nil else { break } state.alert = nil state.contentState = .loading - return fetchEffect(record: state.record) - case .store(.loaded(let version)): - state.contentState = .confirmed(version) - case .store(.failed): + state.restoredSourceVersionID = nil + return fetchEffect(goalID: state.record.goalId, recordID: state.record.id) + case .view(.restore(let version)): + guard !state.isRestoring, + state.record.draft == nil, + version.id != state.currentVersionID else { break } + state.alert = Self.restoreConfirmationAlert(version) + case .store(.loaded(let record, let versions)): + state.record = record + state.versions = versions.sorted { $0.number < $1.number } + state.currentVersionID = record.currentVersion?.id + state.restoreRequest = nil + state.contentState = .loaded + case .store(.restored(let version)): + if !state.versions.contains(where: { $0.id == version.id }) { + state.versions.append(version) + state.versions.sort { $0.number < $1.number } + } + state.currentVersionID = version.id + state.restoredSourceVersionID = state.restoreRequest?.sourceVersionID + state.restoreRequest = nil + state.isRestoring = false + state.contentState = .loaded + case .store(.loadFailed): state.contentState = .failed state.alert = Self.errorAlert + case .store(.restoreFailed): + state.isRestoring = false + state.alert = Self.restoreErrorAlert } return .none @@ -81,25 +151,48 @@ struct RecordDetailFeature { } private extension RecordDetailFeature { - func fetchEffect(record: DevelopmentRecord) -> Effect { - .run { [fetchRecordHistoryUseCase] send in + func fetchEffect(goalID: String, recordID: String) -> Effect { + .run { [fetchRecordsUseCase, fetchRecordHistoryUseCase] send in do { - let versions = try await fetchRecordHistoryUseCase.execute( - goalId: record.goalId, - recordId: record.id + async let recordValues = fetchRecordsUseCase.execute(goalId: goalID) + async let versionValues = fetchRecordHistoryUseCase.execute( + goalId: goalID, + recordId: recordID ) - guard let currentVersion = record.currentVersion, - let version = versions.first(where: { $0.id == currentVersion.id }) else { + let (records, versions) = try await (recordValues, versionValues) + guard let record = records.first(where: { $0.id == recordID }), + let currentVersion = record.currentVersion, + versions.contains(where: { $0.id == currentVersion.id }) else { throw DomainLayerError.developmentRecordVersionNotFound } - await send(.store(.loaded(version))) + await send(.store(.loaded(record, versions))) + } catch { + await send(.store(.loadFailed)) + } + } + } + + func restoreEffect( + goalID: String, + recordID: String, + request: RestoreRequest + ) -> Effect { + .run { [restoreRecordUseCase] send in + do { + let version = try await restoreRecordUseCase.execute( + goalId: goalID, + recordId: recordID, + versionId: request.versionID, + sourceVersionId: request.sourceVersionID + ) + await send(.store(.restored(version))) } catch { - await send(.store(.failed)) + await send(.store(.restoreFailed)) } } } - static var errorAlert: AlertState { + static var errorAlert: AlertState { AlertState { TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) } actions: { @@ -113,4 +206,50 @@ private extension RecordDetailFeature { )) } } + + static var restoreErrorAlert: AlertState { + AlertState { + TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_close", bundle: PresentationResources.bundle)) + } + } message: { + TextState(String( + localized: "development_record_restore_error_message", + bundle: PresentationResources.bundle + )) + } + } +} + +extension RecordDetailFeature { + static func restoreConfirmationAlert( + _ version: DevelopmentRecord.Version + ) -> AlertState { + AlertState { + TextState(String( + localized: "development_record_restore_alert_title", + bundle: PresentationResources.bundle + )) + } actions: { + ButtonState(role: .cancel) { + TextState(String(localized: "common_cancel", bundle: PresentationResources.bundle)) + } + ButtonState(action: .confirmRestore(version)) { + TextState(String( + localized: "development_record_restore_alert_confirm", + bundle: PresentationResources.bundle + )) + } + } message: { + TextState(String.localizedStringWithFormat( + String( + localized: "development_record_restore_alert_message_format", + bundle: PresentationResources.bundle + ), + RecordPresentation.versionLabel(version.number) + )) + } + } } diff --git a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift index 9a39dec2..1e53b5fd 100644 --- a/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/Detail/RecordDetailView.swift @@ -10,9 +10,18 @@ import Domain import PresentationShared public struct RecordDetailView: View { + @Environment(\.dismiss) private var dismiss + private let onUpdate: () -> Void @State private var store: StoreOf + @State private var isEditorPresented = false + @State private var isHistoryPresented = false - public init(goalTitle: String, record: DevelopmentRecord) { + public init( + goalTitle: String, + record: DevelopmentRecord, + onUpdate: @escaping () -> Void = { } + ) { + self.onUpdate = onUpdate self._store = State(initialValue: Store( initialState: RecordDetailFeature.State( goalTitle: goalTitle, @@ -33,12 +42,15 @@ public struct RecordDetailView: View { markdownContent: draft.markdownContent, version: nil ) - case .confirmed(let version): - recordContent( - title: version.title, - markdownContent: version.markdownContent, - version: version - ) + case .loaded: + if let version = store.currentVersion { + recordContent( + title: version.title, + markdownContent: version.markdownContent, + version: version + ) + actionSection + } case .failed: failureContent case .idle, .loading: @@ -47,15 +59,42 @@ public struct RecordDetailView: View { } .padding() } + .safeAreaInset(edge: .top, spacing: 0) { topBar } .background(Color.appBackground.ignoresSafeArea()) - .navigationBarTitleDisplayMode(.inline) + .toolbarVisibility(.hidden, for: .navigationBar) .onAppear { store.send(.view(.fetch)) } .prominentAlert(store, state: \.alert, action: \.alert) + .sheet(isPresented: $isEditorPresented) { + RecordEditorView( + goalId: store.record.goalId, + goalTitle: store.goalTitle, + record: store.record, + baseVersion: store.currentVersion, + onCompletion: finishEditing + ) + } + .navigationDestination(isPresented: $isHistoryPresented) { + RecordVersionHistoryView(store: store) + } .overlay { - if store.isLoading { + if store.isLoading || store.isRestoring { LoadingView() } } + .onChange(of: store.restoredSourceVersionID) { _, sourceVersionID in + guard sourceVersionID != nil else { return } + onUpdate() + } + } + + private var topBar: some View { + HStack { + RecordBackButton(action: dismiss.callAsFunction) + .disabled(store.isRestoring) + Spacer() + } + .padding(.horizontal) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) } @ViewBuilder @@ -81,12 +120,19 @@ public struct RecordDetailView: View { .scrollIndicators(.hidden) HStack(spacing: 10) { - Text(statusText(isDraft: version == nil)) + Text( + version == nil + ? RecordPresentation.text("development_record_draft") + : RecordPresentation.text("development_record_confirmed") + ) .font(.callout.weight(.semibold)) - .foregroundStyle(statusColor(isDraft: version == nil)) + .foregroundStyle(version == nil ? Color.warning : Color.white) .padding(.horizontal, 12) .padding(.vertical, 7) - .background(statusBackground(isDraft: version == nil), in: .capsule) + .background( + version == nil ? Color.warning.opacity(0.12) : Color.accent, + in: .capsule + ) if let version { Text(RecordPresentation.versionLabel(version.number)) @@ -143,17 +189,58 @@ public struct RecordDetailView: View { .background(Color.surface, in: .rect(cornerRadius: 24)) } - private func statusText(isDraft: Bool) -> String { - isDraft - ? RecordPresentation.text("development_record_draft") - : RecordPresentation.text("development_record_confirmed") + private var actionSection: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + correctionButton + historyButton + } + + VStack(spacing: 12) { + correctionButton + historyButton + } + } + } + + private var correctionButton: some View { + Button { + isEditorPresented = true + } label: { + Label( + RecordPresentation.text("development_record_correct"), + systemImage: "pencil" + ) + .font(.headline) + .foregroundStyle(Color.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .accent) } - private func statusColor(isDraft: Bool) -> Color { - isDraft ? .warning : .white + private var historyButton: some View { + Button { + isHistoryPresented = true + } label: { + Label( + RecordPresentation.text("development_record_history_title"), + systemImage: "clock.arrow.circlepath" + ) + .font(.headline) + .foregroundStyle(Color.onPrimaryContainer) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .adaptiveButtonStyle( + shape: RoundedRectangle(cornerRadius: 16), + color: .primaryContainer + ) } - private func statusBackground(isDraft: Bool) -> Color { - isDraft ? .warning.opacity(0.12) : .accent + private func finishEditing() { + isEditorPresented = false + store.send(.view(.fetch)) + onUpdate() } } diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift index 0d8612b7..f0b33a95 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorFeature.swift @@ -18,6 +18,7 @@ struct RecordEditorFeature { let goalTitle: String let recordId: String let versionId: String + let baseVersion: DevelopmentRecord.Version? var record: DevelopmentRecord? var title: String var markdownContent: String @@ -27,16 +28,23 @@ struct RecordEditorFeature { var confirmationPreparation: ConfirmationPreparation? var versionNumber: Int { - (record?.currentVersion?.number ?? 0) + 1 + (baseVersion?.number ?? record?.currentVersion?.number ?? 0) + 1 + } + + var baseVersionID: String? { + record?.draft?.baseVersionId ?? baseVersion?.id ?? record?.currentVersion?.id + } + + var isCorrection: Bool { + baseVersionID != nil } var isReadyToSave: Bool { !isLoading - && record?.currentVersion == nil && !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - var canConfirmInitialVersion: Bool { + var canConfirmVersion: Bool { isReadyToSave } @@ -44,6 +52,7 @@ struct RecordEditorFeature { goalId: String, goalTitle: String, record: DevelopmentRecord? = nil, + baseVersion: DevelopmentRecord.Version? = nil, recordId: String = UUID().uuidString, versionId: String = UUID().uuidString ) { @@ -51,9 +60,10 @@ struct RecordEditorFeature { self.goalTitle = goalTitle self.recordId = record?.id ?? recordId self.versionId = versionId + self.baseVersion = baseVersion self.record = record - self.title = record?.draft?.title ?? "" - self.markdownContent = record?.draft?.markdownContent ?? "" + self.title = record?.draft?.title ?? baseVersion?.title ?? "" + self.markdownContent = record?.draft?.markdownContent ?? baseVersion?.markdownContent ?? "" } } @@ -115,7 +125,7 @@ struct RecordEditorFeature { state.confirmationPreparation = nil return saveEffect(state: state) case .view(.confirm): - guard state.canConfirmInitialVersion else { break } + guard state.canConfirmVersion else { break } state.isLoading = true state.result = nil if let preparation = state.confirmationPreparation, @@ -125,6 +135,7 @@ struct RecordEditorFeature { goalId: state.goalId, recordId: preparation.record.id, versionId: state.versionId, + baseVersionId: state.baseVersionID, draftRevisionId: preparation.record.draft?.revisionId ) } @@ -147,6 +158,7 @@ struct RecordEditorFeature { goalId: state.goalId, recordId: record.id, versionId: state.versionId, + baseVersionId: state.baseVersionID, draftRevisionId: record.draft?.revisionId ) case .store(.confirmed(let version)): @@ -175,7 +187,7 @@ private extension RecordEditorFeature { record = try await saveRecordDraftUseCase.execute( goalId: state.goalId, recordId: recordId, - baseVersionId: nil, + baseVersionId: state.baseVersionID, draftRevisionId: state.record?.draft?.revisionId, title: state.title, markdownContent: state.markdownContent @@ -203,7 +215,7 @@ private extension RecordEditorFeature { record = try await saveRecordDraftUseCase.execute( goalId: state.goalId, recordId: recordId, - baseVersionId: nil, + baseVersionId: state.baseVersionID, draftRevisionId: state.record?.draft?.revisionId, title: state.title, markdownContent: state.markdownContent @@ -227,6 +239,7 @@ private extension RecordEditorFeature { goalId: String, recordId: String, versionId: String, + baseVersionId: String?, draftRevisionId: String? ) -> Effect { .run { [confirmRecordUseCase] send in @@ -235,7 +248,7 @@ private extension RecordEditorFeature { goalId: goalId, recordId: recordId, versionId: versionId, - baseVersionId: nil, + baseVersionId: baseVersionId, draftRevisionId: draftRevisionId ) await send(.store(.confirmed(version))) diff --git a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift index 387a4515..fbf711ba 100644 --- a/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift +++ b/Application/Presentation/Development/Sources/Record/Editor/RecordEditorView.swift @@ -23,13 +23,15 @@ public struct RecordEditorView: View { goalId: String, goalTitle: String, record: DevelopmentRecord? = nil, + baseVersion: DevelopmentRecord.Version? = nil, onCompletion: @escaping () -> Void = { } ) { self._store = State(initialValue: Store( initialState: RecordEditorFeature.State( goalId: goalId, goalTitle: goalTitle, - record: record + record: record, + baseVersion: baseVersion ) ) { RecordEditorFeature() @@ -78,7 +80,11 @@ public struct RecordEditorView: View { .topBarButtonStyle() .disabled(store.isLoading) Spacer() - Text(RecordPresentation.text("development_record_editor_title")) + Text(RecordPresentation.text( + store.isCorrection + ? "development_record_editor_correction_title" + : "development_record_editor_title" + )) .font(.headline) Spacer() Button { @@ -187,7 +193,11 @@ public struct RecordEditorView: View { ProgressView() .tint(Color.white) } else { - Text(RecordPresentation.text("development_record_confirm")) + Text(RecordPresentation.text( + store.isCorrection + ? "development_record_confirm_correction" + : "development_record_confirm" + )) } } .font(.headline) @@ -196,7 +206,7 @@ public struct RecordEditorView: View { .padding(.vertical, 10) } .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .accent) - .disabled(!store.canConfirmInitialVersion) + .disabled(!store.canConfirmVersion) Text(String.localizedStringWithFormat( RecordPresentation.text("development_record_confirm_hint_format"), @@ -209,6 +219,7 @@ public struct RecordEditorView: View { .padding(.vertical, 12) .background(Color.surface, ignoresSafeAreaEdges: .bottom) } + } private extension View { diff --git a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift index dbfc8664..2fb1fbe2 100644 --- a/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift +++ b/Application/Presentation/Development/Sources/Record/GoalDetail/GoalDetailView.swift @@ -50,7 +50,8 @@ public struct GoalDetailView: View { .navigationDestination(item: $detailDestination) { destination in RecordDetailView( goalTitle: store.goalTitle, - record: destination.record + record: destination.record, + onUpdate: refresh ) } .toolbarBackground(Color.appBackground) @@ -164,6 +165,10 @@ public struct GoalDetailView: View { private func finishEditing() { editorDestination = nil + refresh() + } + + private func refresh() { store.send(.view(.refresh)) } } diff --git a/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift b/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift new file mode 100644 index 00000000..abf3fc4c --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/History/RecordVersionDetailView.swift @@ -0,0 +1,171 @@ +// +// RecordVersionDetailView.swift +// Development +// +// Created by opfic on 9/14/26. +// + +import SwiftUI +import Domain +import PresentationShared + +struct RecordVersionDetailView: View { + @Environment(\.dismiss) private var dismiss + + let store: StoreOf + let version: DevelopmentRecord.Version + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 20) { + titleSection + contentCard + currentVersionCard + } + .padding(.horizontal) + .padding(.bottom, 20) + } + .safeAreaInset(edge: .top, spacing: 0) { topBar } + .safeAreaInset(edge: .bottom, spacing: 0) { restoreBar } + .background(Color.appBackground.ignoresSafeArea()) + .toolbarVisibility(.hidden, for: .navigationBar) + .prominentAlert( + store, + state: \.alert, + action: \.alert + ) + .overlay { + if store.isRestoring { + LoadingView() + } + } + .onChange(of: store.restoredSourceVersionID) { _, sourceVersionID in + guard sourceVersionID == version.id else { return } + dismiss() + } + } + + private var topBar: some View { + HStack { + RecordBackButton(action: dismiss.callAsFunction) + .disabled(store.isRestoring) + Spacer() + } + .padding(.horizontal) + .padding(.bottom, 12) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) + } + + private var titleSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text(RecordPresentation.text("development_record_previous_version_title")) + .font(.largeTitle.bold()) + + Text(version.title) + .font(.title3) + .foregroundStyle(Color.textSecondary) + .lineLimit(2) + + HStack(spacing: 8) { + RecordVersionBadge(number: version.number, isCurrent: false) + kindBadge + Text(version.confirmedAt, format: .dateTime.month().day().hour().minute()) + .font(.caption) + .foregroundStyle(Color.textTertiary) + } + } + } + + private var contentCard: some View { + Group { + if version.markdownContent.isEmpty { + ContentUnavailableView( + RecordPresentation.text("development_record_content_empty_title"), + systemImage: "doc.text" + ) + } else { + MarkdownContentView(content: version.markdownContent) + .frame(minHeight: 420) + } + } + .padding(.vertical, 16) + .background(Color.surface, in: .rect(cornerRadius: 24)) + } + + private var currentVersionCard: some View { + Label { + if store.record.draft != nil { + Text(RecordPresentation.text("development_record_restore_draft_message")) + } else { + Text(String.localizedStringWithFormat( + RecordPresentation.text("development_record_restore_current_version_format"), + RecordPresentation.versionLabel(store.currentVersion?.number ?? version.number) + )) + } + } icon: { + Image( + systemName: store.record.draft == nil ? "clock.arrow.circlepath" : "pencil" + ) + .foregroundStyle(store.record.draft == nil ? Color.accent : Color.warning) + } + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .background(Color.surfaceSecondary, in: .rect(cornerRadius: 18)) + } + + private var restoreBar: some View { + Button { + store.send(.view(.restore(version))) + } label: { + Text(RecordPresentation.text("development_record_restore_button")) + .font(.headline) + .foregroundStyle(Color.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .adaptiveButtonStyle(shape: RoundedRectangle(cornerRadius: 16), color: .accent) + .disabled(store.isRestoring || store.record.draft != nil) + .padding(.horizontal) + .padding(.vertical, 12) + .background(Color.surface, ignoresSafeAreaEdges: .bottom) + } + + @ViewBuilder + private var kindBadge: some View { + switch version.kind { + case .initial: + statusBadge( + RecordPresentation.text("development_record_version_initial"), + foreground: .textSecondary, + background: .surfaceSecondary + ) + case .correction: + statusBadge( + RecordPresentation.text("development_record_version_correction"), + foreground: .accent, + background: .primaryContainer + ) + case .rollback: + statusBadge( + RecordPresentation.text("development_record_version_rollback"), + foreground: .warning, + background: .warning.opacity(0.12) + ) + } + } + + private func statusBadge( + _ title: String, + foreground: Color, + background: Color + ) -> some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(foreground) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(background, in: .rect(cornerRadius: 8)) + } +} diff --git a/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift b/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift new file mode 100644 index 00000000..fd89cc2e --- /dev/null +++ b/Application/Presentation/Development/Sources/Record/History/RecordVersionHistoryView.swift @@ -0,0 +1,276 @@ +// +// RecordVersionHistoryView.swift +// Development +// +// Created by opfic on 9/14/26. +// + +import SwiftUI +import Domain +import PresentationShared + +struct RecordVersionHistoryView: View { + @Environment(\.dismiss) private var dismiss + @State private var destination: VersionDestination? + + let store: StoreOf + + private var sortedVersions: [DevelopmentRecord.Version] { + store.versions.sorted { $1.number < $0.number } + } + + private var currentVersion: DevelopmentRecord.Version? { + store.currentVersion + } + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 24) { + titleSection + currentVersionCard + historyCard + } + .padding(.horizontal) + .padding(.bottom, 24) + } + .safeAreaInset(edge: .top, spacing: 0) { topBar } + .safeAreaInset(edge: .bottom, spacing: 0) { footer } + .background(Color.appBackground) + .toolbarVisibility(.hidden, for: .navigationBar) + .navigationDestination(item: $destination) { destination in + RecordVersionDetailView( + store: store, + version: destination.version + ) + } + } + + private var topBar: some View { + HStack { + RecordBackButton(action: dismiss.callAsFunction) + Spacer() + } + .padding(.horizontal) + .padding(.bottom, 12) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) + } + + private var titleSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text(RecordPresentation.text("development_record_history_title")) + .font(.largeTitle.bold()) + + if let currentVersion { + Text(currentVersion.title) + .font(.title3) + .foregroundStyle(Color.textSecondary) + .lineLimit(2) + } + } + } + + @ViewBuilder + private var currentVersionCard: some View { + if let currentVersion { + HStack(alignment: .center, spacing: 16) { + Image(systemName: "doc.text") + .font(.title2) + .foregroundStyle(Color.accent) + .frame(width: 56, height: 56) + .background(Color.accent.opacity(0.12), in: .rect(cornerRadius: 16)) + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(RecordPresentation.text("development_record_current_version")) + .font(.headline) + + RecordVersionBadge(number: currentVersion.number, isCurrent: false) + } + + Text(RecordPresentation.text("development_record_current_version_message")) + .font(.subheadline) + .foregroundStyle(Color.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + .background(Color.primaryContainer, in: .rect(cornerRadius: 24)) + } + } + + private var historyCard: some View { + LazyVStack(spacing: 0) { + ForEach(Array(sortedVersions.enumerated()), id: \.element.id) { index, version in + Button { + if version.id == store.currentVersionID { + dismiss() + } else { + destination = VersionDestination(version: version) + } + } label: { + VersionHistoryRow( + version: version, + isCurrent: version.id == store.currentVersionID, + isLast: index == sortedVersions.count - 1 + ) + } + .buttonStyle(.plain) + } + } + .padding(24) + .background(Color.surface, in: .rect(cornerRadius: 28)) + } + + private var footer: some View { + Label( + RecordPresentation.text("development_record_history_footer"), + systemImage: "lock" + ) + .font(.caption) + .foregroundStyle(Color.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(Color.appBackground, ignoresSafeAreaEdges: .bottom) + } +} + +private struct VersionHistoryRow: View { + let version: DevelopmentRecord.Version + let isCurrent: Bool + let isLast: Bool + + var body: some View { + HStack(spacing: 8) { + HStack(alignment: .top, spacing: 16) { + timelineIndicator + + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + RecordVersionBadge(number: version.number, isCurrent: false) + + if isCurrent { + statusBadge( + RecordPresentation.text("development_record_version_current"), + foreground: .white, + background: .accent + ) + } + + kindBadge + } + + Text(version.confirmedAt, format: .dateTime.month().day().hour().minute()) + .font(.caption) + .foregroundStyle(Color.textTertiary) + + Text(version.title) + .font(.body.weight(.semibold)) + .foregroundStyle(Color.primary) + .multilineTextAlignment(.leading) + .lineLimit(2) + } + } + + Spacer(minLength: 8) + + Image(systemName: "chevron.right") + .font(.callout.weight(.semibold)) + .foregroundStyle(Color.border) + } + .contentShape(.rect) + .background(alignment: .topLeading) { + if !isLast { + Rectangle() + .fill(Color.accent.opacity(0.45)) + .frame(width: 2) + .frame(maxHeight: .infinity) + .offset(x: 5.5, y: 16) + } + } + } + + private var timelineIndicator: some View { + Circle() + .fill(isCurrent ? Color.accent : Color.textTertiary) + .frame(width: 13, height: 13) + .overlay { + Circle() + .strokeBorder(isCurrent ? Color.accent : Color.border, lineWidth: 2) + } + .padding(.top, 3) + } + + @ViewBuilder + private var kindBadge: some View { + switch version.kind { + case .initial: + statusBadge( + RecordPresentation.text("development_record_version_initial"), + foreground: .textSecondary, + background: .surfaceSecondary + ) + case .correction: + statusBadge( + RecordPresentation.text("development_record_version_correction"), + foreground: .accent, + background: .primaryContainer + ) + case .rollback: + statusBadge( + RecordPresentation.text("development_record_version_rollback"), + foreground: .warning, + background: .warning.opacity(0.12) + ) + } + } + + private func statusBadge( + _ title: String, + foreground: Color, + background: Color + ) -> some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(foreground) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(background, in: .rect(cornerRadius: 8)) + } +} + +struct RecordVersionBadge: View { + let number: Int + let isCurrent: Bool + + var body: some View { + Text(RecordPresentation.versionLabel(number)) + .font(.caption.weight(.semibold)) + .foregroundStyle(isCurrent ? Color.white : Color.accent) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + isCurrent ? Color.accent : Color.primaryContainer, + in: .capsule + ) + } +} + +struct RecordBackButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "chevron.left") + .font(.title3.weight(.semibold)) + .frame(width: 28, height: 28) + } + .adaptiveButtonStyle(shape: .circle, color: .surface, glassEffect: .enabled) + } +} + +private struct VersionDestination: Identifiable { + let version: DevelopmentRecord.Version + var id: String { version.id } +} diff --git a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift index fa7d84d0..c169d679 100644 --- a/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/GoalDetailFeatureTests.swift @@ -50,6 +50,55 @@ struct GoalDetailFeatureTests { } } + @Test("타임라인 새로고침은 변경된 현재 버전을 반영한다") + func 타임라인_새로고침은_변경된_현재_버전을_반영한다() async throws { + let goal = try makeDevelopmentGoal() + let previousVersion = try makeDevelopmentRecordVersion(id: "version-1") + let currentVersion = try makeDevelopmentRecordVersion( + id: "version-2", + number: 2, + title: "변경된 기록", + kind: .correction, + sourceVersionId: previousVersion.id + ) + let previousRecord = try makeConfirmedDevelopmentRecord(versionId: previousVersion.id) + let currentRecord = try makeConfirmedDevelopmentRecord( + versionId: currentVersion.id, + versionNumber: currentVersion.number + ) + let previousItem = RecordTimelineItem( + record: previousRecord, + currentVersion: previousVersion + ) + let currentItem = RecordTimelineItem( + record: currentRecord, + currentVersion: currentVersion + ) + var state = GoalDetailFeature.State(goalId: goal.id) + state.goalTitle = goal.title + state.items = [previousItem] + state.hasLoaded = true + let store = TestStore(initialState: state) { + GoalDetailFeature() + } withDependencies: { + $0.developmentFetchGoalUseCase = FetchDevelopmentGoalUseCaseStub(result: .success(goal)) + $0.developmentFetchRecordsUseCase = FetchDevelopmentRecordsUseCaseStub( + result: .success([currentRecord]) + ) + $0.developmentFetchRecordVersionUseCase = FetchDevelopmentRecordVersionUseCaseStub( + resultByRecordId: [currentRecord.id: .success(currentVersion)] + ) + } + + await store.send(.view(.refresh)) { + $0.isLoading = true + } + await store.receive(.store(.loaded(goalTitle: goal.title, items: [currentItem]))) { + $0.items = [currentItem] + $0.isLoading = false + } + } + @Test("타임라인 조회 실패는 입력 가능한 재조회 상태를 유지한다") func 타임라인_조회_실패는_입력_가능한_재조회_상태를_유지한다() async throws { let goal = try makeDevelopmentGoal() diff --git a/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift b/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift index 64271a2b..5ead5cfd 100644 --- a/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/RecordDetailFeatureTests.swift @@ -5,6 +5,7 @@ // Created by opfic on 9/13/26. // +import Foundation import Testing import PresentationShared @testable import Development @@ -23,6 +24,9 @@ struct RecordDetailFeatureTests { ) { RecordDetailFeature() } withDependencies: { + $0.developmentFetchRecordsUseCase = FetchDevelopmentRecordsUseCaseStub( + result: .success([record]) + ) $0.developmentFetchRecordHistoryUseCase = FetchDevelopmentRecordHistoryUseCaseStub( resultByRecordId: [record.id: .success([version])] ) @@ -31,8 +35,9 @@ struct RecordDetailFeatureTests { await store.send(.view(.fetch)) { $0.contentState = .loading } - await store.receive(.store(.loaded(version))) { - $0.contentState = .confirmed(version) + await store.receive(.store(.loaded(record, [version]))) { + $0.versions = [version] + $0.contentState = .loaded } } @@ -64,6 +69,9 @@ struct RecordDetailFeatureTests { ) { RecordDetailFeature() } withDependencies: { + $0.developmentFetchRecordsUseCase = FetchDevelopmentRecordsUseCaseStub( + result: .success([record]) + ) $0.developmentFetchRecordHistoryUseCase = FetchDevelopmentRecordHistoryUseCaseStub( resultByRecordId: [record.id: .failure(RecordTestError.failed)] ) @@ -72,7 +80,7 @@ struct RecordDetailFeatureTests { await store.send(.view(.fetch)) { $0.contentState = .loading } - await store.receive(.store(.failed)) { + await store.receive(.store(.loadFailed)) { $0.contentState = .failed $0.alert = makeRecordErrorAlert("development_record_detail_error_message") } @@ -82,9 +90,213 @@ struct RecordDetailFeatureTests { await store.send(.view(.fetch)) { $0.contentState = .loading } - await store.receive(.store(.failed)) { + await store.receive(.store(.loadFailed)) { $0.contentState = .failed $0.alert = makeRecordErrorAlert("development_record_detail_error_message") } } + + @Test("이전 버전 되돌리기는 새 버전을 현재 버전으로 반영한다") + func 이전_버전_되돌리기는_새_버전을_현재_버전으로_반영한다() async throws { + let restoreVersionID = "00000000-0000-0000-0000-000000000003" + let restoreUUID = try #require(UUID(uuidString: restoreVersionID)) + let initialVersion = try makeDevelopmentRecordVersion(id: "version-1") + let currentVersion = try makeDevelopmentRecordVersion( + id: "version-2", + number: 2, + kind: .correction, + sourceVersionId: initialVersion.id + ) + let restoredVersion = try makeDevelopmentRecordVersion( + id: restoreVersionID, + number: 3, + kind: .rollback, + sourceVersionId: initialVersion.id + ) + let record = try makeConfirmedDevelopmentRecord( + versionId: currentVersion.id, + versionNumber: currentVersion.number + ) + let spy = RestoreDevelopmentRecordUseCaseSpy(result: .success(restoredVersion)) + var state = RecordDetailFeature.State(goalTitle: "개발 목표", record: record) + state.versions = [initialVersion, currentVersion] + state.contentState = .loaded + let store = TestStore(initialState: state) { + RecordDetailFeature() + } withDependencies: { + $0.developmentRestoreRecordUseCase = spy + $0.uuid = .constant(restoreUUID) + } + + await store.send(.view(.restore(initialVersion))) { + $0.alert = RecordDetailFeature.restoreConfirmationAlert(initialVersion) + } + await store.send(.alert(.presented(.confirmRestore(initialVersion)))) { + $0.alert = nil + $0.isRestoring = true + $0.restoreRequest = .init( + versionID: restoreVersionID, + sourceVersionID: initialVersion.id + ) + } + await store.receive(.store(.restored(restoredVersion))) { + $0.versions = [initialVersion, currentVersion, restoredVersion] + $0.currentVersionID = restoredVersion.id + $0.isRestoring = false + $0.restoreRequest = nil + $0.restoredSourceVersionID = initialVersion.id + } + + #expect(await spy.requests() == [ + .init( + goalId: record.goalId, + recordId: record.id, + versionId: restoreVersionID, + sourceVersionId: initialVersion.id + ) + ]) + } + + @Test("되돌리기 실패 재시도는 같은 버전 ID를 사용한다") + func 되돌리기_실패_재시도는_같은_버전_ID를_사용한다() async throws { + let restoreVersionID = "00000000-0000-0000-0000-000000000003" + let restoreUUID = try #require(UUID(uuidString: restoreVersionID)) + let initialVersion = try makeDevelopmentRecordVersion(id: "version-1") + let currentVersion = try makeDevelopmentRecordVersion( + id: "version-2", + number: 2, + kind: .correction, + sourceVersionId: initialVersion.id + ) + let record = try makeConfirmedDevelopmentRecord( + versionId: currentVersion.id, + versionNumber: currentVersion.number + ) + let restoredVersion = try makeDevelopmentRecordVersion( + id: restoreVersionID, + number: 3, + kind: .rollback, + sourceVersionId: initialVersion.id + ) + let spy = RestoreDevelopmentRecordUseCaseSpy( + results: [ + .failure(RecordTestError.failed), + .success(restoredVersion) + ] + ) + var state = RecordDetailFeature.State(goalTitle: "개발 목표", record: record) + state.versions = [initialVersion, currentVersion] + state.contentState = .loaded + let store = TestStore(initialState: state) { + RecordDetailFeature() + } withDependencies: { + $0.developmentRestoreRecordUseCase = spy + $0.uuid = .constant(restoreUUID) + } + + await store.send(.view(.restore(initialVersion))) { + $0.alert = RecordDetailFeature.restoreConfirmationAlert(initialVersion) + } + await store.send(.alert(.presented(.confirmRestore(initialVersion)))) { + $0.alert = nil + $0.isRestoring = true + $0.restoreRequest = .init( + versionID: restoreVersionID, + sourceVersionID: initialVersion.id + ) + } + await store.receive(.store(.restoreFailed)) { + $0.isRestoring = false + $0.alert = makeRecordErrorAlert("development_record_restore_error_message") + } + await store.send(.alert(.dismiss)) { + $0.alert = nil + } + await store.send(.view(.restore(initialVersion))) { + $0.alert = RecordDetailFeature.restoreConfirmationAlert(initialVersion) + } + await store.send(.alert(.presented(.confirmRestore(initialVersion)))) { + $0.alert = nil + $0.isRestoring = true + } + await store.receive(.store(.restored(restoredVersion))) { + $0.versions = [initialVersion, currentVersion, restoredVersion] + $0.currentVersionID = restoredVersion.id + $0.isRestoring = false + $0.restoreRequest = nil + $0.restoredSourceVersionID = initialVersion.id + } + + #expect(await spy.requests() == [ + .init( + goalId: record.goalId, + recordId: record.id, + versionId: restoreVersionID, + sourceVersionId: initialVersion.id + ), + .init( + goalId: record.goalId, + recordId: record.id, + versionId: restoreVersionID, + sourceVersionId: initialVersion.id + ) + ]) + } + + @Test("정정 초안이 있으면 이전 버전 되돌리기를 시작하지 않는다") + func 정정_초안이_있으면_이전_버전_되돌리기를_시작하지_않는다() async throws { + let initialVersion = try makeDevelopmentRecordVersion(id: "version-1") + let currentVersion = try makeDevelopmentRecordVersion( + id: "version-2", + number: 2, + kind: .correction, + sourceVersionId: initialVersion.id + ) + let record = try makeConfirmedDevelopmentRecord( + versionId: currentVersion.id, + versionNumber: currentVersion.number, + draft: makeDevelopmentRecordDraft(baseVersionId: currentVersion.id) + ) + var state = RecordDetailFeature.State(goalTitle: "개발 목표", record: record) + state.versions = [initialVersion, currentVersion] + state.contentState = .loaded + let store = TestStore(initialState: state) { + RecordDetailFeature() + } + + await store.send(.view(.restore(initialVersion))) + } + + @Test("되돌리기 확인 취소는 복원을 시작하지 않는다") + func 되돌리기_확인_취소는_복원을_시작하지_않는다() async throws { + let initialVersion = try makeDevelopmentRecordVersion(id: "version-1") + let currentVersion = try makeDevelopmentRecordVersion( + id: "version-2", + number: 2, + kind: .correction, + sourceVersionId: initialVersion.id + ) + let record = try makeConfirmedDevelopmentRecord( + versionId: currentVersion.id, + versionNumber: currentVersion.number + ) + let spy = RestoreDevelopmentRecordUseCaseSpy(result: .failure(RecordTestError.failed)) + var state = RecordDetailFeature.State(goalTitle: "개발 목표", record: record) + state.versions = [initialVersion, currentVersion] + state.contentState = .loaded + let store = TestStore(initialState: state) { + RecordDetailFeature() + } withDependencies: { + $0.developmentRestoreRecordUseCase = spy + } + + await store.send(.view(.restore(initialVersion))) { + $0.alert = RecordDetailFeature.restoreConfirmationAlert(initialVersion) + } + await store.send(.alert(.dismiss)) { + $0.alert = nil + } + + #expect(await spy.requests().isEmpty) + } } diff --git a/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift b/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift index 7f6d1ce6..cab50b00 100644 --- a/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift +++ b/Application/Presentation/Development/Tests/Record/RecordEditorFeatureTests.swift @@ -88,20 +88,70 @@ struct RecordEditorFeatureTests { await store.receive(.delegate(.saved(updatedRecord))) } - @Test("확정된 기록은 #871 전까지 저장과 확정을 허용하지 않는다") - func 확정된_기록은_871_전까지_저장과_확정을_허용하지_않는다() async throws { - let record = try makeConfirmedDevelopmentRecord() - let version = try makeDevelopmentRecordVersion() - let store = makeStore( - record: record, - createResult: .failure(RecordTestError.failed), - confirmResult: .success(version) + @Test("확정된 기록은 현재 내용을 바탕으로 correction 버전을 만든다") + func 확정된_기록은_현재_내용을_바탕으로_correction_버전을_만든다() async throws { + let baseVersion = try makeDevelopmentRecordVersion(id: "version-1") + let record = try makeConfirmedDevelopmentRecord(versionId: baseVersion.id) + let preparedRecord = try makeConfirmedDevelopmentRecord( + versionId: baseVersion.id, + draft: makeDevelopmentRecordDraft(baseVersionId: baseVersion.id) ) + let version = try makeDevelopmentRecordVersion( + id: "version-2", + number: 2, + kind: .correction, + sourceVersionId: baseVersion.id + ) + let spy = ConfirmDevelopmentRecordUseCaseSpy(results: [.success(version)]) + let store = TestStore( + initialState: RecordEditorFeature.State( + goalId: "goal", + goalTitle: "개발 목표", + record: record, + baseVersion: baseVersion, + versionId: version.id + ) + ) { + RecordEditorFeature() + } withDependencies: { + $0.developmentSaveRecordDraftUseCase = SaveDevelopmentRecordDraftUseCaseStub( + result: .success(preparedRecord) + ) + $0.developmentConfirmRecordUseCase = spy + } - #expect(!store.state.isReadyToSave) - #expect(!store.state.canConfirmInitialVersion) - await store.send(.view(.save)) - await store.send(.view(.confirm)) + #expect(store.state.title == baseVersion.title) + #expect(store.state.markdownContent == baseVersion.markdownContent) + #expect(store.state.versionNumber == 2) + #expect(store.state.isCorrection) + + await store.send(.view(.confirm)) { + $0.isLoading = true + } + await store.receive(.store(.preparedForConfirmation(preparedRecord))) { + $0.record = preparedRecord + $0.confirmationPreparation = .init( + record: preparedRecord, + title: baseVersion.title, + markdownContent: baseVersion.markdownContent + ) + } + await store.receive(.store(.confirmed(version))) { + $0.isLoading = false + $0.result = .confirmed(version) + $0.confirmationPreparation = nil + } + await store.receive(.delegate(.confirmed(version))) + + #expect(await spy.requests() == [ + .init( + goalId: record.goalId, + recordId: record.id, + versionId: version.id, + baseVersionId: baseVersion.id, + draftRevisionId: preparedRecord.draft?.revisionId + ) + ]) } @Test("새 기록 생성 재시도는 같은 recordId를 사용한다") diff --git a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift index b6fe4236..df532552 100644 --- a/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift +++ b/Application/Presentation/Development/Tests/Record/RecordFeatureTestSupport.swift @@ -13,7 +13,9 @@ enum RecordTestError: Error { case failed } -func makeRecordErrorAlert(_ messageKey: String.LocalizationValue) -> AlertState { +func makeRecordErrorAlert( + _ messageKey: String.LocalizationValue +) -> AlertState { AlertState { TextState(String(localized: "common_error_title", bundle: PresentationResources.bundle)) } actions: { @@ -80,10 +82,10 @@ struct CreateDevelopmentRecordUseCaseStub: CreateDevelopmentRecordUseCase { struct SaveDevelopmentRecordDraftUseCaseStub: SaveDevelopmentRecordDraftUseCase { let result: Result + // swiftlint:disable:next function_parameter_count func execute( goalId: String, recordId: String, - versionId: String, baseVersionId: String?, draftRevisionId: String?, title: String, @@ -99,6 +101,7 @@ struct ConfirmDevelopmentRecordUseCaseStub: ConfirmDevelopmentRecordUseCase { func execute( goalId: String, recordId: String, + versionId: String, baseVersionId: String?, draftRevisionId: String? ) async throws -> DevelopmentRecord.Version { @@ -106,6 +109,19 @@ struct ConfirmDevelopmentRecordUseCaseStub: ConfirmDevelopmentRecordUseCase { } } +struct RestoreDevelopmentRecordUseCaseStub: RestoreDevelopmentRecordUseCase { + let result: Result + + func execute( + goalId: String, + recordId: String, + versionId: String, + sourceVersionId: String + ) async throws -> DevelopmentRecord.Version { + try result.get() + } +} + actor CreateDevelopmentRecordUseCaseSpy: CreateDevelopmentRecordUseCase { struct Request: Equatable { let goalId: String @@ -185,6 +201,46 @@ actor ConfirmDevelopmentRecordUseCaseSpy: ConfirmDevelopmentRecordUseCase { } } +actor RestoreDevelopmentRecordUseCaseSpy: RestoreDevelopmentRecordUseCase { + struct Request: Equatable { + let goalId: String + let recordId: String + let versionId: String + let sourceVersionId: String + } + + private var results: [Result] + private var recordedRequests = [Request]() + + init(result: Result) { + self.results = [result] + } + + init(results: [Result]) { + self.results = results + } + + func execute( + goalId: String, + recordId: String, + versionId: String, + sourceVersionId: String + ) async throws -> DevelopmentRecord.Version { + recordedRequests.append(.init( + goalId: goalId, + recordId: recordId, + versionId: versionId, + sourceVersionId: sourceVersionId + )) + guard !results.isEmpty else { throw RecordTestError.failed } + return try results.removeFirst().get() + } + + func requests() -> [Request] { + recordedRequests + } +} + func makeDevelopmentGoal(title: String = "개발 목표") throws -> DevelopmentGoal { let date = Date(timeIntervalSince1970: 1_700_000_000) return try DevelopmentGoal( @@ -215,13 +271,15 @@ func makeDevelopmentRecord( func makeConfirmedDevelopmentRecord( id: String = "record", - versionId: String = "version" + versionId: String = "version", + versionNumber: Int = 1, + draft: DevelopmentRecord.Draft? = nil ) throws -> DevelopmentRecord { try DevelopmentRecord( id: id, goalId: "goal", - currentVersion: DevelopmentRecord.CurrentVersion(id: versionId, number: 1), - draft: nil, + currentVersion: DevelopmentRecord.CurrentVersion(id: versionId, number: versionNumber), + draft: draft, createdAt: Date(timeIntervalSince1970: 1_700_000_000) ) } @@ -229,12 +287,13 @@ func makeConfirmedDevelopmentRecord( func makeDevelopmentRecordDraft( title: String = "기록 제목", markdownContent: String = "# 내용", + baseVersionId: String? = nil, revisionId: String = "revision" ) throws -> DevelopmentRecord.Draft { try DevelopmentRecord.Draft( title: title, markdownContent: markdownContent, - baseVersionId: nil, + baseVersionId: baseVersionId, revisionId: revisionId, updatedAt: Date(timeIntervalSince1970: 1_700_000_100) ) @@ -243,16 +302,20 @@ func makeDevelopmentRecordDraft( func makeDevelopmentRecordVersion( id: String = "version", recordId: String = "record", - title: String = "확정 기록" + number: Int = 1, + title: String = "확정 기록", + markdownContent: String = "# 확정 내용", + kind: DevelopmentRecord.Version.Kind = .initial, + sourceVersionId: String? = nil ) throws -> DevelopmentRecord.Version { try DevelopmentRecord.Version( id: id, recordId: recordId, - number: 1, + number: number, title: title, - markdownContent: "# 확정 내용", - kind: .initial, - sourceVersionId: nil, - confirmedAt: Date(timeIntervalSince1970: 1_700_000_200) + markdownContent: markdownContent, + kind: kind, + sourceVersionId: sourceVersionId, + confirmedAt: Date(timeIntervalSince1970: 1_700_000_200 + Double(number)) ) } diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 8730645a..64f54296 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -438,6 +438,13 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "확정하기" } } } }, + "development_record_confirm_correction" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm as New Version" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 버전으로 확정" } } + } + }, "development_record_confirm_hint_format" : { "extractionState" : "manual", "localizations" : { @@ -473,6 +480,27 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "기록 이어서 작성" } } } }, + "development_record_correct" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Correct Record" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "기록 정정" } } + } + }, + "development_record_current_version" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Current Version" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "현재 버전" } } + } + }, + "development_record_current_version_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirmed versions cannot be edited or deleted." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "확정된 버전은 변경하거나 삭제하지 않아요." } } + } + }, "development_record_detail_error_message" : { "extractionState" : "manual", "localizations" : { @@ -508,6 +536,13 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록을 저장하지 못했어요. 작성한 내용은 그대로 유지돼요." } } } }, + "development_record_editor_correction_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Correct Record" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록 정정" } } + } + }, "development_record_editor_mode" : { "extractionState" : "manual", "localizations" : { @@ -536,6 +571,20 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "아직 개발 기록이 없어요" } } } }, + "development_record_history_footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Select an earlier version to review it and restore its content as a new version." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "이전 버전을 선택하면 내용을 확인하고 새 버전으로 되돌릴 수 있어요." } } + } + }, + "development_record_history_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Version History" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "버전 이력" } } + } + }, "development_record_markdown_hint" : { "extractionState" : "manual", "localizations" : { @@ -564,6 +613,13 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "미리 볼 내용이 없어요" } } } }, + "development_record_previous_version_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Previous Version" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "이전 버전" } } + } + }, "development_record_result_title" : { "extractionState" : "manual", "localizations" : { @@ -578,6 +634,55 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "저장" } } } }, + "development_record_restore_alert_confirm" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm as New Version" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 버전으로 확정" } } + } + }, + "development_record_restore_alert_message_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The content of %@ will be confirmed as a new version. Existing versions will be preserved." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "%@의 내용으로 새 버전을 확정합니다. 기존 버전은 그대로 보존돼요." } } + } + }, + "development_record_restore_alert_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Restore this previous version?" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "이전 버전으로 되돌릴까요?" } } + } + }, + "development_record_restore_button" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Restore This Version" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "이 버전으로 되돌리기" } } + } + }, + "development_record_restore_current_version_format" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The current version is %@. Restoring this content creates a new version." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "현재 버전은 %@이에요. 이 내용으로 되돌리면 새 버전이 생성돼요." } } + } + }, + "development_record_restore_draft_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm the saved correction draft before restoring an earlier version." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "저장된 정정 초안을 먼저 새 버전으로 확정해야 되돌릴 수 있어요." } } + } + }, + "development_record_restore_error_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "The selected version could not be restored. Please try again." } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "선택한 버전으로 되돌리지 못했어요. 다시 시도해주세요." } } + } + }, "development_record_timeline_error_message" : { "extractionState" : "manual", "localizations" : { @@ -613,6 +718,34 @@ "ko" : { "stringUnit" : { "state" : "translated", "value" : "버전" } } } }, + "development_record_version_correction" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Correction" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "정정" } } + } + }, + "development_record_version_current" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Current" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "현재" } } + } + }, + "development_record_version_initial" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Initial" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "최초 확정" } } + } + }, + "development_record_version_rollback" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Restored" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "되돌리기" } } + } + }, "development_record_write" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Presentation/PresentationShared/Sources/Extension/View+Alert.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+Alert.swift index 8b8978f0..caa3bfd2 100644 --- a/Application/Presentation/PresentationShared/Sources/Extension/View+Alert.swift +++ b/Application/Presentation/PresentationShared/Sources/Extension/View+Alert.swift @@ -40,36 +40,114 @@ where State: ObservableState { let item = $store.scope(state: alertState, action: alertAction) let alertStore = item.wrappedValue let state = store.state[keyPath: alertState] + let isPresented: Binding = Binding(item).activePresentation(when: isTabContentActive) - content.alert( - state.map(\.title).map(Text.init) ?? Text(verbatim: ""), - isPresented: Binding(item).activePresentation(when: isTabContentActive), - presenting: state, - actions: { state in - ForEach(state.buttons) { button in - let usesDefaultAction = state.usesDefaultAction(for: button) - - Button( - role: state.buttonRole(for: button), - action: { - button.withAction { action in - if let action { - alertStore?.send(action) + if #available(iOS 26, *) { + content.alert( + state.map(\.title).map(Text.init) ?? Text(verbatim: ""), + isPresented: isPresented, + presenting: state, + actions: { state in + ForEach(state.buttons) { button in + let usesDefaultAction = state.usesDefaultAction(for: button) + + Button( + role: state.buttonRole(for: button), + action: { + button.withAction { action in + if let action { + alertStore?.send(action) + } } } + ) { + Text(button.label) } - ) { + .keyboardShortcut( + usesDefaultAction ? .defaultAction : nil + ) + } + }, + message: { + $0.message.map(Text.init) + } + ) + } else { + ZStack { + content + .allowsHitTesting(!isPresented.wrappedValue) + + if let state, isTabContentActive { + Color.black.opacity(0.32) + .ignoresSafeArea() + .transition(.opacity) + + confirmationAlert(state) { button in + button.withAction { action in + if let action { + alertStore?.send(action) + } + isPresented.wrappedValue = false + } + } + .padding(.horizontal, 20) + .transition(.scale(scale: 0.96).combined(with: .opacity)) + } + } + .animation(.easeOut(duration: 0.18), value: isPresented.wrappedValue) + } + } + + private func confirmationAlert( + _ state: AlertState, + onSelect: @escaping (ButtonState) -> Void + ) -> some View { + VStack(spacing: 0) { + VStack(spacing: 12) { + Text(state.title) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.primary) + .multilineTextAlignment(.center) + + if let message = state.message { + Text(message) + .font(.body) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 28) + + Divider() + + HStack(spacing: 0) { + ForEach(Array(state.buttons.enumerated()), id: \.element.id) { index, button in + if 0 < index { + Divider() + } + + Button { + onSelect(button) + } label: { Text(button.label) } - .keyboardShortcut( - usesDefaultAction ? .defaultAction : nil + .foregroundStyle( + state.usesDefaultAction(for: button) + ? Color.accent + : Color.textSecondary ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - }, - message: { - $0.message.map(Text.init) } - ) + .font(.body) + .frame(height: 56) + } + .frame(maxWidth: 420) + .background(Color.surface, in: .rect(cornerRadius: 28)) + .clipShape(.rect(cornerRadius: 28)) + .shadow(color: Color.black.opacity(0.18), radius: 24, y: 12) } } @@ -82,6 +160,9 @@ extension AlertState { func usesDefaultAction(for button: ButtonState) -> Bool { guard 1 < buttons.count else { return true } - return buttons.first { $0.role == .destructive }?.id == button.id + if let destructiveButton = buttons.first(where: { $0.role == .destructive }) { + return destructiveButton.id == button.id + } + return buttons.last(where: { $0.role != .cancel })?.id == button.id } }