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 @@ -17,6 +17,7 @@ let project = Project.makeApp(
.project(target: "FortuneFeature", path: .relativeToRoot("Projects/Feature/FortuneFeature")),
.project(target: "TodakFeature", path: .relativeToRoot("Projects/Feature/TodakFeature")),
.project(target: "LuckyActionFeature", path: .relativeToRoot("Projects/Feature/LuckyActionFeature")),
.project(target: "LuckyActionFeatureInterface", path: .relativeToRoot("Projects/Feature/LuckyActionFeature")),
.project(target: "MyPageFeature", path: .relativeToRoot("Projects/Feature/MyPageFeature"))
],
hasTests: true
Expand Down
35 changes: 33 additions & 2 deletions Projects/App/Sources/MainTab/MainTabFeature.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import ComposableArchitecture
import FortuneFeature
import Foundation
import LuckyActionFeature
import MyPageFeature

@Reducer
struct MainTabFeature {
@Dependency(\.date.now) private var now

init() {}

enum Tab: Equatable, Sendable {
Expand All @@ -18,6 +21,8 @@ struct MainTabFeature {
struct State: Equatable {
var selectedTab: Tab
var fortune: FortuneFeature.State
var luckyAction: LuckyActionFeature.State
@Presents var pushedLuckyAction: LuckyActionFeature.State?
var myPage: MyPageFeature.State
/// 궁합 화면에서 시작된 내 정보 편집 요청의 출처를 보관한다.
/// 경로가 사라진 뒤 도착한 응답이 편집 화면을 표시하지 않도록 App에서 수명을 관리한다.
Expand All @@ -26,10 +31,14 @@ struct MainTabFeature {
init(
selectedTab: Tab = .fortune,
fortune: FortuneFeature.State = .init(),
luckyAction: LuckyActionFeature.State = .init(),
pushedLuckyAction: LuckyActionFeature.State? = nil,
myPage: MyPageFeature.State = .init()
) {
self.selectedTab = selectedTab
self.fortune = fortune
self.luckyAction = luckyAction
self.pushedLuckyAction = pushedLuckyAction
self.myPage = myPage
}
}
Expand All @@ -38,6 +47,9 @@ struct MainTabFeature {
case selectedTabChanged(Tab)
case fortuneNavigationChanged
case fortune(FortuneFeature.Action)
case luckyAction(LuckyActionFeature.Action)
case pushedLuckyAction(PresentationAction<LuckyActionFeature.Action>)
case pushedLuckyActionPresentationChanged(Bool)
case myPage(MyPageFeature.Action)
}

Expand All @@ -50,6 +62,10 @@ struct MainTabFeature {
MyPageFeature()
}

Scope(state: \.luckyAction, action: \.luckyAction) {
LuckyActionFeature()
}

Reduce { state, action in
switch action {
case let .selectedTabChanged(tab):
Expand All @@ -71,10 +87,22 @@ struct MainTabFeature {
state.compatibilityEditSourceID = nil
return .send(.myPage(.discardPendingEditPresentation))

case .fortune(.delegate(.luckyActionRequested)):
case .fortune(.delegate(.luckyActionTabRequested)):
state.selectedTab = .luckyAction
return .none

case .fortune(.delegate(.luckyActionPushRequested)):
state.pushedLuckyAction = LuckyActionFeature.State(
presentationStyle: .pushed,
today: now
)
return .none

case .pushedLuckyAction(.presented(.delegate(.dismissRequested))),
.pushedLuckyActionPresentationChanged(false):
state.pushedLuckyAction = nil
return .none

case .fortune(.delegate(.todakRequested)):
state.selectedTab = .todak
return .none
Expand Down Expand Up @@ -110,10 +138,13 @@ struct MainTabFeature {
)
)

case .fortune, .myPage:
case .fortune, .luckyAction, .pushedLuckyAction, .pushedLuckyActionPresentationChanged, .myPage:
return .none
}
}
.ifLet(\.$pushedLuckyAction, action: \.pushedLuckyAction) {
LuckyActionFeature()
}
}

private func currentCompatibilityDestinationID(in state: State) -> StackElementID? {
Expand Down
95 changes: 90 additions & 5 deletions Projects/App/Sources/MainTab/MainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import ComposableArchitecture
import DesignSystem
import FortuneFeature
import Foundation
import LuckyActionFeature
import MyPageFeature
import SwiftUI

Expand All @@ -13,18 +14,36 @@ struct MainTabView: View {
}

var body: some View {
ZStack {
tabContent
.disabled(isLuckyActionCompletionPresented)
.accessibilityHidden(isLuckyActionCompletionPresented)

if let completion = activeLuckyActionCompletion {
LuckyActionGlobalCompletionOverlay(
completion: completion,
dismiss: dismissLuckyActionCompletion
)
.transition(.opacity)
.zIndex(1)
}
}
.animation(.easeInOut(duration: 0.2), value: isLuckyActionCompletionPresented)
}

private var tabContent: some View {
ZStack {
switch store.selectedTab {
case .fortune:
FortuneView(
store: store.scope(state: \.fortune, action: \.fortune)
)
fortuneTab

case .todak:
UnavailableTabView(title: "토닥이")

case .luckyAction:
UnavailableTabView(title: "행운 액션")
LuckyActionView(
store: store.scope(state: \.luckyAction, action: \.luckyAction)
)

case .myPage:
MyPageView(
Expand Down Expand Up @@ -64,7 +83,35 @@ struct MainTabView: View {
&& store.myPage.withdrawal == nil
&& store.myPage.sajuManagement == nil
)
return fortuneShowing && myPageShowing && !isMyInfoEditPresented
return fortuneShowing
&& myPageShowing
&& !isMyInfoEditPresented
&& store.pushedLuckyAction == nil
}

private var fortuneTab: some View {
FortuneNavigationView(
store: store.scope(state: \.fortune, action: \.fortune),
isLuckyActionPresented: Binding(
get: { store.pushedLuckyAction != nil },
set: { isPresented in
store.send(.pushedLuckyActionPresentationChanged(isPresented))
}
),
luckyActionDestination: AnyView(pushedLuckyActionDestination)
)
}

@ViewBuilder
private var pushedLuckyActionDestination: some View {
if let luckyActionStore = store.scope(
state: \.pushedLuckyAction,
action: \.pushedLuckyAction.presented
) {
LuckyActionView(store: luckyActionStore)
} else {
EmptyView()
}
}

private var isMyInfoEditPresented: Bool {
Expand All @@ -74,6 +121,26 @@ struct MainTabView: View {
&& store.myPage.edit != nil
}

private var isLuckyActionCompletionPresented: Bool {
activeLuckyActionCompletion != nil
}

private var activeLuckyActionCompletion: LuckyActionCompletion? {
if store.selectedTab == .luckyAction {
return store.luckyAction.completion
}
guard store.selectedTab == .fortune else { return nil }
return store.pushedLuckyAction?.completion
}

private func dismissLuckyActionCompletion() {
if store.selectedTab == .luckyAction {
store.send(.luckyAction(.view(.completionDismissButtonTapped)))
} else {
store.send(.pushedLuckyAction(.presented(.view(.completionDismissButtonTapped))))
}
}

private var currentCompatibilityDestinationID: StackElementID? {
guard let destinationID = store.fortune.path.ids.last,
case .compatibility = store.fortune.path[id: destinationID]
Expand Down Expand Up @@ -113,6 +180,24 @@ private extension DSBottomNavigationItem {
}
}

private struct LuckyActionGlobalCompletionOverlay: View {
let completion: LuckyActionCompletion
let dismiss: () -> Void

var body: some View {
ZStack {
Color.ds.opacity50
.ignoresSafeArea()
.accessibilityHidden(true)

LuckyActionCompletionContent(
completion: completion,
dismiss: dismiss
)
}
}
}

private struct UnavailableTabView: View {
let title: LocalizedStringResource

Expand Down
6 changes: 6 additions & 0 deletions Projects/App/Sources/Root/RootApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import FortuneFeature
import GoogleSignIn
import KakaoSDKAuth
import KakaoSDKCommon
import LuckyActionFeature
import LuckyActionFeatureInterface
import MyPageFeature
import NetworkCore
import OnboardingFeature
Expand All @@ -26,6 +28,7 @@ struct TodakunApp: App {
let authSession = AuthSessionClient.live
let authClient: AuthClient
let fortuneClient: FortuneClient
let luckyActionClient: LuckyActionClient
let myPageClient: MyPageClient

if let baseURL = configuration.apiBaseURL {
Expand All @@ -47,10 +50,12 @@ struct TodakunApp: App {
}
)
fortuneClient = FortuneClient.live(httpClient: authenticatedHTTPClient)
luckyActionClient = LuckyActionClient.live(httpClient: authenticatedHTTPClient)
myPageClient = MyPageClient.live(httpClient: authenticatedHTTPClient)
} else {
authClient = .unavailable
fortuneClient = .unavailable
luckyActionClient = .unavailable
myPageClient = .unavailable
}

Expand All @@ -66,6 +71,7 @@ struct TodakunApp: App {
$0.authClient = authClient
$0.authSession = authSession
$0.fortuneClient = fortuneClient
$0.luckyActionClient = luckyActionClient
$0.myPageClient = myPageClient
$0.socialLoginClient = .live(configuration: configuration)
}
Expand Down
86 changes: 84 additions & 2 deletions Projects/App/Tests/MainTabFeatureTests.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import ComposableArchitecture
import FortuneFeature
import Foundation
import LuckyActionFeature
import Testing
@testable import Todakun

Expand Down Expand Up @@ -31,16 +33,96 @@ struct MainTabFeatureTests {
}

@Test
func fortuneDelegateLuckyActionRequestedSwitchesTab() async {
func fortuneDelegateLuckyActionTabRequestedSwitchesTab() async {
let store = TestStore(initialState: MainTabFeature.State()) {
MainTabFeature()
}

await store.send(.fortune(.delegate(.luckyActionRequested))) {
await store.send(.fortune(.delegate(.luckyActionTabRequested))) {
$0.selectedTab = .luckyAction
}
}

@Test
func fortuneDelegateLuckyActionPushRequestedPresentsLuckyAction() async {
let now = Date(timeIntervalSince1970: 1_786_762_800)
let store = TestStore(initialState: MainTabFeature.State()) {
MainTabFeature()
} withDependencies: {
$0.date.now = now
}

await store.send(.fortune(.delegate(.luckyActionPushRequested))) {
$0.pushedLuckyAction = LuckyActionFeature.State(
presentationStyle: .pushed,
today: now
)
}
}

@Test
func dismissingPushedLuckyActionPreservesFortuneReportPath() async {
var state = MainTabFeature.State()
state.fortune.path.append(.report(.init(dailyFortuneID: UUID())))
state.pushedLuckyAction = .init(presentationStyle: .pushed)
let store = TestStore(initialState: state) {
MainTabFeature()
}

await store.send(.pushedLuckyAction(.presented(.delegate(.dismissRequested)))) {
$0.pushedLuckyAction = nil
}

#expect(store.state.fortune.path.count == 1)
}

@Test
func backFromPushedLuckyActionDismissesPresentationAndPreservesReportPath() async {
var state = MainTabFeature.State()
state.fortune.path.append(.report(.init(dailyFortuneID: UUID())))
state.pushedLuckyAction = .init(presentationStyle: .pushed)
let store = TestStore(initialState: state) {
MainTabFeature()
}

await store.send(.pushedLuckyAction(.presented(.view(.backButtonTapped))))
await store.receive(.pushedLuckyAction(.presented(.delegate(.dismissRequested)))) {
$0.pushedLuckyAction = nil
}

#expect(store.state.fortune.path.count == 1)
}

@Test
func reopeningPushedLuckyActionCreatesFreshState() async {
let now = Date(timeIntervalSince1970: 1_786_762_800)
var previousPresentation = LuckyActionFeature.State(
presentationStyle: .pushed,
today: now
)
previousPresentation.selectedDate = previousPresentation.today.addingTimeInterval(-86_400)
let store = TestStore(
initialState: MainTabFeature.State(
pushedLuckyAction: previousPresentation
)
) {
MainTabFeature()
} withDependencies: {
$0.date.now = now
}

await store.send(.pushedLuckyAction(.presented(.delegate(.dismissRequested)))) {
$0.pushedLuckyAction = nil
}

await store.send(.fortune(.delegate(.luckyActionPushRequested))) {
$0.pushedLuckyAction = LuckyActionFeature.State(
presentationStyle: .pushed,
today: now
)
}
}

@Test
func fortuneDelegateTodakRequestedSwitchesTab() async {
let store = TestStore(initialState: MainTabFeature.State()) {
Expand Down
2 changes: 1 addition & 1 deletion Projects/Core/DesignSystem/Docs/Components/Button.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
- 활성 Primary·Secondary 버튼은 누르는 동안 기존 배경 위에 `gray975` 색상을 `16%` opacity로 덮는다.
- Disabled 버튼에는 pressed 오버레이를 적용하지 않는다.
- 오버레이는 기존 버튼 영역에만 적용하며, 별도의 최소 크기나 터치 영역을 강제하지 않는다.
- `DSButton`이 아닌 화면 전용 조합 버튼은 공통 값을 직접 알지 않고 `dsSurfaceButtonStyle(shape:)` 또는 `dsIconButtonStyle(_:width:height:)` 조립 API로 동일한 pressed 정책을 사용한다.
- `DSButton`이 아닌 화면 전용 조합 버튼은 공통 값을 직접 알지 않고 `dsSurfaceButtonStyle(shape:)`, `dsIconButtonStyle(_:width:height:)`(DS 아이콘) 또는 `dsIconButtonStyle(width:height:)`(Feature 전용 아이콘) 조립 API로 동일한 pressed 정책을 사용한다.

## 🏗️ Structure & Layout

Expand Down
Loading