diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 46b52bd..dc7cb8a 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -9,6 +9,7 @@ let project = Project.makeApp( .external(name: "KakaoSDKAuth"), .external(name: "KakaoSDKCommon"), .project(target: "AuthSession", path: .relativeToRoot("Projects/Core/AuthSession")), + .project(target: "DesignSystem", path: .relativeToRoot("Projects/Core/DesignSystem")), .project(target: "NetworkCore", path: .relativeToRoot("Projects/Core/NetworkCore")), .project(target: "OnboardingFeature", path: .relativeToRoot("Projects/Feature/OnboardingFeature")), .project(target: "FortuneFeature", path: .relativeToRoot("Projects/Feature/FortuneFeature")), diff --git a/Projects/App/Resources/Assets.xcassets/LaunchBackgroundImage.imageset/Contents.json b/Projects/App/Resources/Assets.xcassets/LaunchBackgroundImage.imageset/Contents.json index 0c768d8..83fb377 100644 --- a/Projects/App/Resources/Assets.xcassets/LaunchBackgroundImage.imageset/Contents.json +++ b/Projects/App/Resources/Assets.xcassets/LaunchBackgroundImage.imageset/Contents.json @@ -1,5 +1,15 @@ { "images" : [ + { + "filename" : "launch-background.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "launch-background@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, { "filename" : "launch-background@3x.png", "idiom" : "universal", diff --git a/Projects/App/Resources/Assets.xcassets/LaunchBackgroundImage.imageset/launch-background.png b/Projects/App/Resources/Assets.xcassets/LaunchBackgroundImage.imageset/launch-background.png new file mode 100644 index 0000000..dae9575 Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/LaunchBackgroundImage.imageset/launch-background.png differ diff --git a/Projects/App/Resources/Assets.xcassets/LaunchBackgroundImage.imageset/launch-background@2x.png b/Projects/App/Resources/Assets.xcassets/LaunchBackgroundImage.imageset/launch-background@2x.png new file mode 100644 index 0000000..a6aef07 Binary files /dev/null and b/Projects/App/Resources/Assets.xcassets/LaunchBackgroundImage.imageset/launch-background@2x.png differ diff --git a/Projects/App/Resources/Assets.xcassets/LaunchBackgroundImage.imageset/launch-background@3x.png b/Projects/App/Resources/Assets.xcassets/LaunchBackgroundImage.imageset/launch-background@3x.png index 09a8432..2c52b67 100644 Binary files a/Projects/App/Resources/Assets.xcassets/LaunchBackgroundImage.imageset/launch-background@3x.png and b/Projects/App/Resources/Assets.xcassets/LaunchBackgroundImage.imageset/launch-background@3x.png differ diff --git a/Projects/App/Resources/LaunchScreen.storyboard b/Projects/App/Resources/LaunchScreen.storyboard index 177701d..66252c0 100644 --- a/Projects/App/Resources/LaunchScreen.storyboard +++ b/Projects/App/Resources/LaunchScreen.storyboard @@ -61,7 +61,7 @@ - + diff --git a/Projects/App/Sources/MainTab/MainTab.swift b/Projects/App/Sources/MainTab/MainTab.swift deleted file mode 100644 index 96a2134..0000000 --- a/Projects/App/Sources/MainTab/MainTab.swift +++ /dev/null @@ -1 +0,0 @@ -// MainTab Logic diff --git a/Projects/App/Sources/MainTab/MainTabFeature.swift b/Projects/App/Sources/MainTab/MainTabFeature.swift new file mode 100644 index 0000000..8f8d25d --- /dev/null +++ b/Projects/App/Sources/MainTab/MainTabFeature.swift @@ -0,0 +1,55 @@ +import ComposableArchitecture +import FortuneFeature +import Foundation + +@Reducer +struct MainTabFeature: Sendable { + init() {} + + enum Tab: Equatable, Sendable { + case fortune + case todak + case luckyAction + case myPage + } + + @ObservableState + struct State: Equatable, Sendable { + var selectedTab: Tab + var fortune: FortuneFeature.State + + init( + selectedTab: Tab = .fortune, + fortune: FortuneFeature.State = .init() + ) { + self.selectedTab = selectedTab + self.fortune = fortune + } + } + + enum Action: Equatable, Sendable { + case selectedTabChanged(Tab) + case fortune(FortuneFeature.Action) + } + + var body: some ReducerOf { + Scope(state: \.fortune, action: \.fortune) { + FortuneFeature() + } + + Reduce { state, action in + switch action { + case let .selectedTabChanged(tab): + state.selectedTab = tab + return .none + + case .fortune(.delegate(.luckyActionRequested)): + state.selectedTab = .luckyAction + return .none + + case .fortune: + return .none + } + } + } +} diff --git a/Projects/App/Sources/MainTab/MainTabView.swift b/Projects/App/Sources/MainTab/MainTabView.swift new file mode 100644 index 0000000..3481375 --- /dev/null +++ b/Projects/App/Sources/MainTab/MainTabView.swift @@ -0,0 +1,95 @@ +import ComposableArchitecture +import DesignSystem +import FortuneFeature +import Foundation +import SwiftUI + +struct MainTabView: View { + @Bindable var store: StoreOf + + init(store: StoreOf) { + self.store = store + } + + var body: some View { + ZStack { + switch store.selectedTab { + case .fortune: + FortuneView( + store: store.scope(state: \.fortune, action: \.fortune) + ) + + case .todak: + UnavailableTabView(title: "토닥이") + + case .luckyAction: + UnavailableTabView(title: "행운 액션") + + case .myPage: + UnavailableTabView(title: "마이페이지") + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + DSBottomNavigation( + selectedItem: bottomNavigationBinding + ) + + Color.clear + .frame(height: 0) + .background( + DesignSystemAsset.Colors.white.swiftUIColor, + ignoresSafeAreaEdges: .bottom + ) + } + } + } + + private var bottomNavigationBinding: Binding { + Binding( + get: { store.selectedTab.bottomNavigationItem }, + set: { store.send(.selectedTabChanged($0.tab)) } + ) + } +} + +private extension MainTabFeature.Tab { + var bottomNavigationItem: DSBottomNavigationItem { + switch self { + case .fortune: .fortune + case .todak: .todak + case .luckyAction: .luckyAction + case .myPage: .myPage + } + } +} + +private extension DSBottomNavigationItem { + var tab: MainTabFeature.Tab { + switch self { + case .fortune: .fortune + case .todak: .todak + case .luckyAction: .luckyAction + case .myPage: .myPage + } + } +} + +private struct UnavailableTabView: View { + let title: LocalizedStringResource + + var body: some View { + VStack(spacing: 12) { + Text(title) + .font(.title2.bold()) + .foregroundStyle(DesignSystemAsset.Colors.gray975.swiftUIColor) + + Text("준비 중인 기능이에요.") + .font(.subheadline) + .foregroundStyle(DesignSystemAsset.Colors.gray600.swiftUIColor) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(DesignSystemAsset.Colors.gray100.swiftUIColor) + } +} diff --git a/Projects/App/Sources/Root/RootApp.swift b/Projects/App/Sources/Root/RootApp.swift index 5a5b709..1b5e5fd 100644 --- a/Projects/App/Sources/Root/RootApp.swift +++ b/Projects/App/Sources/Root/RootApp.swift @@ -1,5 +1,6 @@ import AuthSession import ComposableArchitecture +import DesignSystem import FortuneFeature import GoogleSignIn import KakaoSDKAuth @@ -60,13 +61,12 @@ struct TodakunApp: App { var body: some Scene { WindowGroup { +#if DEBUG RootView(store: store) - .onOpenURL { callbackURL in - _ = GIDSignIn.sharedInstance.handle(callbackURL) - // 외부 SDK의 고정 API 표기(`Url`)를 그대로 호출한다. - // swiftlint:disable:next acronym_casing - _ = AuthController.handleOpenUrl(url: callbackURL) - } + .dsDebugLayoutInspector() +#else + RootView(store: store) +#endif } } } @@ -83,12 +83,9 @@ private struct RootView: View { .background(Color("LaunchBackground").ignoresSafeArea()) case .authenticated: - VStack(spacing: 12) { - Text("홈") - .font(.title.bold()) - Text("로그인이 완료되었어요") - .foregroundStyle(.secondary) - } + MainTabView( + store: store.scope(state: \.mainTab, action: \.mainTab) + ) case .unauthenticated: OnboardingView( store: store.scope(state: \.onboarding, action: \.onboarding) @@ -96,5 +93,11 @@ private struct RootView: View { } } .task { store.send(.task) } + .onOpenURL { callbackURL in + _ = GIDSignIn.sharedInstance.handle(callbackURL) + // 외부 SDK의 고정 API 표기(`Url`)를 그대로 호출한다. + // swiftlint:disable:next acronym_casing + _ = AuthController.handleOpenUrl(url: callbackURL) + } } } diff --git a/Projects/App/Sources/Root/RootFeature.swift b/Projects/App/Sources/Root/RootFeature.swift index 7aad785..2ffe896 100644 --- a/Projects/App/Sources/Root/RootFeature.swift +++ b/Projects/App/Sources/Root/RootFeature.swift @@ -8,6 +8,7 @@ struct RootFeature { struct State: Equatable { var route: Route = .launching var onboarding = OnboardingFeature.State() + var mainTab = MainTabFeature.State() } enum Route: Equatable { @@ -20,6 +21,7 @@ struct RootFeature { case task case sessionRestored(Result) case onboarding(OnboardingFeature.Action) + case mainTab(MainTabFeature.Action) } @Dependency(\.authSession) private var authSession @@ -29,6 +31,10 @@ struct RootFeature { OnboardingFeature() } + Scope(state: \.mainTab, action: \.mainTab) { + MainTabFeature() + } + Reduce { state, action in switch action { case .task: @@ -54,7 +60,7 @@ struct RootFeature { state.route = .authenticated return .none - case .onboarding: + case .onboarding, .mainTab: return .none } } diff --git a/Projects/App/Tests/MainTabFeatureTests.swift b/Projects/App/Tests/MainTabFeatureTests.swift new file mode 100644 index 0000000..15b66d7 --- /dev/null +++ b/Projects/App/Tests/MainTabFeatureTests.swift @@ -0,0 +1,78 @@ +import ComposableArchitecture +import FortuneFeature +import Testing +@testable import Todakun + +@Suite +@MainActor +struct MainTabFeatureTests { + @Test + func defaultSelectedTabIsFortune() { + let store = TestStore(initialState: MainTabFeature.State()) { + MainTabFeature() + } + + #expect(store.state.selectedTab == .fortune) + } + + @Test + func selectedTabChanged() async { + let store = TestStore(initialState: MainTabFeature.State()) { + MainTabFeature() + } + + await store.send(.selectedTabChanged(.todak)) { + $0.selectedTab = .todak + } + + await store.send(.selectedTabChanged(.myPage)) { + $0.selectedTab = .myPage + } + } + + @Test + func fortuneDelegateLuckyActionRequestedSwitchesTab() async { + let store = TestStore(initialState: MainTabFeature.State()) { + MainTabFeature() + } + + await store.send(.fortune(.delegate(.luckyActionRequested))) { + $0.selectedTab = .luckyAction + } + } + + @Test + func otherFortuneDelegateDoesNotChangeTab() async { + let store = TestStore(initialState: MainTabFeature.State()) { + MainTabFeature() + } + + await store.send(.fortune(.delegate(.notificationRequested))) + } + + @Test + func statePersistedAcrossTabChanges() async { + var initialState = MainTabFeature.State() + initialState.fortune.viewState = .failed(message: "네트워크 오류가 발생했습니다.") + + let store = TestStore(initialState: initialState) { + MainTabFeature() + } + + await store.send(.selectedTabChanged(.todak)) { + $0.selectedTab = .todak + } + + #expect( + store.state.fortune.viewState == .failed(message: "네트워크 오류가 발생했습니다.") + ) + + await store.send(.selectedTabChanged(.fortune)) { + $0.selectedTab = .fortune + } + + #expect( + store.state.fortune.viewState == .failed(message: "네트워크 오류가 발생했습니다.") + ) + } +} diff --git a/Projects/App/Tests/RootFeatureTests.swift b/Projects/App/Tests/RootFeatureTests.swift index 590b109..86668a3 100644 --- a/Projects/App/Tests/RootFeatureTests.swift +++ b/Projects/App/Tests/RootFeatureTests.swift @@ -1,11 +1,13 @@ import AuthSession import ComposableArchitecture -import XCTest +import Testing @testable import Todakun +@Suite @MainActor -final class RootFeatureTests: XCTestCase { - func testLaunchWithoutStoredSessionShowsOnboarding() async { +struct RootFeatureTests { + @Test + func launchWithoutStoredSessionShowsOnboarding() async { let store = TestStore(initialState: RootFeature.State()) { RootFeature() } withDependencies: { @@ -18,7 +20,8 @@ final class RootFeatureTests: XCTestCase { } } - func testLaunchWithStoredSessionShowsAuthenticatedRoute() async { + @Test + func launchWithStoredSessionShowsAuthenticatedRoute() async { let store = TestStore(initialState: RootFeature.State()) { RootFeature() } withDependencies: { @@ -31,7 +34,8 @@ final class RootFeatureTests: XCTestCase { } } - func testLaunchWithSessionRestoreFailureShowsOnboarding() async { + @Test + func launchWithSessionRestoreFailureShowsOnboarding() async { let store = TestStore(initialState: RootFeature.State()) { RootFeature() } withDependencies: { @@ -44,7 +48,8 @@ final class RootFeatureTests: XCTestCase { } } - func testTaskDoesNotRestoreSessionAfterRouteIsResolved() async { + @Test + func taskDoesNotRestoreSessionAfterRouteIsResolved() async { let counter = RestoreCounter() var state = RootFeature.State() state.route = .unauthenticated @@ -60,12 +65,14 @@ final class RootFeatureTests: XCTestCase { await store.send(.task) let restoreCount = await counter.value - XCTAssertEqual(restoreCount, 0) + #expect(restoreCount == 0) } - func testOnboardingCompletionShowsAuthenticatedRoute() async { + @Test + func onboardingCompletionShowsAuthenticatedRoute() async { var state = RootFeature.State() state.route = .unauthenticated + state.mainTab.selectedTab = .myPage let store = TestStore(initialState: state) { RootFeature() } @@ -73,6 +80,7 @@ final class RootFeatureTests: XCTestCase { await store.send(.onboarding(.delegate(.authenticationCompleted))) { $0.route = .authenticated } + #expect(store.state.mainTab.selectedTab == .myPage) } } diff --git a/docs/graph.dot b/docs/graph.dot index 2f23b5d..71d15a2 100644 --- a/docs/graph.dot +++ b/docs/graph.dot @@ -82,6 +82,7 @@ digraph { TodakFeatureTesting -> Utils TodakFeatureTesting [fillcolor=coral1 shape=cylinder style=filled] Todakun -> AuthSession + Todakun -> DesignSystem Todakun -> FortuneFeature Todakun -> LuckyActionFeature Todakun -> MyPageFeature diff --git a/docs/graph.png b/docs/graph.png index cf4f769..07be729 100644 Binary files a/docs/graph.png and b/docs/graph.png differ