diff --git a/Projects/DVData/Sources/RepositoryImpl/Settings/SettingsRepositoryImpl.swift b/Projects/DVData/Sources/RepositoryImpl/Settings/SettingsRepositoryImpl.swift new file mode 100644 index 00000000..a0292813 --- /dev/null +++ b/Projects/DVData/Sources/RepositoryImpl/Settings/SettingsRepositoryImpl.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation +import DVDomain + +// UserDefaults는 thread-safe하므로 @unchecked Sendable 허용 +public struct SettingsRepositoryImpl: SettingsRepository, @unchecked Sendable { + + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + public func hasCompletedOnboarding() -> Bool { + defaults.bool(forKey: .hasCompletedOnboarding) + } + + public func setOnboardingCompleted() { + defaults.set(true, forKey: .hasCompletedOnboarding) + } +} diff --git a/Projects/DVData/Sources/RepositoryImpl/Settings/UserDefaultsKey.swift b/Projects/DVData/Sources/RepositoryImpl/Settings/UserDefaultsKey.swift new file mode 100644 index 00000000..8a4907a8 --- /dev/null +++ b/Projects/DVData/Sources/RepositoryImpl/Settings/UserDefaultsKey.swift @@ -0,0 +1,17 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +enum UserDefaultsKey: String { + case hasCompletedOnboarding +} + +extension UserDefaults { + func bool(forKey key: UserDefaultsKey) -> Bool { + bool(forKey: key.rawValue) + } + + func set(_ value: Bool, forKey key: UserDefaultsKey) { + set(value, forKey: key.rawValue) + } +} diff --git a/Projects/DVDesign/Sources/Foundations/Color/View+DVColor.swift b/Projects/DVDesign/Sources/Foundations/Color/View+DVColor.swift index d8f02244..1a255cba 100644 --- a/Projects/DVDesign/Sources/Foundations/Color/View+DVColor.swift +++ b/Projects/DVDesign/Sources/Foundations/Color/View+DVColor.swift @@ -2,16 +2,16 @@ import SwiftUI -public extension View { - func dvForegroundColor(_ token: DVColor) -> some View { +extension View { + public func dvForegroundColor(_ token: DVColor) -> some View { self.foregroundStyle(token.color) } - func dvBackgroundColor(_ token: DVColor) -> some View { + public func dvBackgroundColor(_ token: DVColor) -> some View { self.background(token.color) } - func dvScreenBackground(_ token: DVColor = .gray100) -> some View { + public func dvScreenBackground(_ token: DVColor = .gray100) -> some View { self.background(token.color.ignoresSafeArea()) } } diff --git a/Projects/DVDomain/Sources/Repository/Interface/SettingsRepository.swift b/Projects/DVDomain/Sources/Repository/Interface/SettingsRepository.swift new file mode 100644 index 00000000..2f1a6587 --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/Interface/SettingsRepository.swift @@ -0,0 +1,8 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol SettingsRepository: Sendable { + func hasCompletedOnboarding() -> Bool + func setOnboardingCompleted() +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Settings/OnboardingStatusUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Settings/OnboardingStatusUseCaseImpl.swift new file mode 100644 index 00000000..8b25acb4 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Settings/OnboardingStatusUseCaseImpl.swift @@ -0,0 +1,20 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct OnboardingStatusUseCaseImpl: OnboardingStatusUseCase { + + private let repository: any SettingsRepository + + public init(repository: any SettingsRepository) { + self.repository = repository + } + + public func hasCompleted() -> Bool { + repository.hasCompletedOnboarding() + } + + public func setCompleted() { + repository.setOnboardingCompleted() + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Settings/OnboardingStatusUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Settings/OnboardingStatusUseCase.swift new file mode 100644 index 00000000..26998174 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Settings/OnboardingStatusUseCase.swift @@ -0,0 +1,8 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol OnboardingStatusUseCase: Sendable { + func hasCompleted() -> Bool + func setCompleted() +} diff --git a/Projects/DVPresentation/Project.swift b/Projects/DVPresentation/Project.swift index c0bd94e6..7ef86892 100644 --- a/Projects/DVPresentation/Project.swift +++ b/Projects/DVPresentation/Project.swift @@ -17,6 +17,7 @@ let project = Project.project( // 3rd-party dependency .tca(), + .lottie(), ] ), .tests( diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index 966dec69..91840cfb 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -1,39 +1,59 @@ { "sourceLanguage" : "en", "strings" : { + "Access your secrets on all your\nApple devices, securely encrypted." : { + + }, "Content" : { "comment" : "The title of the main content section in the preview.", "isCommentAutoGenerated" : true + }, + "Create Project" : { + + }, + "De" : { + }, "Detail" : { }, - "Development" : { + "If Touch ID is unavailable,\nsystem password will be used" : { }, - "Enterprise" : { + "LOGO" : { }, - "Failed to load projects" : { + "No expire" : { }, - "Individual" : { + "Placeholder row %@" : { }, - "Production" : { + "Project" : { }, - "Staging" : { + "Project Name" : { }, - "Team" : { + "Save" : { }, - "Sidebar" : { - "comment" : "The text that appears in the sidebar of the main view.", - "isCommentAutoGenerated" : true + "Select Project" : { + + }, + "SSL Required" : { + + }, + "This may take a moment..." : { + + }, + "Vault" : { + + }, + "목록을 불러오지 못했어요" : { + }, - "The project list couldn't be loaded. Please try again later." : { + "시크릿이 없어요" : { } }, diff --git a/Projects/DVPresentation/Sources/Dependencies/OnboardingStatusClient.swift b/Projects/DVPresentation/Sources/Dependencies/OnboardingStatusClient.swift new file mode 100644 index 00000000..8435f847 --- /dev/null +++ b/Projects/DVPresentation/Sources/Dependencies/OnboardingStatusClient.swift @@ -0,0 +1,33 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +import ComposableArchitecture + +// MARK: - OnboardingStatusClient + +@DependencyClient +public struct OnboardingStatusClient { + public var hasCompleted: @Sendable () -> Bool = { false } + public var setCompleted: @Sendable () -> Void +} + +// MARK: - DependencyKey + +extension OnboardingStatusClient: TestDependencyKey { + public static let testValue = OnboardingStatusClient() + + public static let previewValue = OnboardingStatusClient( + hasCompleted: { false }, + setCompleted: { } + ) +} + +// MARK: - DependencyValues + +extension DependencyValues { + public var onboardingStatus: OnboardingStatusClient { + get { self[OnboardingStatusClient.self] } + set { self[OnboardingStatusClient.self] = newValue } + } +} diff --git a/Projects/DVPresentation/Sources/Features/AppFeature.swift b/Projects/DVPresentation/Sources/Features/AppFeature.swift index 66ad6ebf..84bba50d 100644 --- a/Projects/DVPresentation/Sources/Features/AppFeature.swift +++ b/Projects/DVPresentation/Sources/Features/AppFeature.swift @@ -1,6 +1,6 @@ // Copyright © 2026 Devault. All rights reserved -import SwiftUI +import Foundation import ComposableArchitecture @@ -13,7 +13,10 @@ public struct AppFeature { @ObservableState public struct State: Equatable { - var main: MainFeature.State = .init() + // 세 프로퍼티 중 항상 하나만 non-nil — reducer 로직으로 불변식 보장 + var onboarding: OnboardingContainerFeature.State? + var locked: LockFeature.State? + var main: MainFeature.State? public init() {} } @@ -28,6 +31,8 @@ public struct AppFeature { // MARK: - Child + case onboarding(OnboardingContainerFeature.Action) + case locked(LockFeature.Action) case main(MainFeature.Action) // MARK: - Delegate @@ -37,6 +42,10 @@ public struct AppFeature { public enum Delegate: Equatable {} } + // MARK: - Dependencies + + @Dependency(\.onboardingStatus) var onboardingStatus + // MARK: - Init public init() {} @@ -44,12 +53,34 @@ public struct AppFeature { // MARK: - Body public var body: some ReducerOf { - Scope(state: \.main, action: \.main) { - MainFeature() - } Reduce { state, action in switch action { case .task: + state.onboarding = nil + state.locked = nil + state.main = nil + + if onboardingStatus.hasCompleted() { + state.locked = .init() + } else { + state.onboarding = .init() + } + return .none + + case .onboarding(.delegate(.completed)): + state.onboarding = nil + state.main = .init() + return .run { _ in onboardingStatus.setCompleted() } + + case .onboarding: + return .none + + case .locked(.delegate(.unlockCompleted)): + state.locked = nil + state.main = .init() + return .none + + case .locked: return .none case .main: @@ -59,5 +90,14 @@ public struct AppFeature { return .none } } + .ifLet(\.onboarding, action: \.onboarding) { + OnboardingContainerFeature() + } + .ifLet(\.locked, action: \.locked) { + LockFeature() + } + .ifLet(\.main, action: \.main) { + MainFeature() + } } } diff --git a/Projects/DVPresentation/Sources/Features/AppView.swift b/Projects/DVPresentation/Sources/Features/AppView.swift index 41c80426..47093e21 100644 --- a/Projects/DVPresentation/Sources/Features/AppView.swift +++ b/Projects/DVPresentation/Sources/Features/AppView.swift @@ -10,7 +10,7 @@ public struct AppView: View { // MARK: - Properties - @Bindable public var store: StoreOf + @Bindable var store: StoreOf // MARK: - Init @@ -30,8 +30,15 @@ public struct AppView: View { extension AppView { + @ViewBuilder private var content: some View { - MainView(store: store.scope(state: \.main, action: \.main)) + if let store = store.scope(state: \.onboarding, action: \.onboarding) { + OnboardingContainerView(store: store) + } else if let store = store.scope(state: \.locked, action: \.locked) { + LockView(store: store) + } else if let store = store.scope(state: \.main, action: \.main) { + MainView(store: store) + } } } @@ -41,6 +48,9 @@ extension AppView { AppView( store: Store(initialState: AppFeature.State()) { AppFeature() + } withDependencies: { + $0.onboardingStatus.hasCompleted = { false } + $0.onboardingStatus.setCompleted = { } } ) } diff --git a/Projects/DVPresentation/Sources/Features/Lock/LockFeature.swift b/Projects/DVPresentation/Sources/Features/Lock/LockFeature.swift index de89e18f..9c12d1cc 100644 --- a/Projects/DVPresentation/Sources/Features/Lock/LockFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Lock/LockFeature.swift @@ -13,11 +13,7 @@ public struct LockFeature { @ObservableState public struct State: Equatable { - public var isPostOnboarding: Bool - - public init(isPostOnboarding: Bool = false) { - self.isPostOnboarding = isPostOnboarding - } + public init() {} } // MARK: - Action diff --git a/Projects/DVPresentation/Sources/Features/Lock/LockView.swift b/Projects/DVPresentation/Sources/Features/Lock/LockView.swift index bf522c64..2a7c3e60 100644 --- a/Projects/DVPresentation/Sources/Features/Lock/LockView.swift +++ b/Projects/DVPresentation/Sources/Features/Lock/LockView.swift @@ -11,7 +11,7 @@ public struct LockView: View { // MARK: - Properties - @Bindable public var store: StoreOf + @Bindable var store: StoreOf // MARK: - Init @@ -32,17 +32,8 @@ public struct LockView: View { extension LockView { private var content: some View { - ZStack { - unlockView - if store.isPostOnboarding { - VStack { - Spacer() - DVStepIndicator(totalSteps: 4, currentStep: 3) - .padding(.bottom, 40) - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + unlockView + .frame(maxWidth: .infinity, maxHeight: .infinity) } private var unlockView: some View { @@ -70,18 +61,9 @@ extension LockView { // MARK: - Preview -#Preview("Post Onboarding") { - LockView( - store: Store(initialState: LockFeature.State(isPostOnboarding: true)) { - LockFeature() - } - ) - .frame(width: 540, height: 400) -} - -#Preview("Re-entry / Locked") { +#Preview { LockView( - store: Store(initialState: LockFeature.State(isPostOnboarding: false)) { + store: Store(initialState: LockFeature.State()) { LockFeature() } ) diff --git a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingContainerFeature.swift b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingContainerFeature.swift new file mode 100644 index 00000000..3f58cf9f --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingContainerFeature.swift @@ -0,0 +1,81 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +import ComposableArchitecture + +// MARK: - OnboardingContainerFeature + +@Reducer +public struct OnboardingContainerFeature { + + // MARK: - State + + @ObservableState + public struct State: Equatable { + var onboarding: OnboardingFeature.State? + var lock: LockFeature.State? + + public init() { + self.onboarding = .init() + } + + var currentStepIndex: Int { + if let onboarding { return onboarding.currentStepIndex } + return OnboardingFeature.Step.allCases.count - 1 + } + } + + // MARK: - Action + + public enum Action: Equatable { + + // MARK: - Child + + case onboarding(OnboardingFeature.Action) + case lock(LockFeature.Action) + + // MARK: - Delegate + + case delegate(Delegate) + + public enum Delegate: Equatable { + case completed + } + } + + // MARK: - Init + + public init() {} + + // MARK: - Body + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onboarding(.delegate(.completed)): + state.onboarding = nil + state.lock = .init() + return .none + + case .onboarding: + return .none + + case .lock(.delegate(.unlockCompleted)): + return .send(.delegate(.completed)) + + case .lock: + return .none + + case .delegate: + return .none + } + } + .ifLet(\.onboarding, action: \.onboarding) { + OnboardingFeature() + } + .ifLet(\.lock, action: \.lock) { + LockFeature() + } + } +} diff --git a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingContainerView.swift b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingContainerView.swift new file mode 100644 index 00000000..33f03d6a --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingContainerView.swift @@ -0,0 +1,66 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +import ComposableArchitecture +import DVDesign + +// MARK: - OnboardingContainerView + +public struct OnboardingContainerView: View { + + // MARK: - Properties + + @Bindable var store: StoreOf + + // MARK: - Init + + public init(store: StoreOf) { + self.store = store + } + + // MARK: - Body + + public var body: some View { + content + .dvScreenBackground() + } +} + +// MARK: - Subviews + +extension OnboardingContainerView { + + private var content: some View { + ZStack { + stepView + VStack { + Spacer() + DVStepIndicator(totalSteps: OnboardingFeature.Step.allCases.count, currentStep: store.currentStepIndex) + .padding(.bottom, 40) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private var stepView: some View { + if let store = store.scope(state: \.onboarding, action: \.onboarding) { + OnboardingView(store: store) + } else if let store = store.scope(state: \.lock, action: \.lock) { + LockView(store: store) + } + } +} + +// MARK: - Preview + +#Preview("Onboarding Flow") { + OnboardingContainerView( + store: Store(initialState: OnboardingContainerFeature.State()) { + OnboardingContainerFeature() + } + ) + .frame(width: 540, height: 400) +} + diff --git a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift index a4dbfd57..56fa436e 100644 --- a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingFeature.swift @@ -11,7 +11,7 @@ public struct OnboardingFeature { // MARK: - Step - public enum Step: Equatable { + public enum Step: Equatable, CaseIterable { case welcome case security case icloudSync @@ -59,6 +59,10 @@ public struct OnboardingFeature { } } + // MARK: - Dependencies + + @Dependency(\.continuousClock) var clock + // MARK: - Init public init() {} @@ -81,7 +85,11 @@ public struct OnboardingFeature { case .didTapEnableSync: state.step = .syncing - return .none + return .run { send in + // TODO: 실제 iCloud sync 완료 콜백으로 교체 + try await clock.sleep(for: .seconds(3)) + await send(.syncingCompleted) + } case .syncingCompleted: return .send(.delegate(.completed)) diff --git a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift index 7492dcd8..e60a8cdf 100644 --- a/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift +++ b/Projects/DVPresentation/Sources/Features/Onboarding/OnboardingView.swift @@ -12,7 +12,7 @@ public struct OnboardingView: View { // MARK: - Properties - @Bindable public var store: StoreOf + @Bindable var store: StoreOf // MARK: - Init @@ -33,15 +33,8 @@ public struct OnboardingView: View { extension OnboardingView { private var content: some View { - ZStack { - stepContent - VStack { - Spacer() - DVStepIndicator(totalSteps: 4, currentStep: store.currentStepIndex) - .padding(.bottom, 40) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + stepContent + .frame(maxWidth: .infinity, maxHeight: .infinity) } @ViewBuilder diff --git a/Projects/Devault/Sources/Composition/Dependencies/OnboardingStatusClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/OnboardingStatusClient+Live.swift new file mode 100644 index 00000000..3dbc8971 --- /dev/null +++ b/Projects/Devault/Sources/Composition/Dependencies/OnboardingStatusClient+Live.swift @@ -0,0 +1,18 @@ +// Copyright © 2026 Devault. All rights reserved + +import ComposableArchitecture +import DVPresentation +import DVDomain +import DVData + +extension OnboardingStatusClient: @retroactive DependencyKey { + public static let liveValue: OnboardingStatusClient = { + let repository: any SettingsRepository = SettingsRepositoryImpl() + let useCase: any OnboardingStatusUseCase = OnboardingStatusUseCaseImpl(repository: repository) + + return OnboardingStatusClient( + hasCompleted: { useCase.hasCompleted() }, + setCompleted: { useCase.setCompleted() } + ) + }() +} diff --git a/Projects/Devault/Sources/DevaultApp.swift b/Projects/Devault/Sources/DevaultApp.swift index 69316e6e..8106549b 100644 --- a/Projects/Devault/Sources/DevaultApp.swift +++ b/Projects/Devault/Sources/DevaultApp.swift @@ -5,13 +5,16 @@ import DVPresentation @main struct DevaultApp: App { - var body: some Scene { - WindowGroup { - AppView( - store: Store(initialState: AppFeature.State()) { - AppFeature() - } - ) + var body: some Scene { + WindowGroup { + AppView( + store: Store(initialState: AppFeature.State()) { + AppFeature() } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } + .windowStyle(.hiddenTitleBar) + .defaultSize(width: 800, height: 600) + } }