Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Projects/App/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion Projects/App/Resources/LaunchScreen.storyboard
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
</scene>
</scenes>
<resources>
<image name="LaunchBackgroundImage" width="393" height="852"/>
<image name="LaunchBackgroundImage" width="393" height="850"/>
<image name="LaunchCharacter" width="164" height="160"/>
<image name="LaunchLogo" width="244" height="71"/>
<image name="LaunchTagline" width="187.33333333333334" height="18"/>
Expand Down
1 change: 0 additions & 1 deletion Projects/App/Sources/MainTab/MainTab.swift

This file was deleted.

55 changes: 55 additions & 0 deletions Projects/App/Sources/MainTab/MainTabFeature.swift
Original file line number Diff line number Diff line change
@@ -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<Self> {
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
}
}
}
}
95 changes: 95 additions & 0 deletions Projects/App/Sources/MainTab/MainTabView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import ComposableArchitecture
import DesignSystem
import FortuneFeature
import Foundation
import SwiftUI

struct MainTabView: View {
@Bindable var store: StoreOf<MainTabFeature>

init(store: StoreOf<MainTabFeature>) {
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<DSBottomNavigationItem> {
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)
}
}
27 changes: 15 additions & 12 deletions Projects/App/Sources/Root/RootApp.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AuthSession
import ComposableArchitecture
import DesignSystem
import FortuneFeature
import GoogleSignIn
import KakaoSDKAuth
Expand Down Expand Up @@ -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
}
}
}
Expand All @@ -83,18 +83,21 @@ 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)
)
}
}
.task { store.send(.task) }
.onOpenURL { callbackURL in
_ = GIDSignIn.sharedInstance.handle(callbackURL)
// 외부 SDK의 고정 API 표기(`Url`)를 그대로 호출한다.
// swiftlint:disable:next acronym_casing
_ = AuthController.handleOpenUrl(url: callbackURL)
}
}
}
8 changes: 7 additions & 1 deletion Projects/App/Sources/Root/RootFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct RootFeature {
struct State: Equatable {
var route: Route = .launching
var onboarding = OnboardingFeature.State()
var mainTab = MainTabFeature.State()
}

enum Route: Equatable {
Expand All @@ -20,6 +21,7 @@ struct RootFeature {
case task
case sessionRestored(Result<Bool, AuthSessionError>)
case onboarding(OnboardingFeature.Action)
case mainTab(MainTabFeature.Action)
}

@Dependency(\.authSession) private var authSession
Expand All @@ -29,6 +31,10 @@ struct RootFeature {
OnboardingFeature()
}

Scope(state: \.mainTab, action: \.mainTab) {
MainTabFeature()
}

Reduce { state, action in
switch action {
case .task:
Expand All @@ -54,7 +60,7 @@ struct RootFeature {
state.route = .authenticated
return .none

case .onboarding:
case .onboarding, .mainTab:
return .none
}
}
Expand Down
78 changes: 78 additions & 0 deletions Projects/App/Tests/MainTabFeatureTests.swift
Original file line number Diff line number Diff line change
@@ -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: "네트워크 오류가 발생했습니다.")
)
}
}
Loading