Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d1d2674
refactor(core): AuthSession 의존성 정리 및 DesignSystem 에셋 누락 수정
seungwone Aug 14, 2026
89b02fe
feat(design-system): DSSajuPillarCell 공통 컴포넌트 및 명세·플레이그라운드 추가 (#79)
seungwone Aug 14, 2026
6b08bc4
feat(fortune): 운세 상세 리포트 및 상세운 바텀시트 UI 구현 (#66)
seungwone Aug 14, 2026
20c5f86
feat(fortune): 사주풀이 3종(상대방 궁합·택일·연운) 화면 및 클라이언트 구현 (#79)
seungwone Aug 14, 2026
328dd55
feat(app): 궁합 내 정보 변경 시 마이페이지 탭 전환 연동 (#67)
seungwone Aug 14, 2026
1f51a18
fix(fortune): PR Preflight Review 지적사항 보완 (#79)
seungwone Aug 14, 2026
e548842
style(fortune): SwiftLint 포맷팅 및 컨벤션 준수 (#79)
seungwone Aug 14, 2026
956b583
fix(fortune): 운세 상세 리포트 화면 내 상세운 섹션 렌더링 누락 수정 (#66)
seungwone Aug 14, 2026
55ecfc0
fix(fortune): 사주 등록 페이로드 규격 수정 및 궁합 사주 구조도 레이아웃 개선
seungwone Aug 14, 2026
036e871
refactor(fortune): 사주 기본 정보 입력 폼 공통화 및 유효성 검증 정책 통합 (#79)
seungwone Aug 14, 2026
6de9fb9
fix(fortune): 하단 CTA 영역 인위적 그라데이션 페이드 제거 및 공통 뷰 리팩토링 (#79)
seungwone Aug 15, 2026
7583dfc
fix(fortune): 궁합 내 정보 수정 흐름과 CTA 상태 보완 (#79)
seungwone Aug 15, 2026
b1aaf4c
chore: Fortune 후속 변경의 SwiftLint 위반 정리 (#79)
seungwone Aug 15, 2026
ef6cfd7
fix(fortune): 내 사주 갱신 실패 상태를 표시 (#79)
seungwone Aug 15, 2026
cb1780c
test(mypage): 사주 입력 검증 fixture 보완 (#79)
seungwone Aug 15, 2026
9cfc00d
fix(fortune): 궁합 내 정보 편집 경합 방지 (#79)
seungwone Aug 15, 2026
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
69 changes: 66 additions & 3 deletions Projects/App/Sources/MainTab/MainTabFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Foundation
import MyPageFeature

@Reducer
struct MainTabFeature: Sendable {
struct MainTabFeature {
init() {}

enum Tab: Equatable, Sendable {
Expand All @@ -15,10 +15,13 @@ struct MainTabFeature: Sendable {
}

@ObservableState
struct State: Equatable, Sendable {
struct State: Equatable {
var selectedTab: Tab
var fortune: FortuneFeature.State
var myPage: MyPageFeature.State
/// 궁합 화면에서 시작된 내 정보 편집 요청의 출처를 보관한다.
/// 경로가 사라진 뒤 도착한 응답이 편집 화면을 표시하지 않도록 App에서 수명을 관리한다.
var compatibilityEditSourceID: StackElementID?

init(
selectedTab: Tab = .fortune,
Expand All @@ -31,8 +34,9 @@ struct MainTabFeature: Sendable {
}
}

enum Action: Equatable, Sendable {
enum Action: Equatable {
case selectedTabChanged(Tab)
case fortuneNavigationChanged
case fortune(FortuneFeature.Action)
case myPage(MyPageFeature.Action)
}
Expand All @@ -49,16 +53,75 @@ struct MainTabFeature: Sendable {
Reduce { state, action in
switch action {
case let .selectedTabChanged(tab):
let shouldDiscardEditPresentation = tab != .fortune
&& state.compatibilityEditSourceID != nil
state.selectedTab = tab
if shouldDiscardEditPresentation {
state.compatibilityEditSourceID = nil
return .send(.myPage(.discardPendingEditPresentation))
}
return .none

case .fortuneNavigationChanged:
guard let sourceID = state.compatibilityEditSourceID,
sourceID != currentCompatibilityDestinationID(in: state)
else {
return .none
}
state.compatibilityEditSourceID = nil
return .send(.myPage(.discardPendingEditPresentation))

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

case .fortune(.delegate(.todakRequested)):
state.selectedTab = .todak
return .none

case .fortune(.delegate(.myPageRequested)):
state.selectedTab = .myPage
return .none

case .fortune(.delegate(.myInfoEditRequested)):
guard let destinationID = currentCompatibilityDestinationID(in: state) else {
return .none
}
state.compatibilityEditSourceID = destinationID
return .send(.myPage(.presentEdit))

case .myPage(.editDismissButtonTapped):
state.compatibilityEditSourceID = nil
return .none

case .myPage(.delegate(.profileUpdated)):
state.compatibilityEditSourceID = nil
guard let destinationID = currentCompatibilityDestinationID(in: state) else {
return .none
}
return .send(
.fortune(
.path(
.element(
id: destinationID,
action: .compatibility(.mySajuRefreshRequested)
)
)
)
)

case .fortune, .myPage:
return .none
}
}
}

private func currentCompatibilityDestinationID(in state: State) -> StackElementID? {
guard let destinationID = state.fortune.path.ids.last,
case .compatibility = state.fortune.path[id: destinationID]
else {
return nil
}
return destinationID
}
}
34 changes: 33 additions & 1 deletion Projects/App/Sources/MainTab/MainTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ struct MainTabView: View {
store: store.scope(state: \.myPage, action: \.myPage)
)
}

if isMyInfoEditPresented {
MyPageEditView(
store: store.scope(state: \.myPage, action: \.myPage)
)
.transition(.move(edge: .trailing))
.zIndex(1)
}
}
.animation(.easeInOut(duration: 0.25), value: isMyInfoEditPresented)
.onChange(of: currentCompatibilityDestinationID) { _, _ in
store.send(.fortuneNavigationChanged)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.safeAreaInset(edge: .bottom, spacing: 0) {
Expand All @@ -43,12 +55,32 @@ struct MainTabView: View {
}

private var shouldShowBottomNavigation: Bool {
store.myPage.edit == nil
let fortuneShowing = store.selectedTab != .fortune || !store.fortune.isShowingDetail
let myPageShowing = store.selectedTab != .myPage || (
store.myPage.edit == nil
&& store.myPage.sajuDetail == nil
&& store.myPage.notificationSettings == nil
&& store.myPage.appSettings == nil
&& store.myPage.withdrawal == nil
&& store.myPage.sajuManagement == nil
)
return fortuneShowing && myPageShowing && !isMyInfoEditPresented
}

private var isMyInfoEditPresented: Bool {
guard let currentCompatibilityDestinationID else { return false }
return store.selectedTab == .fortune
&& store.compatibilityEditSourceID == currentCompatibilityDestinationID
&& store.myPage.edit != nil
}

private var currentCompatibilityDestinationID: StackElementID? {
guard let destinationID = store.fortune.path.ids.last,
case .compatibility = store.fortune.path[id: destinationID]
else {
return nil
}
return destinationID
}

private var bottomNavigationBinding: Binding<DSBottomNavigationItem> {
Expand Down
76 changes: 74 additions & 2 deletions Projects/App/Tests/MainTabFeatureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,84 @@ struct MainTabFeatureTests {
}

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

await store.send(.fortune(.delegate(.notificationRequested)))
await store.send(.fortune(.delegate(.todakRequested))) {
$0.selectedTab = .todak
}
}

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

await store.send(.fortune(.delegate(.myPageRequested))) {
$0.selectedTab = .myPage
}
}

@Test
func fortuneDelegateMyInfoEditRequestedPresentsEditWithoutSwitchingTab() async {
var state = MainTabFeature.State()
state.fortune.path.append(.compatibility(.init()))
let store = TestStore(initialState: state) {
MainTabFeature()
}
store.exhaustivity = .off(showSkippedAssertions: false)

await store.send(.fortune(.delegate(.myInfoEditRequested))) {
$0.compatibilityEditSourceID = 0
}
await store.receive(.myPage(.presentEdit))

#expect(store.state.selectedTab == .fortune)
}

@Test
func poppedCompatibilityDiscardsPendingMyInfoEditPresentation() async {
var state = MainTabFeature.State()
state.fortune.path.append(.compatibility(.init()))
state.compatibilityEditSourceID = 0
state.myPage.isPendingEditPresentation = true
state.fortune.path.removeLast()

let store = TestStore(initialState: state) {
MainTabFeature()
}

await store.send(.fortuneNavigationChanged) {
$0.compatibilityEditSourceID = nil
}
await store.receive(.myPage(.discardPendingEditPresentation)) {
$0.myPage.isPendingEditPresentation = false
}
}

@Test
func profileUpdateRefreshesMySajuInCurrentCompatibilityScreen() async {
var state = MainTabFeature.State()
state.fortune.path.append(.compatibility(.init()))
let store = TestStore(initialState: state) {
MainTabFeature()
}
store.exhaustivity = .off(showSkippedAssertions: false)

await store.send(.myPage(.delegate(.profileUpdated)))
await store.receive(
.fortune(
.path(
.element(
id: 0,
action: .compatibility(.mySajuRefreshRequested)
)
)
)
)
}

@Test
Expand Down
2 changes: 1 addition & 1 deletion Projects/Core/AuthSession/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ import ProjectDescriptionHelpers
let project = Project.makeCore(
name: "AuthSession",
dependencies: [
.external(name: "ComposableArchitecture")
.external(name: "Dependencies")
]
)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import ComposableArchitecture
import Dependencies
import Foundation

public struct SessionRefreshOperation: Sendable {
Expand Down
22 changes: 22 additions & 0 deletions Projects/Core/DesignSystem/Docs/Components/Saju_Pillar_Cell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Saju Pillar Cell (사주 기둥 셀)

사주 만세력 기둥의 천간(Heavenly Stem)과 지지(Earthly Branch)를 표시하는 표준 사주 기둥 타일 컴포넌트입니다.

## Specification

- **Size**: 48x48 pt
- **Corner Radius**: 12 pt
- **Typography**:
- Main Hanja Text: 18pt Bold (`gray975`)
- Sublabel (독음 / 오행): 10pt Regular (`gray975`)

## Variants & Five Elements (오행 컬러)

| Element | 한글 | 한자 | Background Asset |
| :--- | :--- | :--- | :--- |
| `.wood` | 목 | 木 | `Color.ds.teal200` |
| `.fire` | 화 | 火 | `Color.ds.red200` |
| `.earth` | 토 | 土 | `Color.ds.orange200` |
| `.metal` | 금 | 金 | `Color.ds.coolGray300` |
| `.water` | 수 | 水 | `Color.ds.sky200` |
| `.unknown` | - | - | `Color.ds.gray100` |
9 changes: 9 additions & 0 deletions Projects/Core/DesignSystem/Docs/Figma_Specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,12 @@
📄 [자세한 픽셀 수치 및 레이아웃 명세 보기](Components/Shadow_m.md)

![Shadow_m](Images/Shadow_m.png)

## 7. 사주 만세력 컴포넌트 (Saju Components)

### 🧩 Saju Pillar Cell

📄 [자세한 픽셀 수치 및 레이아웃 명세 보기](Components/Saju_Pillar_Cell.md)

- 천간/지지 한자 및 독음, 오행 컬러를 표시하는 48x48 규격 셀 (`DSSajuPillarCell`)

Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ struct ComponentsCatalogView: View {
title: "Conversation History List",
subtitle: "대화 제목, 시간, 읽지 않은 상태와 삭제 동작을 표시하는 행"
)
catalogLink(
destination: SajuPillarCellPlaygroundView(),
systemImage: "square.grid.2x2.fill",
title: "Saju Pillar Cell",
subtitle: "사주 만세력 기둥의 천간/지지와 오행을 표시하는 셀"
)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import SwiftUI
import DesignSystem

// MARK: - Saju Pillar Cell Playground Sandbox
struct SajuPillarCellPlaygroundView: View {
@State private var hanjaText: String = "辛"
@State private var readingText: String = "신"
@State private var selectedElement: DSSajuElement = .metal
@State private var showElementHanja: Bool = true
@State private var isDarkBackground: Bool = false

private var specification: DSSajuPillarCell.Specification {
DSSajuPillarCell.specification(element: selectedElement)
}

var body: some View {
VStack(spacing: 0) {
DSPlaygroundPreviewCard(
title: String(describing: DSSajuPillarCell.self),
isDarkBackground: $isDarkBackground
) {
DSSajuPillarCell(
hanja: hanjaText,
reading: readingText,
element: selectedElement,
showElementHanja: showElementHanja
)
}

Form {
Section(header: Text("Content")) {
TextField("Hanja", text: $hanjaText)
.autocorrectionDisabled()
TextField("Reading", text: $readingText)
.autocorrectionDisabled()
Toggle("Show Element Hanja", isOn: $showElementHanja)
}

Section(header: Text("Five Element")) {
Picker("Element", selection: $selectedElement) {
ForEach([DSSajuElement.wood, .fire, .earth, .metal, .water, .unknown], id: \.self) { element in
Text("\(element.label) (\(element.hanja))").tag(element)
}
}
}

Section(header: Text("Figma Specification Check")) {
DSSpecificationRow(title: "Shape", value: specification.shape.specName)
DSSpecificationRow(title: "Size", value: specification.size.ptDescription)
DSSpecificationRow(title: "Bg Color", value: specification.backgroundAsset.specDescription)
DSSpecificationRow(title: "Text Color", value: specification.foregroundAsset.specDescription)
}
}
}
.navigationTitle("Saju Pillar Cell Playground")
.navigationBarTitleDisplayMode(.inline)
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "Bell.svg",
"filename" : "bell.svg",
"idiom" : "universal"
}
],
Expand Down
Loading