From d1d26748affb293ed5763f26192952b3d49b7029 Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Fri, 14 Aug 2026 23:45:42 +0900 Subject: [PATCH 01/16] =?UTF-8?q?refactor(core):=20AuthSession=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20DesignSys?= =?UTF-8?q?tem=20=EC=97=90=EC=85=8B=20=EB=88=84=EB=9D=BD=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuthSession 모듈 의존성을 ComposableArchitecture에서 경량 Dependencies로 교체 - DesignSystem bell 아이콘 svg 파일명 대소문자 매핑 불일치 수정 --- Projects/Core/AuthSession/Project.swift | 2 +- Projects/Core/AuthSession/Sources/AuthSessionClient.swift | 2 +- .../Resources/Assets.xcassets/Icons/bell.imageset/Contents.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Projects/Core/AuthSession/Project.swift b/Projects/Core/AuthSession/Project.swift index 4c20e3d..cbd1539 100644 --- a/Projects/Core/AuthSession/Project.swift +++ b/Projects/Core/AuthSession/Project.swift @@ -4,6 +4,6 @@ import ProjectDescriptionHelpers let project = Project.makeCore( name: "AuthSession", dependencies: [ - .external(name: "ComposableArchitecture") + .external(name: "Dependencies") ] ) diff --git a/Projects/Core/AuthSession/Sources/AuthSessionClient.swift b/Projects/Core/AuthSession/Sources/AuthSessionClient.swift index 5780f8d..4497309 100644 --- a/Projects/Core/AuthSession/Sources/AuthSessionClient.swift +++ b/Projects/Core/AuthSession/Sources/AuthSessionClient.swift @@ -1,4 +1,4 @@ -import ComposableArchitecture +import Dependencies import Foundation public struct SessionRefreshOperation: Sendable { diff --git a/Projects/Core/DesignSystem/Resources/Assets.xcassets/Icons/bell.imageset/Contents.json b/Projects/Core/DesignSystem/Resources/Assets.xcassets/Icons/bell.imageset/Contents.json index a0689a2..db4fbb2 100644 --- a/Projects/Core/DesignSystem/Resources/Assets.xcassets/Icons/bell.imageset/Contents.json +++ b/Projects/Core/DesignSystem/Resources/Assets.xcassets/Icons/bell.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Bell.svg", + "filename" : "bell.svg", "idiom" : "universal" } ], From 89b02fe9a8ca2d7e766715f59e7d99897b89b545 Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Fri, 14 Aug 2026 23:45:47 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat(design-system):=20DSSajuPillarCell?= =?UTF-8?q?=20=EA=B3=B5=ED=86=B5=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EB=AA=85=EC=84=B8=C2=B7=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=9D=BC=EC=9A=B4=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#7?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사주 만세력 기둥 천간/지지 및 오행 컬러를 표시하는 DSSajuPillarCell 구현 - DSSajuElement 오행(목·화·토·금·수) 테마 및 음양 표기 지원 - Figma 명세 문서(Docs) 및 Example 플레이그라운드·카탈로그 등록 - DesignSystemTests에 Specification 매핑 단위 테스트 추가 --- .../Docs/Components/Saju_Pillar_Cell.md | 22 +++ .../DesignSystem/Docs/Figma_Specification.md | 9 ++ .../Catalogs/ComponentsCatalogView.swift | 6 + .../SajuPillarCellPlaygroundView.swift | 58 ++++++++ .../Components/Saju/DSSajuPillarCell.swift | 131 ++++++++++++++++++ .../DSSajuPillarCellSpecificationTests.swift | 33 +++++ 6 files changed, 259 insertions(+) create mode 100644 Projects/Core/DesignSystem/Docs/Components/Saju_Pillar_Cell.md create mode 100644 Projects/Core/DesignSystem/Example/Sources/Playgrounds/SajuPillarCellPlaygroundView.swift create mode 100644 Projects/Core/DesignSystem/Sources/Components/Saju/DSSajuPillarCell.swift create mode 100644 Projects/Core/DesignSystem/Tests/DSSajuPillarCellSpecificationTests.swift diff --git a/Projects/Core/DesignSystem/Docs/Components/Saju_Pillar_Cell.md b/Projects/Core/DesignSystem/Docs/Components/Saju_Pillar_Cell.md new file mode 100644 index 0000000..c767d4d --- /dev/null +++ b/Projects/Core/DesignSystem/Docs/Components/Saju_Pillar_Cell.md @@ -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` | diff --git a/Projects/Core/DesignSystem/Docs/Figma_Specification.md b/Projects/Core/DesignSystem/Docs/Figma_Specification.md index 21fcbbf..58ebe6b 100644 --- a/Projects/Core/DesignSystem/Docs/Figma_Specification.md +++ b/Projects/Core/DesignSystem/Docs/Figma_Specification.md @@ -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`) + diff --git a/Projects/Core/DesignSystem/Example/Sources/Catalogs/ComponentsCatalogView.swift b/Projects/Core/DesignSystem/Example/Sources/Catalogs/ComponentsCatalogView.swift index 2a255f7..bf220b0 100644 --- a/Projects/Core/DesignSystem/Example/Sources/Catalogs/ComponentsCatalogView.swift +++ b/Projects/Core/DesignSystem/Example/Sources/Catalogs/ComponentsCatalogView.swift @@ -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: "사주 만세력 기둥의 천간/지지와 오행을 표시하는 셀" + ) } } diff --git a/Projects/Core/DesignSystem/Example/Sources/Playgrounds/SajuPillarCellPlaygroundView.swift b/Projects/Core/DesignSystem/Example/Sources/Playgrounds/SajuPillarCellPlaygroundView.swift new file mode 100644 index 0000000..a640432 --- /dev/null +++ b/Projects/Core/DesignSystem/Example/Sources/Playgrounds/SajuPillarCellPlaygroundView.swift @@ -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) + } +} diff --git a/Projects/Core/DesignSystem/Sources/Components/Saju/DSSajuPillarCell.swift b/Projects/Core/DesignSystem/Sources/Components/Saju/DSSajuPillarCell.swift new file mode 100644 index 0000000..f48bf52 --- /dev/null +++ b/Projects/Core/DesignSystem/Sources/Components/Saju/DSSajuPillarCell.swift @@ -0,0 +1,131 @@ +import SwiftUI + +public enum DSSajuElement: String, Sendable, Equatable, Hashable, Codable { + case wood = "WOOD" + case fire = "FIRE" + case earth = "EARTH" + case metal = "METAL" + case water = "WATER" + case unknown + + public var label: String { + switch self { + case .wood: "목" + case .fire: "화" + case .earth: "토" + case .metal: "금" + case .water: "수" + case .unknown: "-" + } + } + + public var hanja: String { + switch self { + case .wood: "木" + case .fire: "火" + case .earth: "土" + case .metal: "金" + case .water: "水" + case .unknown: "-" + } + } + + public var backgroundAsset: DesignSystemColors { + switch self { + case .wood: DesignSystemAsset.Colors.teal200 + case .fire: DesignSystemAsset.Colors.red200 + case .earth: DesignSystemAsset.Colors.orange200 + case .metal: DesignSystemAsset.Colors.coolGray300 + case .water: DesignSystemAsset.Colors.sky200 + case .unknown: DesignSystemAsset.Colors.gray100 + } + } + + public static func from(hanja: String) -> DSSajuElement { + switch hanja { + case "甲", "乙", "寅", "卯": .wood + case "丙", "丁", "巳", "午": .fire + case "戊", "己", "辰", "戌", "丑", "未": .earth + case "庚", "辛", "申", "酉": .metal + case "壬", "癸", "亥", "子": .water + default: .unknown + } + } + + public static func from(text: String) -> DSSajuElement { + let upper = text.uppercased() + if upper.contains("WOOD") || upper.contains("MOK") || upper.contains("목") || upper.contains("木") { return .wood } + if upper.contains("FIRE") || upper.contains("HWA") || upper.contains("화") || upper.contains("火") { return .fire } + if upper.contains("EARTH") || upper.contains("TO") || upper.contains("토") || upper.contains("土") { return .earth } + if upper.contains("METAL") || upper.contains("GEUM") || upper.contains("금") || upper.contains("金") { return .metal } + if upper.contains("WATER") || upper.contains("SU") || upper.contains("수") || upper.contains("水") { return .water } + return .unknown + } +} + +public struct DSSajuPillarCell: View { + public struct Specification: Sendable { + public let size: CGFloat + public let shape: DSComponentShape + public let backgroundAsset: DesignSystemColors + public let foregroundAsset: DesignSystemColors + } + + public static func specification(element: DSSajuElement) -> Specification { + Specification( + size: 48, + shape: .roundedRectangle(cornerRadius: 12), + backgroundAsset: element.backgroundAsset, + foregroundAsset: DesignSystemAsset.Colors.gray975 + ) + } + + private let hanja: String + private let sublabel: String + private let element: DSSajuElement + + public init( + hanja: String, + reading: String, + element: DSSajuElement? = nil, + showElementHanja: Bool = true + ) { + self.hanja = hanja + let resolvedElement = element ?? DSSajuElement.from(hanja: hanja) + self.element = resolvedElement + if showElementHanja && resolvedElement != .unknown { + self.sublabel = "-\(reading), \(resolvedElement.hanja)" + } else { + self.sublabel = "-\(reading)" + } + } + + public init( + hanja: String, + sublabel: String, + element: DSSajuElement + ) { + self.hanja = hanja + self.sublabel = sublabel + self.element = element + } + + public var body: some View { + let spec = Self.specification(element: element) + + VStack(spacing: 2) { + Text(hanja) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(spec.foregroundAsset.swiftUIColor) + Text(sublabel) + .font(.system(size: 10, weight: .regular)) + .foregroundStyle(spec.foregroundAsset.swiftUIColor) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + .frame(width: spec.size, height: spec.size) + .background(spec.backgroundAsset.swiftUIColor) + .clipShape(spec.shape.swiftUIShape) + .dsDebugGeometry("DSSajuPillarCell") + } +} diff --git a/Projects/Core/DesignSystem/Tests/DSSajuPillarCellSpecificationTests.swift b/Projects/Core/DesignSystem/Tests/DSSajuPillarCellSpecificationTests.swift new file mode 100644 index 0000000..a139cee --- /dev/null +++ b/Projects/Core/DesignSystem/Tests/DSSajuPillarCellSpecificationTests.swift @@ -0,0 +1,33 @@ +import Testing +@testable import DesignSystem + +struct DSSajuPillarCellSpecificationTests { + private static let expectedAssets: [DSSajuElement: DesignSystemColors] = [ + .wood: DesignSystemAsset.Colors.teal200, + .fire: DesignSystemAsset.Colors.red200, + .earth: DesignSystemAsset.Colors.orange200, + .metal: DesignSystemAsset.Colors.coolGray300, + .water: DesignSystemAsset.Colors.sky200, + .unknown: DesignSystemAsset.Colors.gray100 + ] + + @Test("SajuPillarCell 스펙 매핑 검증", arguments: [DSSajuElement.wood, .fire, .earth, .metal, .water, .unknown]) + func testSpecifications(element: DSSajuElement) throws { + let specification = DSSajuPillarCell.specification(element: element) + let expectedBackground = try #require(Self.expectedAssets[element]) + + #expect(specification.size == 48) + #expect(specification.shape == .roundedRectangle(cornerRadius: 12)) + expectColorEqual(specification.backgroundAsset, expectedBackground) + expectColorEqual(specification.foregroundAsset, DesignSystemAsset.Colors.gray975) + } + + @Test("Hanja로부터 오행 파싱 검증") + func testHanjaElementParsing() { + #expect(DSSajuElement.from(hanja: "甲") == .wood) + #expect(DSSajuElement.from(hanja: "丙") == .fire) + #expect(DSSajuElement.from(hanja: "戊") == .earth) + #expect(DSSajuElement.from(hanja: "庚") == .metal) + #expect(DSSajuElement.from(hanja: "壬") == .water) + } +} From 6b08bc4d1da78f97f021e6a32580b9ae15e95dcd Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Fri, 14 Aug 2026 23:45:51 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat(fortune):=20=EC=9A=B4=EC=84=B8=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EB=B0=8F=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EC=9A=B4=20=EB=B0=94=ED=85=80=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20UI=20=EA=B5=AC=ED=98=84=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카테고리별 상세운 바텀시트(FortuneCategoryDetailView) UI 및 레이아웃 구현 - 행운 아이템(클로버), 주의 아이템(경고), AI 토닥이 플로팅 버튼 3D 에셋 추가 --- .../img_clover.imageset/Contents.json | 23 + .../img_clover.imageset/img_clover.png | Bin 0 -> 6683 bytes .../img_clover.imageset/img_clover@2x.png | Bin 0 -> 21435 bytes .../img_clover.imageset/img_clover@3x.png | Bin 0 -> 42990 bytes .../img_fab_todak.imageset/Contents.json | 23 + .../img_fab_todak.imageset/img_fab_todak.png | Bin 0 -> 5726 bytes .../img_fab_todak@2x.png | Bin 0 -> 17909 bytes .../img_fab_todak@3x.png | Bin 0 -> 35502 bytes .../img_warning.imageset/Contents.json | 23 + .../img_warning.imageset/img_warning.png | Bin 0 -> 4889 bytes .../img_warning.imageset/img_warning@2x.png | Bin 0 -> 15731 bytes .../img_warning.imageset/img_warning@3x.png | Bin 0 -> 31315 bytes .../Report/FortuneCategoryDetailFeature.swift | 101 ++++ .../Report/FortuneCategoryDetailView.swift | 156 ++++++ .../Sources/Report/FortuneReportFeature.swift | 112 +++++ .../Sources/Report/FortuneReportView.swift | 445 ++++++++++++++++++ 16 files changed, 883 insertions(+) create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_clover.imageset/Contents.json create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_clover.imageset/img_clover.png create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_clover.imageset/img_clover@2x.png create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_clover.imageset/img_clover@3x.png create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_fab_todak.imageset/Contents.json create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_fab_todak.imageset/img_fab_todak.png create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_fab_todak.imageset/img_fab_todak@2x.png create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_fab_todak.imageset/img_fab_todak@3x.png create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_warning.imageset/Contents.json create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_warning.imageset/img_warning.png create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_warning.imageset/img_warning@2x.png create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_warning.imageset/img_warning@3x.png create mode 100644 Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailFeature.swift create mode 100644 Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailView.swift create mode 100644 Projects/Feature/FortuneFeature/Sources/Report/FortuneReportFeature.swift create mode 100644 Projects/Feature/FortuneFeature/Sources/Report/FortuneReportView.swift diff --git a/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_clover.imageset/Contents.json b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_clover.imageset/Contents.json new file mode 100644 index 0000000..f333a8a --- /dev/null +++ b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_clover.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "img_clover.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "img_clover@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "img_clover@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_clover.imageset/img_clover.png b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_clover.imageset/img_clover.png new file mode 100644 index 0000000000000000000000000000000000000000..bddc762de28a6f7d40837647847ffb072f144a8d GIT binary patch literal 6683 zcmV+$8sz1PP)}R@Llu)GnM+gkOC$7uJ~i=2Z8}NqxC3;X_t3^LR9DwIcv~&?haweo znxZh8lzb-ts|W9#x^yb0lgy4QKwqvu@ScQ|oA!pa@^a!+%BivpgGtE; zv$6q^O}6SY@_@>`U4_LfrzUosRNi!40Q%s}zZBmvaPCL9*=yh5Aiwxba)_Sv4$>}f zQ6-AlW{YxWPMKuj$Wo+Cu2{v?OpV*O4bHsh3ce!wUDv&O|IhLR*2uhcPA0 z=C8vjzHwo$`i=W9y>wN>bkza+RQ0YueN*M7cP?|AXVN7OR7@#i%1>jm5KgCCBm7Kz zmY()z)qLn7+>o)YJP+D^@F<6d?7n?i;f|^DA1o@R_`?fNye2j6o7-`Go);x5hHt5q zC`s|FsiGieiekxU2AQVykJUr7e+7_M&6`#Yps62CS@yYSp1IB)zNhRKHdK=)hlJY; zaAKt>#fJ4N7Xb#3pV>1K1_Cv)QJ(c0`mKG;HU%z>cc66KaGh*@i&NcX3{Fy=a%of zAU`qo=}BkkCdanNmeUp^h!7De0~5)~+Kc25Kx1D;H0+j;j}i{xSpoI}WX`lW0g5HN zux-8>uHWtsTzg@Dz-=4wLL+6vB8oVs5WgS7jcQSZFiXiX41}zbjmxfe;l1*$tU1{DHbG7gveld(=lv? zyU))y=ha@X&Il4^!UK?*C})%@XZ6$e{vk3S7$7mL<(EhIVZ|85d$M4u;O}u2B2WgJ#G_RV- zm?%?VIfnKbF@VFQ-($+|h=3L$_~ga<{2(oaEovtT1*uPMB%l$y%;lV|=Hh^g>`;hn z);OhB5>XQ{iw6zm^R$q}1kflpa3}EdjitO$2vu>d*TzZm5`5I7bd0tlfNq}o`o`_{ zy1QRtzvT2BxwJ?P-m4ZAh#)2K6yZ4H2?Ek#LZKDglr=+or+1&4UGpDvqNj zas^aOQngV1;h)@f*D>eVo&gGtzK`I0p80sRj~+|+(xdS{nv3gFuTs&pX;Xe26oyDDl3b^|V3lE# zRV_)7F$U%h*KsFP++E-V~NDNvA{|tmGp$;H_Z_QbJ$6FrW zzhj8je(csA9QCs1o&XxNht_P%PjXGQXn;#p;ykq^d8$bGi%PQmlaWFqz0Xo|c1)eU zb|AcswnAzi%DW(y(2!eJRR9+lDeX_&;ME4gE6YWPLsbZ1s`P8*BgHs>2=rhYY_u5= zdp@f))+rFgk@cw{;8tMpHBEDF7MJ)O^y_L95k;Ijy z(2}9@nQGCO)P23B^wrJ|FpIN@3mo@hUP&@QqIH;Dx z0C)R|-~|fcJz)~FY_ibgFgU;QBpqHy0z;i+eZ@}d#%lq>Fwg$^M{Kq6{M(`U; zw-;$*X)uz=89(fB5~$H5I)3%@(33c z0lXF|B!dXuI!+fa5YOrEEyOhT*pkFx0Z8eJ2A}|jR6>E-M|E>erh<)-!#?lwg;)Jz zdhlr1+!H`ojb7TUk$-y=(nSm0HLC8o*CGZ@8KoLL?kBMc22gTw|*aReKN;(vU9*7r}a;Ekk z6X{LJr2v%TkDsW~q2b(lN4w^pc~IMrzJkx$Em}@B^60VFi?qocrL|^-2eHV4bkwG$ zBxDS|WnOaFSarN7;|2n#$y= z&l27tpXXQxm0VoMHfU|1MQA-}5_`#Q(_Un@Vp@B&>+QMKxTbvB;*Cr9Ug41W6I6=Q zUav|n?);_2Gbn(-Q8k-L;4P}BA`dvUs$ps zT{|nN80;mIOC;eMS_6tfgHow%De3Bl=eu^?Y)7X+|->QB;g?9(P5p>xd%+HQ~1gxyDF)1@Mk zrwgqjhvaKx@5mkmEq%Z?Y zhn;N7>;RUvAmT;RrUW>*fnV6y3Teg!-V#ttzQIEP26-}s*!8}0Up!=hC8YvB31pS-{Vg9L-oYfu0okc4B{5ROJd|BA?U)u zk#A(5u|^^78YGEa<`~&O0eYfGEqNg~5|4^*io~teL6r~Hm%qDX<5fqyzEzQ2r>3Ti z>U$?IHq7KU+t?>0zX2L5?@IA}0XFnRux?Jb)xAPR)4%m-7*w z)^BPO8%Anpe9*t-YZq^xJKD7!6F^^7-*blp=aQC+K0F)MU$zi6`)XlZ`4GBXl?K?^(l^g-r}+Kvi0o>(?baj7T-{v&Yf8mP!~A!P}%W zLDgc?uyhzUjx61DNT5l}B8-XHGE#LQ2>IeQxMdd8ycf>Q&H0yq?W_&I=w*#7Rure6 zo^mdTCO@4x>iu(R-E<(HPufFd6~IUE)uqQ;g1$44iUtQFiG>zg+7|R57bQJFvP4_L z(ohOt5x^o;n5UbIWTY0@mBS?yGAcUySS337n(#)uLmez&@tteCGrYGISgXCB}@4EVK$} z$w|a4Mj}>t58XD(G`s@sLfr_U8KP@f!g5o_cuR)_WDM;|5&q}l4Ga9{v1*GJL4SRm z6rk=S40Riv z<>3Rus$=ar*_=zJ5~8wIltWJ*Y|uVbvRo_cqwNp|i}G3Z6Y1TS1`TzC;Ur(bu`qIoj$s7UGeAbuy7m>tmt2mKGCh&LDE$~Z`IL1& zVY$ON7N7u#l>4+p61y!0oP#O|u@;DA_CD#kSuE$`8td`}iaqA4l~-%Wi)ayYVAiHi z^ikWOnS}(^FxBdzfpHM3CjvSP1++TcpJR1e*)IDoy?x$~_R!1tZaN0*381U@-#)p{ zUUTJ(RORV(N!73t@fv9oy9%E|@K8rd%ow!URT?!&;xQ$BtdHLm#w#hc09_fPt*XW` zBSh8YV4laDOvPdjNj|4ZrJm=D6at#P*hF4w6E}V7&!l>x*|p?4LNJ+T7L}2=)ARE= z=UZ3&w0>iY($C!cs>%|rza)nYu+ z5xX)yS(7_#^I?GZ;1K}oNv}aat{sA=obc9C8B90j$x?wv3IN6uecIGtQ)FAwcjTHy zgx7GtMH7Rj8YqDLA$F1TA#srGHC1c}Ox3!~?u}~;hH>j_?wTvn3alp&syKz0NqLng zG@$ya2FM=^f$IqBsuMC@u(JNBBh_=Jf}qi+r@{rzjssB~^Tf3%p`WyCbXIE9_F@IV zLAC(dG_Z*_#Cq^(=xQelqA8pap$aXG$^gg(OZGuN(N!RikwS`M*I<43Lr9WH>Tj7i zEe~f`@94^ye%Z zF4}6?wMF89FF_=8^HjAQ+8w3pyN7BNU<~ad+zu;>qCo3BN2;6f9o$}#F)Y!0T!f?} zxsQYpq8DSOI^PKMA-0wAwkscPzJ+?U-a?Fv{uXOgt9&IAaVwXJ8fSi>lu04W2lgKlCSux$rnoM#JD)1u>Q`Bk`&21jHQLwlw6SUI*q081{> zhC!yjJSv@wJS0TO)Wrb#jHo}aWmoD)B^)iu5L9bjG3oh5b58(4O}uwm z1&IyTd?0SBek2eS;LhVMo#{j78+b<`v-C4$S(VGc(Q*V{%K}9Vi{btX-h)J{`VcoZ zE;I}c*)ltV+Y#WTnVx&NKneiTk}V_m)FV9yA`4z6n^25;sOV5r4?$?5mC}4unINRE z>t)S7CFz-k7a#G4=m`uB&YzV^lrC@?U3ZMMnz0?Dy9Dk|O*MGbP2An7d^Yftq>CJ? z3^Yps1|gQ|+H$U`LrFWUK1e%)SSk%EWo>Eptyt2XzCrq-2%L~`=RMJna28kt)xe!* zP}NLMC#VPO$%C$0cZIhc2iMn=h-RVE{kq*CGj(aBGYk(9ZtUm{exUcrEFzrBJZ*I0>K+K>9e@g`WEYM@lSa;2)5i$2h#s8@)HJ4wfv9LqJ@sTOQWSqx zp80IqD%=472x}l7|FyJ0KVDu^q8$&KMNB3N>=*(Ra!dyxy#Wxt2j7WOOM8z6Z3vTV znj=a=OPPYlE{Y87yY z^4xL_|HssWtw`?praY#r)YSXphn{Vw=}lgeOzw|-ZXh9U2OA#5e9LCh#UpA$3yQQz zZ+2*8qLUONDAR+G(3^qJ?NmDXgne}XgAmH5+oY^iCd(NyrS2q2kNY#JRi`0@oKRIA zaGl}gtS=9Z0(A5>BF~GaKY%HEBtbUmCgSUq_qvSsU{MUs?;T zxdBhu+xB%CFM$B&0FsFHnh1jHh*&SLO@vwzr96=dD)L1Thtl!$qVH=d@WP6#J*g#P zTAFe4ZgS_2D@n+KNh=7CBz#B0u;0;P$eKvv)=whD#*v3k8?b20u(NQe;a}BjXTNfQ zI`-L3J8EGNe`?(Bt0~;7MKnaqag5A@!Ja&eC{t2R_DoIu41ki8XMYUY3`xf1MQzP@ zb~l?+g+vzQGq5m`oMplhsqV9B8B3IWBjF^i22vG5l4z_z(1*bGi)e}9En6zg+lEcr zJZ#bSF=rnH*EfCjaEnwK1akWxb_lf{^;fW3cc7_A>hE z2UFhY6lgq`gNScHZ-#eSU`0?KEitd|3o98$(*UDuQFFtox3jVSspNxkP=ok z0Gesvxf{CDw+x%3~d+y@ywxD%k#a{3*H<4X~%HSYNFVRCD-3Juf2Tz zy5hhaJe8a+vc=B<(iX@S!C`3z_l5*M3Fp_x~o}- z17Si74SIdaRJ&?W8@W^J(+=CxBV|la@S@^m-ZYQyT{LWQu?>A{)(_(!E!LB--tyAQ z59t_eRiCu~+v=`uhHc$B9f#*MFshLBF>E_DiN30Ai3gcV{z}Vp7%)E7UiJ@!&979d z2OpgG(mVVVlMuViMO48Z1Cwe7IK6(PKnnqBh3SybERQK-K#0+^jDJwpIosrO2J^ar zal?zR1NXf3@6RsH(kj@h0wngS_WnsP4z6nwztfMS{E#DUgiW5D} z-Y0%BdFd^0-*fjXYmxd%J#jH^E^vrm@{HmrEw+o8{F$@^G=|e zd6X!$R{%!QLL045i%CLkv`CDJ#{9E$#$~teaE|%W-y`j~0d%BINsn>;^)g`mUo`){ z2fs7CwSRDS+Vi;L7HA2rc{P$zwgaLxObs7(FfrAzuu-vKR~N&Cj_AX%Vgg7&*8(h`Oqt&{pGxsQ+rxIh@MGvo{0Gs1ZJP-s$9O1 znwWuMqG9WNhBhKSc&MhnF}D=|!*^d@*-OX4{@(!-`*!%mBdA5^JsUwCFbn*=*QQ!5 z;bf_5C>=U~zRz6uuo+nsE*UvHvS*Aq-QzDN&wTCT@uMCiuf$fZ%pZ%58u^DujsEj7 zDM6ABX~4GBa@0aUhw5jeYANF?ix0f}j>%WtOefe@g(x1YfzmuM!bP>-ETiKNs9&B; zWno4>T9O132)jc$`?mL0A9y{TU^^)QdOlpb?|@of-fRvl`CDcYhKd-X%JUfbDMr-* zX%rxpUH1!DP5)qsPN1C>0R371q63eIFFqzs1-b-Yid$;f$%!b7KM|ym7IIQU#yeXl zhX>wDC(urc93&m~^wK?Fj10bXz$h4V;Q|*NSB(LrF{eV?oKc!XzZQXohsx|pGCfv1 zDLhCFH0THW{e?#l#&sp5o@(5L!syZnw51U)(Am~7onchscGUbCoj^Nj0Fr;6q8+E# z1W6Ey>?Jd^b`sFuc!7Qx?4;+CIrV_|{Fj%D|9MQ!XRmhB03>$9;N?G6N%C&YgJxqi zyB;c<{yQB>vbZOyUb$=M&JRxUiT&p*P70#<+ji~pw>NQ|yfJ4OYh5$Hp5e&Jmleqz$@GU1&0y!Ik+H-AfiAcFbFsxO#{u^^djB#Qq^5+R&J58 z-}TH{z8h6KKNV-ps_G`c?yQWAjEuMu_kYj%pZ{4tfbF%tw%7LBUfXMXZLjUMy|&l( z+Fsjhdu^}nwY|32_S##Jy$NhXyXMb6Psc|nEw&v`~!;3HI z!}j_&Sr0n^9X|5bc&d5uiBVj8US1YY%CNWsZ4u>A8U$081wxwV>tN02v4k7PgVD#{ zaNs#dV0-;9t%nVO4j=pQ?B#R2e_*prerjy;NA!xsq-BN(Xi7jR5dh1=fHCM9#*#G_ zEMQuW3M}8dvA+Jw-@D?4M`3&YtJXsvK!|AGvT^fIhGG3z*KpJvlnIQ>+@z30jyV|_ zf((I(9!C$R8^EwJ;E@UP2ton)=v$9}ov-(Mzw+DrZun)`USIcmNCW6MkNxfL>6xiN z?qm9l6G_*M2s8yjs*rz6L#qTsn4!1H{}76R?;!9Xfyi3AMZdw&1kkV{c-F_!c=UnO zontThqsxD^2ixmkTn||Q{Z8*=&-YCI_eNzpJ0N?rnvP75TvlF^52N*v1JE0WA9}+L;k7S4 zYNC%nEd26??E=_F+uC=ft>Uj*K^AG+?kkDG@exE@jf`pv=nU-O*WV_$jD%$tlH)hhJhG<1-@o6TYf zBVheNsv(@ZA|Qi?8huy9`b2qgM6raA>T9wh550KaW<$! z}lkgB(4;cXcV(0CzeNO%QpS?CX1OphuI`r)Z^kIX3>f;#t z1Qd$7Fe4bGMJ6XGAx4yyXpSeFCZV4hKjWf=OkhMdNtlpDa%~t9BZ_Ru0UQ5cPJ0d0 z@MDhFHb2*Y;G1sT{V(&O@44f{v)Avw{D&K%|B7Lex5*8fF}-L)04kWk=ZJhf1R(i2 zBLtNUWfBzv&ns=fdBU?is}lG#IR6g72~39nE(U$+uog~Q;d;c$S+2*APZ3p zSu!`o<5`ztYgj6P$ZZnfL6in|w7zFc&ksnRDqpv8V)<7-@wBIn;UZpNKY&nT;q&gJ z5BL9KXRC<6{H&>K7wMz7NAV2yi8PPd$^bJQ)T8FYuz_9>4cGb`*Z#)h zx83uPkMD<1eR5$sYQKdn)eXIzj)+3Nl&u{Dh(MB~erUEZrXc@XknLlq$qb%3bt%AN z{)J*8`FKJCP#gp>%CkSb@ye_J6I{g0|GFP}0l*_G$$#_G=F;K{bYP?CL6_VW>pV=4 z`P}dcBfzj^X!wlC|MVMv45cy36R>W2-q_6B7GB@jZ$90-5BqeY=llqk2|y`JjeKqJ zNhA){Hwi3+0a+po5)1qw(P>-`{!2ui?BGus(om z#n>Dj_RYy8!5+Db7zqai?rG06HP6R>#x51XCnV8Bfxw3;p)4iKREvTi z{f@8Pe&WNAy5==-k*kt4D;t0M6Ej}>w=Qk&A@FofiaDL$Oe_tbSUx90Sl9~b z-?%?Td0(Y7MkEl*bCOgD#B<=z!D*y?m|;G&oF*KRfh7w>oXCXa2Iqn%xwATs$qGdz zGVX|41T(w&_q~-%-@5dy6@IQCI&;&H)WXJ_uMVfYX&4^A)j)mbki*Z5jKs08@fWQS< z0->^0v9MGq8Ihvtf1g|xhw-EY!9BwjAQxsjX@>nGyr}Bw}G=C^G~ia)X3N0yFDZtTAADQn1@WVUL*@vcfx?!^e20@1g@ofwLm5J1Tq?hc zeDd{%U`TTps8@&)U>UJ${5(wx&kxpS&AkMow$C8RF+(MJ{0szC7?!EJNCN6c)`ynm zlkENIsm^ESyaHad=-1#$wK+HjLsFqq$HoYx@@1KPBLqnCB7Gy0ycqk?B)7PkB3VAb zBrl1_5eF+RoEQ#?#CtFm##p0c9%N)unb?VqWlLmc$x8iaI=CN%i)>wR3v~GOpDfmb z_~mizzoZ5sc@Tr&;$LI#fzyVs#UYaQAq!CvW&sW|aTeF5=lm=|6DD0KAELW0UGXJx zWRD50v68|trtZZ^X$v@+Y?#yI9(2f!Z4?>1JW^yhOXg8%&v6;0Tghs1nq%6Zf|KJB zOvSS}BKP-{=_L{*4FOzC96f?9iUmrK7e+5p}&=$k=~FiOeY zC8(K|(u$=H~T(e8o524j0k7pcd#?H~;MW z7X8+*Qv|fT?nSm`Yj~Z#%sfEavL&2|ro-Z(gGb2>nLcWk-z2*wx0Nr+L)Z!#)|SZ~ zd15`7+X$%G3P~8r&x@LD-4#y5Fi$1lIwbKi6&u1JEa_FrUDD%nc5OJuQ{$1@MeZue zGT0q8&1x}1b%rDa5PxD4Oj-_xLXrvkbR0y44F^|bQDNST%x-#r^I;7?(K%zL$vutf zHHQQg!VkT%QZ5k!r)@{Jobikq)A5r$WJ0>ALLbAujruF;U4AcIM9YHGe)@{n z?y1keZf`g{yU(9DmwCJ3DtnOSVG;>@zy)9|7-syzN?ZlbOBwmj&%x;lka7+FrQ8;$ z#l~NGj|zq?vN#Pwld5z}lBtx!h-`pt;rL``SUpaqn}RqOM``^mF-<#7QB%xp8(LA! zVx%66Wt%2B2z8kB3_XI+fZT4MqdEdAiI|2jh5=E32GanE>rnD7vI-IaEi$oe@951dY&wb9@@A$*Fb>Jdd=N&*VKm8|-uatNF{-Ng17hmh| z_Yav}X0O=+ZJ3tpd7T&qWwc-e2SlC863|I3)dn#owhlC+^MGK=I(X@Q0uEypRtzL} zf=h=f=ku6LNl96qv>)bH1L8cC`;uoBnofU_%gHCplo>eK*aMwBG1Fm#pG*Gp$i?`c zl!O>7;HJ=@j(0@j0;f|*jV6Go^36c(66fiWog*$~89;m}HTwG@#rPB|LSWh35?F!& z=W793JOZrI=V-FA6=FsfA3?An*-P8U*6Nm{~GEOSK4Q<3P9; z#|x|_P?-4d;UZe+6+o}se8;P=Z0!B+tIR%_gK4x9vsftrBg!aA-{R(+!!(3P8- zT%s=FnN>Wcj)H-rB|j6lrs}T3L_7)?t%99|W5R(LXjU^|{5+J~ln2s@#jE07bRuL|#-BxxELsNUsn7lL zm)mfWtn&t-SD*UhAKnqoy!smdfT@{Sa#;M)k03RtC?$FyETiPOkh1D!s+EPPaTlNZ zvog54Ai8?7a%XyvVoaj(l7A)VPit`8BaF+Lf$ER)8e9W(EL}5~hC7u!fu8Z4K#A+r(}sk=g)B;%a=j1CBL7pS2D zXNM%wA(hY!KOx;pt~J0HiIMal3f4VL$c=SLE}-z<^wKV+9i%J-dg;K-x27mTo6?_9 zXv7tamcKyw4ZJr30kh1T*hmyYl-_hA#FrU#wctXSa7nv>~DYS;=*QWx^LQll<;E|fFroS)(Q zRF$@_N}70!iERXTj2;vY!%j_;vjr~3GZNe}M_Z?y73aqSQHrd%Bj+qNjRKO%WwcI` zhDK7O`ZIEZ864r!J24&zMe;U?&-} z6~qB0Tz5vZcA5w@LIf*xmf1p01vM#wwc>hwIsi{Vs&G}ofR7$sX1C-hQ4=f%+ySXy zL*_~HD9W``-k$(NuR$*hJ8Lms+h}nr1CI@IxN9SUZerTfi~ zz^imTrb<~T(J`)lN5Nz`jB;fil|48oIGb21a2VxitL{g*l8>YTC>Ii(6M=1!fAT4j zD|L%DU}ZRpNH>xUy4p$xtJT+Xxm@Fzlu~s^|JkxsmK3xS5u7|FiCh*OJ>Qy5%5!Xt z5mti|R)>YTx049=>hu9^jtX4Ov#Jb)l05eL^Ye30fBqMbz4_SkiQl~CSx@M~d0tye zqS?xaQTUyei=kvDg`^a=+J+ElE^FWfC*QA1ii(6)xXmRN@;vgdnSA(QDnfeJWlh{1 zeH9!5xxcDhA7A>pmVUT1s+P^&4>KVh6LBIe6=lOBC2LdXDn$|*MJj;#sE!B2CQSK( zb7krVjVR(yC_pE6%8NVlT(N4@`$VtQ8UXY-aY6Qs>xTIAU^Tl(lsb2L)88(O7 z#^o)v{2BGW0xLRhHLi0hD0DBwKQnwuRGj$qcG$osxvR(v75Ki7S4T5&jcJ=jFM?3C zZ{y6~S;>IYD+(J6l_iq&3+l%$M#ot9u0+SREFZ9vm5~dZrtm#0Jm5T;M4Dusi1(OL zPSIXwA(5IPipok$sS-oMhcSbs@CM`Jpzp({Z}`lSm%w>kTM8hr7C(N@kIl|-+AIc5 zSn}Iu!JCGiBp)t%Q!s67WSRr%$@26R7$r5JZc4cXTNDKYR^U)TpsPfb9GUnl1rDps z8uN&2!BW8ms%oRuP8X7p)$BeCd|X2vlN;l*cYbV*fJ5mBsVHP{MRbJo1a=|D3<4{q zuw+4)m02bGlT?f69IFy$b(K~GO{GFs+?U*Id~R&Sc|XQ+N?H&J3lZhXMwFsk9U-|i zAkcUmm%3PhEN>~U+Z3!Vl}3({fwh@H&G_M)p8xp=f7SW@Eni#8fp*31#U0T!v`h{9 zv5mM=AL6e3F^qibLPBNnWweT07`V4EQq2r_uEH2I|9f__uQhAP!gu;1>vk{RK zo1}h=P6k^A{vDIqQVZ_Iy|M)v)1z|GCs82C^&qaOQhlrgjHX#ETIbfP0(u@0QW9E~$$Glq72j zhkW2ti0*(P+y>NLX-TWP2?Y{VQ8SjoWQu}vfdrO*YN12X zSL~@(lS)LkFh)Xr`Ya7F$>z>PwlaKsBzYBsOv1VNjO7dd5=cw60q%M1HywOAY~k8c z0G%3ij&4%Af@}SJIt8OLfsJfn?k|@q8x}#Pr`%OJJ2?(iEY@z9Qmu=UiwTgN55v;A zFmXp^31_WP<@U;Rk)+XDrF5ed##XAIs>r3@?$dGZuP-8(WB4vhA9}tkk(CI_h3!^! zAZK%&RXWRg#5}5XLbf`}llZ_XQAf^FN~6VeFi0#8GKsE4>Zm}$APQWt$B$2oN;(ir zM4X9U#IqO>FXCL9PXG+8XJ7GmbQCNoxhJaw1SV zc2s%1$|R}&B(}^LE$*84>V}Vo)gFoxrF+$rF4iitaxs@gTog+Qm_SJ$oQ=Lp0t9m1 zEB%}oR^1SB8@ivqJo!1#^>G}&4^tyNr(_OAuRx#xOW^f9#iM6en5bt!-?n#8YE<0dc}?x!4|D81<)VvecAdrO+WoWxk}N?kna*Vb&6E! zJM&|3&@5oUC7|x=wMquHE2mU9Q5D8J3!(=t*-0lnD}t)nUmvQ8CM_xxhT?K!r91OZ z0agH5sNh*?N4rRF;6D38I#+!nv^?QMdMM->MF8axUh4=sU*Q03CGjSpnYb+Z9kSI? zy_5U#8EhKX8wL|S6FOm0qcnL8S303bT#SH#oa%@IZGg;*QjUa=gVG;TtiYBhq7O2( zTv|)N^Xd5LCD{svf&U-=(|`R!09&xOyqu$#4*#Z0yoXa4Tz||Z5gc*dJvv1h@UhwD zwWUj9BEVItB5Pb4%c;>C(He91$ek`yR_Jl6G-w9a-Gim8CNdSpo@B*E^dsh_)r?k2 zJNu+rzd`9;rNKG#_Ev?2i-bcrof9o2-gy1fzSbbqA1amvug@{BV`_qP$M}e*Kwe4D#=w`woT>%=ur zf(nb1^8(HqX+to$m>vKo`i>*n;@XTxTUBOCB{k8P%!DR^nM7`CiddX0goBmjHUja5 zApxi1g;=u!By& z$F{OUXDzLjz+Y;gcnlvCX0BR{$v<(w8uyRY$W0}M6z~(#R?5Zl7+nw);c1Z@S*D;? zi=GZ!u(ljPZ#wYeQ)HI^ltn_ml37FH2^BfLu~Xv?-j*GML$F|W_$@hEfj9~mla>4SdU<=lHsYqNK z4PO&NxS5f?o(gMnuDP|Qm!;-wgHsfht-vK_2kZ=HV20G+DH8Em`Q^^cKoX2rPZnrc z%U7gVDa)YRq=c}O>Y-8;Wuu~?E0fmWIQs*t61ywiQRl)r#o3@G1l4oafS)yzgfH_B zOAY+xBY%zyZ9 zEL!60uh?bQk@!B|t^)Xr1z?+9HSmy;mubhAOiE)zu ze|%fYegQIw&=n#nJVZS#6`+_#^_NL7&fvLucOgfcZr57l0Tnmio zGv{;`;)IKsD#>Y7v6kFXQA!?5{T({xrt)U$+c@XO$5gYia0=h35#zcY=59O^hU=NN zqk1Q*f;~Qk$j)c(33S1p$raLNj!WOLaRulcHuip6w z5!T*CDsWl%qe-@!1;h1m-`p}f0%x!fyI_{Srp*pJh5Kw9cLvkg_F|dS6BDHj2~bL6 zStI7FQf`B0@(t^*YJg^2Bz9^nL)b=ICG9=JMbc4Zjd|2RvgAA8Pd|~F;%?jA$4%m!t=XnfUu+AHR z-uh{Qqf(t=srYZ}RjnBdV<7sEjNTd}eST+*6)`7$ca6DVQPqv)eSy5@qfe zxD3Rv5s(6LCxNYUP^t!dpyi>Oa}pq-zB%XSK=MZd7Wbx&MtlllQchs5+=s34RdD0I zr{QQXVG_(llqZUb6=R^P(7<^rvPfK7QE)vGMMbRRpe8sOOa+68Zi}^HFc{`Ts%DF; zlhPB3xUB2mn7FV)vhC{rKscT`t4Pv68KIP27<;q8tL^WIqay8SWh6^%W;U!DYT6yV_w?3|3ufL5h$nW9;F3%}F{E2cI*Rf;+*Yg1^$D4Rr zuwZsuiqK$6nh;qdo`JfVF(cFS66|>yY?fog>5;0JUNd7_5tM7TUGFXR0?$x-6LGnp znQw^?z!!UGfCrT?G;45Vy>Iq4TX4sju32j}aIx-*n~1D*v%+yw@wyp{bXGbl%F0Se zT*#`ks4*%rXc2%0UMf2omwr<@Q~ua>)mC{tRQJlq>gFy;)USF|DZ+Q2Ya;>cR=I?a zJUfXMYbM$#Do>#t}KDCyWo)_bCsoXA39|&E! zt)Py(!nWB&O*3l1P&hu8mNUz#VsK@sV9 zGjIYT%3{2$B0o^Arb!wS<*8sHoFWUN&ZG)$S0Vhki+jYRuA$xUmXAM?{ zXjUjHNhsS*^nfm352h%6eN~fOVRHu75-Ads^a62CJib1FF2%#_8t)tqaQEIBoUMg6 z2zT?z`MMhikt;#KG?cUODU6qq{5=qBr?d8Cz@qt%IqGk&!r$JnRw$imth!BlS?134u0sP&$#kWVGGtp0ibHV86>SpqyO}`MMFY)T0|ip!|zzM-9Ar z(1l0to-xz)h+IOT;kme@37--GNbL?;om7&gpr8S-%KSS3X@*ZtmC@cY)O3Y{46F39 z5d+43_@GfBI*2A^A>)BJ=!)R^C1iO_Ee1-tl-wpQ_1;{k7#mxLgYv8Iy8gg5utihw zFDd|4>nE3g`-!b!>c`1J`~Zt`ksn~>MWUuL(H<3rd}m?C}WXczkL}2de zj_^t0W%&5!PSezG3sY3P8_?}z*$_UuegZbLkqm)i3zjPJEg59Xy~K3-IdEy~2?~SP z$>c`#0Dh)<@1M=nY?pq<1z35mTPW< zEm{}NjKpgF^vWMS6}%`p zdRT0<0hW0SmhA{W+s? z_c+C+I%nvOIW^AU-qit~AV=vNI6o?jC?j`g;`j#nWfCo6*Hlz)0=te@_t|lM0jdr@QSSj zPxU@8iuFqyZ~qapGA}JXa}`fd9Ty2n@)?dv9~z`1vN~h(@jOkw!Q)ntz$8PNc$V{^ zNMvATR!)Q(O#&lfaBP_3XUf#KiG?n49~K zkN^BVk3RT9*y43T0mN6+Z*Kni)A!Vt{xf~>SFaac!!^_WJi$RRHsdl!21r346l{=osBwtbh(EcQ)ugPC_<+7 zBoklNut8OoC`nNkq_ufc%GiA2cuaPT@m=YLFJ&iMwvDBmVZ_@Y&_C1U#cLF&-`yF> z^w&0pSdU%wkn4nc!nDz>6-9af>gdm}Ia(-*iAFd^A@fGG?!vIIp}#<}oSEX13IKYD!E zuEQGaAXn5deX?jwJacte(mbdov#t=4E?$YO=t>r`Pc4<<(EoIj^FDxOep@4<9_R<>29U zK>+k~>ua5V%xk??BTJ^3a{>H%B)@gK^ z@KT%!m$@cm0u8ybSuZpvyJNVMRO`idQ`&VhHYL^<_;Qk$g=4Lw2G>UqqJSI8s{yBA zxKvekR$L4zMdE2MM~6_pmT|dANxaXJLn-m%xt@8YdV+Qc-bpeC(98+3{WFeVKr!34DJ zxEh8a+au9q3a7B}3GNeQ`bBDnD3iNT23nT65XiCa$0b`L5I-Wpa37uY8k|X(PYGYr zdY#fKy3(>ET!kw?+|UK(LtMR4IyH(8)a(rFWXi}ItPOE}nsRb6IWLckz5?Dch|55X zHz_^QCO}^kC#5GT7HjJy7taufTMWrXl3QvMP?`j~u|%0tl}TDV>&pOl3^KTVBjbs# z*dVqZMRX{kludINM&2K$pG?N~C4aSl=}tJ$>%3Z^-x~hK;|@l<|Eg(gb9DmEJWPw* z>Vfu(oyLx#l!P=MW~OjL8cxQspsO6^5MHVp0mRa1_gz?*ku{s3+t4YSuYmv(*d9drnA0oHi~(Eq#o=F4Zpxpytv znb|90AHgRi3o?QeumQ($9X5FXFXA*A>&K>Elc?BmC>G^CyAnMYiR+6Di87opjMn!%k!Kuki(6V}dO-HPSDhb0ja44#qUN^_j zALG4QGl+-}!N8hEjbuzR3vnZcuoglnKrx($!ls+DS_^pUydi7j!HmyaApo?wv(%NO z@P)$c=D3U;HgGSYazJ+uxY^QUfe=lNIM#x1`mt}{wK>Y)_8YUaH^T*9=LJB&aO#g1 z=7O0IU2XU6y8`yg=G>=Y6YeLwbE?>s_GT%z7Xwgg1z$B>0d%aFoY+Yxs;J633>?69 zv1*CEJ_k(7D9}~Xw=pIc5kRLHD!xoLq@gt0Q%E_pQeBu#zL1U%QID}@d{`imZ1yw! zz`e^RZ`N=so&t`$^3;PKh4en1Xzo5Sl7+aWO3I{AKBZtGK{FcCTTTIoW&W-c_N$f^WW95K?=2R&ag~7Nr_9&R&-)5@Ny{!E&Adho-BqBdntrp8>G;pWa^kK z&1|bBWg$G4#uyn@EXuLWs^$=$J1+9lW(h?{1Bw<`5m(98f;0>g637p+Ya@~*m4<=! zB-^S)3|x^nhxrt3g#ZgpA7^5o;0oW9+TbbQ`{|XpOo#KYearP1#%-Ouwj4mO+IZWw z`+|j^y43FCy-v+Z0?+-pLY9Eiol-VBDZg;~ni~#fO+|brr9AZ!HC-C*{a%rVOD;DG`1>u4XLqf~YLTbwzQ`q%)8qo9L3|%)%&gHg$>&N5nzq z!a6pinyH5o-N)$_;xkbQHF{o8YN&Z@0v{ze#zZ~?(<8j}MhK&XqBL$JE3*(dC$R0xle(Y8Dz8>4W!2$!J_vqD5YCfsa%m@rGR7`@V3R^S3z z9nxT6uI`(IQxQgyMeuq-RCzD@k4sMH>mD9z`R3BO2==voGt&s57I{LP zd4~k4jAZRdi#cQx8qbUl9EYOkKn8oBTihI4T;4=Dv4&=4qqIS098*Y>Js~A_T!XydpEt{tvZx3yFQY20L|xTH zGO%V+AMBFZ&b?_QW{ex$r2}>4PqZk|xWbx3T^{HtUmhW|S)kMq^F->Ipk}5ir}eeo z5I?hV#th4`+2J+d@iRMcf4y#YlGb!;NG_LhaK{Ny`x;HD!&o84fjFmBG zd+oJX%|qRW#Z9y?6!U~Xwm{OVJjx8YIW$a`rCg)bZ4-nBfx0kxE=hBMn{6ax(L^CH zwCLX>H+t>PY40;n|J#kH|MzFiJOJl$ZS9Oi8$N}D>0C?MVa96$Z_1sy9V7~E7w53t z$ZZFXDukBMTITh@g!Aa+ckOi0Qc>%Er*+3t#w8J)r%Z@`$|@p zH{m?2Ewn)Xp1H8)xBaFRIE~ys?_;6Ge3fe9dsP7MdPLpKWssk_kE`NEd|O+UT~f(` zNr7gnraUd$b5_dj-(xuB-dT%bX*z)2Z67b0X}}(`B)lUICq}tzgGjY>UXa^`sb&k7 zDRlpfQx7n~#w#go|LE!lbW&;9Vp3^=hD~{HhXpk5JxcGO=;V}n z=Y7G{g1FU%dj(H71&A+qI5(8k6)7|wXV zdl>P&blhy21&Ts1o+X#nvJsxNUD={Q_SGWa&4VG8FSu54ttqYqWQk^sbwgHN19HWI zbStk(SnsA1XboSbS67wPn%C9}MBgYEs^o!O2zogoy}lH`s~*EuhU`otBG@1A4`C-g zB_(6wEq9-SRnmBNH=4+xEKB8Lo_pD9a+uctxIquDNmhG)WN!wpN0fKT0~5FtdO!*$ zAA^9N;`YeBHzv!fV{y4GO1TMpIX8M3wL}En>*+hs#$Z~_@P)U3a{Wenn!kW8UR#Dz zN|E|4OIN*}ffgk(bxyLL*Fg3rlqt(H!qPY)kn=^>{eSkhvT(7)w8E`98@j={%wOUFMxb9Rb2y{Kfu4$T+q4cu8S!&jy)o#IQRu5V+F14G; zdwWSOj}=Sdrn9;YTqfF-vy#TOFuS>jqV@1#aRAAkwdf$)#DzjdxDIKs{4G(BQUY)Q(Ut zpz?6at0PbTs>Y+}EFfn{P|Ikvtkd<1xnT&VBF=6SSXIm;Z=+O|8qRTH4wh<(J*%W|RLSXf}a@gQb zw3&b$mAN2rNp&>>dXCcWds+tOW6GHZ`u_RYY9VtZH`>OrLGq$P&Rs2~;QVUfj5~C?OZihN)gEW*v5(c%Dke?vDSyUAH(-;A?Y^&F4`sC}V%;>y)hZxqlvB0b9Jb z3_y2p9@`ug$(XN{dDE1um9yx4Vt9|PJ^qYby_8R~k(IqPu~n>B*G)KN~n<&vi@JL2j*NlWM+s(ixgZaYjV z6cezk>oQIQs*cAvg%kA^oC}jwg^mCMZEfbE4F$V3dcL(WPuWn(g5{-lEK}{(%F&BZ zf?r0tFlVZTC?j49mn>`qd>;v$p_a(yu@={2e`BcR2#8~-c)HtG9AL%9lD{h;H-FJ0miO;~Ygp3YZ`n;X=q(s7>yT-B+6H zwZe0Ncw49uX+$H6`IA)tjyqC&W3DV9OGbycwAk3vY4uwMpu^^HImYaLD>y7;;IVKq zZ(f3&LO7ZCWt80x+XUWdgg-hh)UhP*fwN%6cJl!tEo9RGFhX8HuuQBg`IV8i2DWFyfThpJ5iPu)}NXeZOp;A~n2 z&hCP!ciTxnd}RUUR<&7-3WBjeUh}1%r zBryPFu3Z7D=mzw$Mu%|gi4)4HT-qtt)et+! zY)!STX`!x=r^U+*9^dzYvh=1wzM^K`04qAbiBkueO#3cuRywR4$MGKr7M%2ePV_0Q zu{Ju=&iPeaC4SX-@v~9#UZ&5xHYC}VhkqVX)HUP|W{$R0)sXisL#{HEeb7XcH`1aH z1485S0Fm--Jf7yNAA0MNEg3Eau*CrSxvB5E`FOf<>z9g?l!s5affqLOR0`X_GCpNC zF_DQruK1!713~+ws_eV~0QGbBDs`uFN3JEuiq+A91WtUZ)a=Q%4^B^VKr=cmm4D9E zV$c;%0HQ2t!($gFA-e3@#V4OjXmt*e4z`kLOK*VNqUtPes@JQcFPP-&B)U>CqdOq= zv$_Y1Lq81N$QK?B@-3N< z$ZJah#0H}~9=v_C7?!Kq$ndnRo(w-KU_Hz6L^^;ae;VtSV>O?RHkoFR0b!*5S8V{h zFL7boX2-Ifr};8dZ6Ba-1)bchQJ2muQ{Lq9;7h1L$M@wWPZg7mOn5RpWk3SqFXK@{ z5ez%6QktbBCe(CR2Dy~Y3Zbrw(M;)xJXWYNI!6dyIc#~(?^I$y4F+7s21EkDs{Tm% zmRb^>1*8Vo)3U`$iIj|#k=vH{sb`CYyQeI1CDnuYK?Pl;!?ALz)E8}XOV*al-LIZ| z)|+}+de>N3hUJ!Avrd;tpHj(pjXIJ|y(CzGUEwq|Y$T-?+WxCTqZ{&Tm9FxXcu!7j z0B%lvH7(H*aGlFzv^>!<6V-(6Iq45Q$!vyX6J&mFqo*gQ5k1ThrjEdl9RlX2=%_81&fqB*}D_RKT=CFp;OS z!Cb?@-j?ny#AAGKIiABEt2rF)7CbZyR{FYIU6S$UR-*xI(K;^xdi|~&J_&{Qx`a+0 z&ljX{6o+(8tWnnaSeD}Z*N<~Y0wTsjfe ziGpHbv@c!z&(#NjvuH9zhl|&^)IwYu0fr*L)VVj_QJOR1Z1cLK5yld6Q96On(e7Ag zRZD9$!)=NVYbxMK4P`zfM@!y>>YGYAhDk2iD(&W*PN~wdy_1xlnIw=EX;uO>vOpv$ z@RkbzyQcu3yq8q`NcAV?JncG1ZS+|ZEjZdKaD(@jr#OF;-Uk~U;putNea3^=uE7?q z^8z4VFK<2Z)lF0TeV&s%7lvlUzL{tBkV`w6Cgwe>$Kb(az`LhX?ks`@0?~|DGj$UP z2P(D3hMVgo@mQdlfC?)sB+{Da=8x-sq*hdHeGGxmq%uuahkuWA7wXe%H2u)zbRzh* z){?}`5D#klGI#}f7}Zpusg`*nMLTPjJ4u22yhu%!j5I01$Pt<+{uTr&8pQ#)3b%jg zv#dE+YMca#3Lk~OOEDV;=v-_Jk8tr-%98CfN&n#u`b6@`D4OMdIoV*CM+VaM(_~ub z+0P$7ymdYD=M6w?C_Zu1+DoTx^DjfrhtUZdlT%~uXGrgQEzQk`)|c^9n=2AUurp}b z0A;i^?8zk-N!yFGM6Ob!-Go&*t5Ab= zqx)QfSISiczOHE3RMttK$W}6>QR2Ry0X!>Xe;xjfxu&KTO-%xdiXaegk0e*6}9aETpz!< z-PlLuf_~!vZEAMn^0PY!HY5uMk-Oj&5AKtP24ov zn#OA~DAQCfOYhcUK~`h{mmxJypaiW2;zt)E8uGkE^?9u7ODTsh<*?lPEHy-R0y<^r zxoAI4{PNdei`NAM5U*D*T=#`H&OZ8@IS8J!FP#4Tv=?EWw^o9H`?biW6|kC$_$VR^ zHw{xVS}cflwy4@C!|mQ>^k+}BAeQ|DNr0+y&AJUoR~!nSV0-mx63{|>@uX{ zgX>C>u`nQoD8RBRxAgd$05NV}l@_W(7FmCsTed``KWHiLCaMoazy8IzKb)I9`|aEUE%-b8U$IZZ+$M@KRikT*3Ux8El##Pxtp0+C$#rK-zPqI%9g75`vV zH3(GtGiYW*DkV`jda+fyLQAuS?$eWTXwFk!2{>Frf(_y^JPVQAdl+^NvNClp;HPJt zA~)KOEnYUq0wiALQxgCw^jP-P` zube^Io1KixIO)3$-PDe7^mb3TMq~yhg(&(%P zxVq&k*#TOJQt9VL>jroh8<(ZVIlhFnM>z=*xB@;+fP1!{DgVle|y0rSKvIYiwZ#RyY^|FQIh`_Z@{uEj>Y|O_ZTHI z%`%<9FZ6nFDoYImDDr%)st!Fl0X~slPdE#2v?80UaL$ZC)F#boF$xR`g9J!x$fB8m zfK!XCsf8{FmXF1C$dD***$D&l+0|9S0lhv&g(+;Jj?vvy_q4CSRA>}c1cpk~C;A|` z!hUg{;OMoIVm{^u0e6P1$gT#$fhmB4QwA=drQ@Ps&XCyjm_-W*THGt|$;==g<-63h z$gMvcl&J`b`|OFhQC_1g9m{`xtsRXC69qMH+a>&l7$va@yhiw@N4mvwX9 z^OGWuk~e)Dk%;!`%^o~%Tt&2+wY1q2)d)OwM^u{>p0 zgi$CvAhH0&2|CJJ4RwoOpihSJD#by+Rr~T+AMzRtxs%!2`<38s4Nh#cVG3ielN>j%qsLFYO(AO z>KWZyn*(|tdhqG)0J$8b;RRUZ>GbTPtbm8hPC3k{yqa&WA`NL*z;RmvJ>oNcVvK{)3N}ax3h8m4)hZ))E!;Htk&~xkCq16WGJJV6f#a0_sL^8%G^860 z*$(oQI_`!wZUbLe{a?vMljX!i-5d{e6dr@3PB)r9yCW;RFc0X$_(})yW2XwZzgJ>A zEX`8G;-ML`SPkx4EpWA)fAMSg^KqgCx6lR1&IeOh%;!zE5Y$&@&e){_NG)(1}babx@d{#LxcSj+k8}-m;d# zv4K<$g4iGF^zfVp1a3-|ag{%y<3dnJQiZlOGem}$8g37I`iu1uK$lciP7sr!GgBI# za}}aQg6o}Zxt9g+xaa=$_rL!;cYP8rg5|+Qx*k_+|M%9D9<#F^1lO@blSsLQE7UVu z^2siaKqdMP6VXVv=oTxaljG^WRCuhF_U6-NhO1@HefhAG_MkJ|xZltHZ=cII_74qS z&$fhHj(xITT;9q>W+ihf0?Ptf!xO{8EK{)7&kQbdG!*JKmt3tl<*6Ggb;~C4X&zT9 zTP>$Gnlr;HK$Es*_Bt0OeW`=u25?<7F&Coa@tOOyaEcZ$xLwc-_@yJ!6uc0G3f z=U=-J{LS6(c;f?bk*tf>0`dCRBOi~S+1~r+sW|@OWy;vE<*7_!EuHe4iTf~2j)^D- z*pgaigLUFBfz%KgPNUi`bnk~8m6xr zV{B83|H$%y^+=_oXXCPuA-T(+xa2Z(M<+86Y?7NJ5^`lL#_MJRmK5x%0r03^J}6mrCa)#=U7p>N%RVPu zGV+PlQ$(P5!0BND-BE$JbrRY9_L`Z1qCd^M&qbb);$UQ8C^LVwe3gM-R(TG|_lvK^ zOlng#+fT=UO`>aDA~H>CGT{*K@07zc%getDaOk2053h?FK-GHn;v--8qxawZ&b{-y zUq#IJMMIm13?eROSfm8dt{@io)g!Q+$uk0q>{v*lc}XWCqpuVP*_hnL=qAcA{>xudk0;(yfp(m8Wu)bP7z^-cWi>xRqrR| zuTQRRF-F)CSsBAJ47J2GN6nWd1xI}3W_|8$r))kKzL%;45fw6XO@jy9tXBaJ$d|2m z09QNx*Z=JehnC?YUJnTX@%qC<-^_mh$6nsK>sQOX{C*M_f6$NON1n(MIGJTqC(WcM z<~1xt}E0^6WzE zSE0ytLP(!j^2q^U?18i{Pm$)cdx{dFz82uGQQqeM)#P)FcO88_T;%Jb=0N}Ca^vux zdE4JzvwvyvnvG=ioae``d*EXqKm4@A`9Jq`KXK$e-x1b= z4-obCmdA;tHEX2O1zt#;CL>l75FC91jz=Ky&7-IHz}Gq?ERHx8?-OJ4#d2Le8{)4^0658?PTb`kpIxO;6oE7fyS3_tvE)x)sGljO%bJNnxFfxCta&OWn;_;fZiQ zT7=_dg|eI{K}k~*#`N#B7n+@9TK3g=PYe)Lj7y9Cv@{9%+<~jd;O-6rPdQfu!xPCk zOy?GzwQ>2uwFmqE@!j9Kcp4r8>mlR9wsNJT-Wk6=1v8}a%!C1j`56+Ak==9l*SI!u zgxw;82B$cP?5>Rmu$G*p4EQ4SGU8%93bxR_<2ML=J%nUDPUp;Q65inkJ_Vt~MA z8=(~D8{*o~;gZ#HIT$3#?~Yf?UnB6Gm+^G}7Hb zMr^81^1v*CYRvW0NXc9)P4n1gSs*c!;fS0MUB4c|q~bgHPOE2mi4<$ZaiW z1R%+CaoXOOQi>qs4jBxkW~W+#8q7g0vNpvSc^2dq2?3cD=LSnEGbA_c6r8mnbpYPE6_f zg=a}D?639lZjuVWG9D&hXrDZB%O5@G`XM~T*EWFMimknk3%2G=9rs6VIGOjPN16LN zIfS1JBiP7Ts>~^0#xu*1#6N^uDMPYIzp1@=I686SFPFakyi@{SEbF4n#$TwlF<$#> z1Y~I-fJ^;ZE}xZ}9UfB7n$ol%XqydpMPSC=T2en0+uTGXB-M=dW*fCvKlk*fUJBnp zYa2jr?Vq{iqXiZT@gR7lnTM7a@P4Mq#rESemAusqw?7yi@5KEXp2;ckS^{rUK&NXJ^y^ke$tV zni_9JUT`Oc1FUTTowGXS_@=(*&*5^rK!EV1Kuq^& z%~>R*ByA~Hh~q16GG^fL=)0KfdwgqJp3LX)TQ&dBf35eW1$daRZ2+CKju)%%c>vdC zqq#bSu*bI8+E6kygM{~;$5aaXd5)sy({l;+8C27Ok8|YWc2Nq1N5VwL%&wLBJKe#;BtVZ62hbj~`w>p8>CV*JkAi<7(+x!GZ-WqvggY2;0TH?uL! zh7Am5UrO#^6iXySRXA5MAb_39`pAGnBG$x-C}rXQ7(9&EHh})oI<|J|#`SVoeyvy) z;=IRe%LE?gLH%Mx5m^Fz;wg-gwIeAYqX^6x=0Q#0M<&!MMQ0iJ*^`B82EkK*YVEUI zzGuaSTH65nN9*vO@B3^w8QgMreg;mIn`SoPS=Kxz24$bu^=t?WLBq@hF^iAT@*{Xe zt&QK@+zn4@?168p&6{~I;#MN=7%_8x42^U&3lG!vuTi4^VUyFtc%9ko{Mpi6_{1+K z51L)!j9K(sl=E7LF(p&ivjH?sKw0`}xM#FZ#5ppLikDyxV-uSYa<7Cty%m@>H8Z04 ztxq3WQjfauFkRdAK>x)0<;@%KGM;}amlP6NmZWHkJm4vp{XP!VW+0F$_W)0h519sK z(*n9@6^A@s#4MObc@n$u$@Ju{FO9E%3_MKNwhQ|wRxca>JSja)o^ebhnmG{n95%c_ zCS)8RZxCrtpdhmR#pD>Q6+^Q^_J*vEnK3o#l)W`O4X4MQUx$bB+D@ka6YI#F`RhwN z*^Qp{Z-8old#kg4>V639&3i?HJdBdg)CCVf2zApuPkJ+Xk{L!aJuNU@NH9y2kC>_Y z^A4M*jp1RuwgL1ntd~FdCs#*P%^Nes1Ki|Oz}bOy-0d2AzI44dY=|{4!VF6a_VZu< z#o9A}8NLD5Hh{j4_0m)CJP`ZA@7vHmz0iGmWEuyL+T#uLY81*x;FUzMzcd_VKYe)d z-+v0eLDn{azRq>{@L@at@kc$gu-Q+MZumscns!87$d>k0V-QE{`7`E5; z`j-|rJ|8~v-uBNw@MkkP*`;}VZLjUMy|&l(+FlRSwe9P-*Y?_8+iQDmukE$Hw%7Ke a^*;dXzaZHnVsG960000+V_ao2Q$s&u2K+B8fiWl(l@4@^BIn!S7MHObb>;mhy5A-K!s~>Z+Gp=DwMdP_Ny5uy(tjGdxvV zs?W>n`p9%EM*lyDY8xKi%Bbn~{mrV|pMU%3-Eyzv)3p5f4S=o*BbE`Q3b#+Dg_F9> zoV@4}!V2N{1j6=b`)%nNQLQR#Pdd1(U#D&4qFt_E?YQ;=4bPU@uSZIE`Pl{@8e>t@ zs*1urLtZAZz=Zk?t_OO+1JhmJ;`ouVD!yKO?_a>+{ifbqnL8(@MSV?sGh^NFAiA;3 z?(5topI^KCQPxM!PGf1o)zva;<`Vfb8i`-N4HfdQv!E~d&$PSKRqa~a8^4Ve!S4~I zbDJYCf&6b>=>tFg_m!8v-zdI2E!;JZ7A)xJuFmh%0B&b1TWaq5gf_ndYgSx`4&rw{ zjxu^sUOTs1K_vQUz)@j3(kE>s3XoVJ>SJy#O5?;@0}izCmmeZ#$ou?RbBg?5FYUtjW0$t%cxmj7{4{yX$P zm#cRFw?Dro|I=K~b)oYWVUOJS;K@ zG<)zQBUodWpk}UH-O%0~e?0Hbfz$eZXRD6DwKWo+-4~0y(V*|^)88j!y)MBh*X5d! zfN%86&70q2c^{YG6a727-?ql%7TnMX+*RPc5D{O-@=VfREUQ5Dh ze}0f&dA032v6gd*`x&$2)qV(oCwa6xscd7y?X0$%Xjj9ZM0!iyu!IT8j;xL$12cgN zf|rSgJJ*<_WEyw?`$cjIijnS8oq4S+0wKWOE!$(PKh<7(@#rJHnleM&X}!OL)qh`0 z>k9ktIBi(aPNQw)+dJRO2!6-P#vfOCo9r32^20PAikWg3;hBz>1OFMd&L@L*e1hDY z&Y1OhczFT93&&WUw~4k3SC|(;XsBP5?zn3pNokzUm*(E)ff53?bp)ef=@Cwi;ABs~ zp>e=yR1wmRmjU2CgWvDt9dX`BSJm3kpvAw~?|0^({!j2t48wcYaFI^jhW|ExU$jqV z8?Y=Ce z9|1_Gx@>!0CqA`M9AHwm_lA-x^i4eW0SyLOvD0$Zm@KkXY6->Sdb%jyCo^%vvu&dK~XDW+1s`652Ld_{Hpy69H~DX(&YL;2@e7 zBRr106$}DDWHnV1Uhqi9s4&l}Jao`)X z_au$)@ZakhYF+MQ>?#AkF5ko3;pAlAO5{CDC65V zyh9d*U0CQ<^9ZZSAPzInY+96K!3^6zo{drpD>V7FY7&s~CSnSWBQM`3kqRYPf=z>s zNlmzo1rQ@AZj=)_k+(ltG@%m9B)O<@ku&hI8GFNV;dJN#dC!H_ckt%qMZWtuWA=+ht4h2j063KJ=|G;Z-uZ-Fe+!U_${N`E9A4@h zCuU>7Sd$POYRp~(t=_ZN1x93&uQ$-1$bt7;N(Bix>?oDgZP!I0T+fm$$RHJ`Vi;r* z?MHAn(o4mIf~rvf=YdRQ(Lwhhko5Hv-1<99jlVE`-}_k8F+t!i*; z%3*+CFf#<07&<+-kH-}~+B3C18Fx98f<@%|~kImzImb{G-Z5VrMRlk}y^<~0+EKkDfc|EeL$=mY-ssC2v?nBNSxJtSbez zleayEYws=hsJzkoc6g_B!wd<)L*YKsy{ZQUsiMFPL@jkxY$aaa*_H)xbPKm*mvIFO z%v52;TQGgB7P|HcK=o}TdyKpf@AbI=YOs?L`W6iKCM!a?f;DTvHWVx0j=-0E^z zE0RvGY1Wl7?m!LlTUZAK|=|e{on=2nK2)gs!tVPmJ+v z8Yl$2wYdA4v~U2Zyc&Ud2UbWI`fdPsY4Y!KFFj>X_Bx+WBSpMlug@(nYX!AHloHBj zO=*+%^wAT?LNZ!j_ww*t>+LDNm7|1)!x)<}Dd=c|aNAhiEq9M#Oc&H3b=PN*m!J1% zZSH%^{qKy|k2Ye@vt^t%pQHJH6wB!!VYL3?PnCf`2{Q5e%1?qG6iS1WVBwBRnrXkB zCGO+4*J^CPOs1DcU@Cm1DBM`c6R=;xI#xtKK`)_2`dnW73#+}q>$3z4q!zpfa91+` z^OrL1XPO$CRK2H=Z;D3qN)~@C%ma)*7^dvRMZmL0$7@~tk-lDs{9T4*mbwB+*avYa zof8B#*7GApqiRP#K04UF6V{OarVj^D(3*+yLHC*4M8qa_S@9+^;sG9KB-|}2Qxx5c z3XLd5i!Shi4_;jw@M>hYhR#DY2L`-c*n@+e{Vn+K@eqstC%;$k?7H{&!bJIRPMjX^ zkFEBicYG7}`1YhqT}vEPFS%#83H>lm=0iOc!p&ER5&OopERb z#z)B=qoSul+*Ot+0d5S5&CN}B#dF_|`u9iLc8_pee99uJ&G&lAxq6G%usTnVt4|AR zn9d>2s1L*;F05@GlL-3dQ`&laA0t$FngaHDW(S44%}A) zl^!8!gi%JpOEt--oiDs!Nr2B!P_N?!g!|RZ@$!zj9_8yfV7?etGt7B7+ga#z^o#!W z-ElTfyoo7iSWp$#GXe?YT@TJK7VW^k%PkoX=c*8AX7ZHD_#d3-Kpke9XGd@hr=OWo zg-z|32au|+)`j2COw3QoSB1|fY03H%@^OEU^;=|8OvLARRu+YQPqXuY{mLiUn?JHZ zm>oTlnP9v~@pC_Cun9!yuVr~TnJ$2_=}SQuFZex88Ta%`L|^Z*Bg&iuL!^UqU2 zcO$E^O>kb^!HUV1|C*!9*gIw*v%nu9Ob=A)f_8j$T-`vwEao@H6ZO{YR!ef@-4hu0 zRxJKHuNSUfz{lk76MxSt1OI0z19!X20mb`qF3*0{Rr_|5qh#a`LNoV988&GNIUz0j zb+BX?W6s=)jaX1a;k0JH;xxqch%#|M%81DyT^q(ag5T_O*Yd&lWec@Mg3&PJA6^qo zwNy4jX+9@A3SzPXWXR^etx>a?*<8%V`<=7Rox3IZZ%0qCDsi~Tw^@R(eQhn4U(%c^ zkI9IU%1xM0olP82PxplYS_{4EA&TJO)F<(Z$OK2Xq}>G}W@yV?vZcgwiA-9LB;X!I zat$KZa&edXtpu!#kC{vdbi-7xtZ9^@*g%W{*gr~jdkxjM9+wddO+W%$BmP>*kZsA5nQ z!3ybvqEv(LYlqW*!MrD0|MHtlo#9nB_fvs0rPlHFc%drWW?#F#7>hr{NRy8a;kz+% zG_4k`20wYy;HHmvv|PyM$pSv4e*_+}B2$)c5cwYCe)p)Jpfpxp!rvb6Pqhg>0Waj! zh3|CInwRI|IN~}K#z#=tD1c@vaE@#gK=%*x zuK2|HahAGJ&8Yo)CZ1=^VZBj=Sv7ZV-i>5`%Wwe>WJ1%R*x?vXhT!<TmZUu8XySC@QYx9^{ON;{43Rkt=6Ik(tTX6xpzaSP6y z6i4-=Hgd{XU09f)s5WH&Kh~1XL(q+v=mtTa)6qVj@d`|8{$3Idq9_iaY|MPoYf;ms zs#{p{TUJHbtGMvH!{2Z0f4zZ{O(iTMwh)O)2KFO~zkL(f;s|+v8fQJeFZmx^^FFtz zbO$dpS?m0M>Ir*l+Pj`+3D)lPU98U6VwCwB^L9K7KNa2ubf(qUXrkBK%urA=R~=K$ zE3%xV{Nj{eQsCu{%PVOu_6gzoJufK-{+#rI{~^*%@v--D<2lG`c~ey=h(Cd zV?Xosl%M%&KHef9893+>@VF4d@mjL21DrALX@Rx;VyLXqf2`%zpvJ!;a@OHDbkTL;;i?SeTqe2Q~0lD7x~)TX9z)$Px? z+E#AtQTq$^ObE3AR$w33Ihyh6***WPLpKAKAnhE>Nb8tLrvKn1AX<2v{S@#T>t;bQ zxwGt2U3TNFH_FKO{Y@Gdq3i3h$$Hq4>y1Eyd0ZwL@{ZUG&rlL=+Iuk#qDKzN7ehYM6n~hde-rWXAS>UuYJd2Vby8-IL|FB$5_@y z?cR;ro0N~`Q^mb^(_jhKpDAxf(NXYPOso{WTO&&SN%LZ)SVRY<1V=yf33J3kp0d+c+JtIR=|f&VHz?0hxkI{q0eQ@Yo9oR4A_g) z0uoIr4900Y9Nw@DFG5i#6Tzs~`Kh9@0W2Jazq9j(KC~d~?r?gj6pim5Wq}&F9YYq$ z2B?-HdDnMlc^^W5;Upfz2F+D;=el_~JU+GjCpOgXq+I*RJ11%qez#Ixi+{-dN2x z5<=2B_LF6oNUtKXsZFJ6Oxj&z5Vz#;aM<=Eb*OM4jbyY$CgCWMr-jS5CG`IzWUVtpBaoMJBQ z(akRIacmt;a_eATK3J|9pe6R!DVz6vkoUg-Sd?hAf_{7}T1xNfl z%1c&5F8Qc<{9`UWpGbdgoyw_-LWG=4{_Bq60OWWB);B7>E`U%7f!Rg4s#`+$!$U|QA97s3u~3r@ z9TuhJooY-{X%ERhaO3pn+d=2mdM{$g876+x3J)UsW{Ryv5Q0sRxd%{;aY~Y4wSH( zG3a9iWyZ(#N$xZP=NrQSC6Z$Fv0QjyQAZr+On2p1R$SZT&}>oNT|FntYc+@sSYBy@ zkxrGlMFTmm&pQ?6d%fo1B;%Hi0>Hag7Z%SJoak#gkV06()Z}vTU>{K~-k)KvjJm6o zo8u}Lfo|S%B^|B1Z83t)kTlx(oIIrbEbsjMr@0f{`UKveGuWS%INv>Uzn4s5v|4V& z7ko_Ln+)lk7kff`dQ2OQ`|!&TK35X%c=hsG`||i{9@Apk0hAqxLNIcf0gF=7J{Y#!K{KvNQ{q&v9&eDQik3PXZD2r-C? zu_mmT*yH6w>9k2;d-l*C;Xe?;=xp$VG->LguY-&Lut_70<^7YHm_!;9!pjr*7vDQu zwqwB{IeX8t87dMSrHTN32ddDJ)uLk{Ac`?)!L&*TT=o_weik_Us30r$nl%Bym?8dv zia#DadnWo14?Be*s5ujT^*`WJv=j9clGP=PW=$Z<{nN-&<1k7PLdS6HZnMT^hMrXY zt+)lYmKti%&ciRi&y5nj*D(8=>?iZLf88_qMfA;mz-XLXmZB;SefTwRj0h)4D1|KT z$$a#Q>`~>oE8eLiUNBX{9vtNYF&K_yaYCvSHuET~*JU!r2*;<`jDn#|h3c`JE`gTq0pD725Y7ZE} z08X&UI_q(*Y076C@Oc9?zbHNi#ySC0B?b&6jFkcDs$@SZLfFkZ)Ll$P`t*875AFq< zdcj9xVklb2w=~P{nx!eU9qGuaub{iSvHj|{@)-fSUZRbh6d~-ShLDoRF)DjKZ9iiE z#W-DSw90>>8IJjcJ4kDA~|LZ zzbz7uvfs%5*vhIZBF9r!LBSKdW83M^rf$>wV+6k3OY)n7UYbiU z9C7VS*n9vy?4Of;lQyBR8s$1lwm! zYWCqi|2#N#FxT=4oNrEmBhEEpEUh2fsv1~lOaIXG0`mUZ=T``>o?5}AU z+^8*3Byw>AQVuk1BEM=)+g!u-u~fiOcgypt-1?kVy}G5AeZe!4^MH>~h-T-|5*30= zL~--Ss~UDlRDH*DZf>o1lod|-{B{7kw#+45N=hkN#_OdU*iCBQfu#QrNrA;k_{2j# znDf;6lVxC#XC-Ks6Gkd@t1!GM5klS`qK{L_oK!4l%oq3IVbZ(-0qB@hW#8zbSI zEFR!*>|}(O%rCdf=s~igIK7a#Lhc&u@cNdgFd+-^t$mHaAIr zHP1^wtSts*-qdgA8vBpmB6};`D>_uxk_`Sg0Oh}bas;I45$7vCOa(_NWe*<_64lOVuqbphV0RCa} z7o!}Ve(Np!zSJJ&0B6npp4n1s8+?3w&Vh75U)+Qh6EuyJ;)r~XpToL4BGP0?1us;~5Av7UOCp7>m-yh(ieiBp4X za6k-nfc!dLCY%hb8agV)h@IL zFuS1V-*Kb|3g~2iHMsXYLg@-$&J~95y8V>d%|?CU+S}&3`0tldB4<_HIe`MRKr~4U zw_-ysTp-#*1j8E$lx*CGYNua1hE|AIGgB$+WhSMJNssSi39|M7!C1BIxP=o_SyChw z^uqb~mnPDPxQI)5dU~N4-4a9%)Zo!Vh7SY-Lnb`@!)C=u0o7pu`@Az4YJTfQg^Le3tKux@G&GAM7PY%;uLu|u|I(m z{uzDAqMH;B!iYb`HNXRZ5=?vo*jB`MR1}Rct{&1iyjx=!Y8)jj+G4E+E77Ko?)EwD z7lHp`R5kS`sU4vN^0UJIS0%wb=C@ACnB(8G;to3ttV<^AidV&jMz$*8mMwAdMJC;Hc&bMH5EIL|4v6~VZM0E zA2sM)_rR(6QxsNq+-6!b;$P~D4_X?i@SbglmpAb3aEU>+oSq_LYMb8Ed?^T+l9f3s z9=sls0yMlctc+`XqF9>~7G)+BueiXWV8+BmXVW0@y1~Q6$>NuwS_L@d0|XSfvo512 zXTwHcj$CfkR#qx!@(RhUbmpzBMC4{+AKQN0^A_bocW27ynw}_TScx?&&hNoH;alJ7 z0$am>%6r|39KK(5=We-#9Kw(GKoeNu^lxhK$ArV29M?&EIa+Y3;RhkRqSZx3poD>A zlA{UTo%bnVde*j3fQ~D4%vq1G^(X?zfzFCykR%iY^>eC-O_qVm zg=QJYg3NF(s}1pI_4J3IvhHxSU#U>aYI8k@ZnXM!br0mWn*`Ih3ZlTl-F=E^sJ(HS zJNnv8D3D9pBy+ZUwbQ@9SVYsC6UFu5+Ttk)sM;aN?@o zx%mJQFh${0cxnpe2u<6hIoCkGUcUA5VK%-GUp zCH;P^oboqS?SkgN#6$$5rfer9O zo9at6Vj9z+uYiR$N(6g4_aDq&7$|0~s@0guMN=EZukwUyXQ4r|KqLHOwUt z&!Af7K>m}yOm zv)K}KSLot%D7#plf@2L!>Y;tn-0l)Zly%#)(``y+xmJf|ttfNpYCmdSn`GO&=0u$k z!HapsA9?YLfym7sq<{9Qun1#Zk;WF%O=(FtV_HTF94_)j09;%;og7fkr#=r)8V;PG z7ehnDk={kR1(I$+Y^2v~(*d71x`WIE;!tTRt@`V3)^iq2Aaace)SN=H=;NrLawVY( zB*-5`+6KOyQ&+U?J^SQ`E>gLhTxmXY6nM{>O$A<;UCnRS8%N>)%ozqY4Z`f4BbgW>M7| z<_eCN_}{6wdLF)7h|?_k+1>_cmt*kR&!kR$H%H^cu-*5q*|%W0-ZzmdVQ{CfK5^fo zT|0LBFlyQshAj9E{5~}SaT}fi*FyGiMH>a8dv=9B7x?`yk7+0}nz3m1kXjfc`EzMf z(L&{Y&WygIIKy-k@G_$&$8?JklzAB3L-*4LPgw=@a#hgAusHW z^DJxo7CPMAz4{AW?DSm}-s*x_WIB=QTsZZ}a?)F|#iKS;{8#UIHyjxrMrCPC!Fe%% z3UpaJcD)YHD%^PwU2g$sy5yG%*lYQXuSa4#Jm+*&BVzkUyU)&)|0TCs^;4-5+iHdx zT{k(n1nIE|=-0-a;E*v{Ny%T(5WMB;dTMDt6bcUrS^3)z1!E?ixccw#@lA}aT+AFY z1uNP$A-}zaBNK)VAWX}PGT;ar5gd1DWucc3M~CynELzgz9l@Hf3(2oOjtl9wffXB= zPCelOTVrPjbTy}TGpQt){=gxm&oXpmA2rH_N!{BWF?lfuxeoTctpah>$qn;f%J!eYE;Oi;z$6Y-GCA-C6a7hb z-f?r`AGT5e4k^@FG81EQj+f^w<~u*x9KY}CbEq;v}F^dQBR8f~6 zX`VI`IbG=TZRlDBc2|xF3(F%?R)!9oew*B{8n}O)%(=p{!dN4ramXd|?Z3VBQ035w z;boQodZ#Pz*Du&6_Cu>98ZH3iiueOjkxe$fl_%OIBz9?6*Gk)H?%CiPlZoiWCgEt- zlnW~$EAY(Fsu ztH3R#RV(dDO+S^M;Fq+?Qk^a&1vvh2Q?^ixLd47|b7^&{h4lt0FllLUuO18{C^-NR za2N@QkhY{!MZ>!?M9)-Lzl+m+FSwV+aKF)ZTkJzVX15`15PT|+p%0PfG&k#e>=16h z^16(z7EJUzt+s{4jN((JlDL4E737n~kXu>Fr)eJm)%Tx1B3~@_$%pXsi3MOcO4iMH z3t`O@b*fx6*O)e#8OW~lN*T0FB4A;n%x7%2FEl;%;<%4k7#d~k3%B5-kK|_MOZDH6 zEMGM2Z(D}_Gl*73Oz||?3t89ijG!qxhZdIrrbWl^v7{%{8>d;1Iz-$VD?|wyS)j^z zqdMginSWQfqC8q7(v)iap=PuGugYub9p#-X(;FZ^GyGp+`e`GCg|O}`g6nJ zH40k2>izM&>#DJ-gOCDwd3??CZ<;S}Lb^$hc3x_}sK?%!axKv^4#;_ZXba~VFHL8@ zoTU2IVCb0Jn1;!R@SJqFwLSAV3wG+Sa4(FfZ~UX z@UX7wKmsvm4yx_e&Oc8(q})&@gsK46$OqMNez@e+@SPELDCNX{uaqKK2PKG&bX>LI z0cxRSKtGq#DvapHaj4-mP;dHg`v-H@Z#0O(XFtcCVB+RW*wi3oRxQbAdCs8Zl#@Xn+be*q8Y;1#f{fX!$dJ` zz~G_zC?QUju`FY_EC;2@NzeMx0-qS7D1=&>@o37Gpsruba)pP>6W60DL4c^%$D|M* z+aRX4AHdtg==59=+f+b9hH%3&CxK!J0qqf`7Ccb<(HYSzjv}0kso57d zb`NZ^7DHVxSDn~uRY=%`2XA1sm@y;pnBfOzjZ!NTzeqR(n5YEDrS>hzxGfmiHTXmR zZRncCNlXm+UnHMdj@{H8CKOWiV?W4Xm+zr}k9AwYnB`{?Ffm!7qUDW z>_lR3xSJh@4iT{oW95ACKjJ_nQyPU=s>kdw9w`=w_Cp-Bto6~$yV6P%c7%I>ek(mN z*!D)qnT7Aaq|Hf01&n_lRmmmzgbN(W)jURG{LAk)v;CB|1yZPi#! zgD`qroE_+=8E6f*4=0A0%rgpX4sm5jH7aj?2mLDoKIgw5%C%%a<|)D1`x1m5c!mPc z_@_>9kvRst$O!@TSd$(;LQQ~Z%2I4M?SS|~7lsAmG6d8B#damVz!&FAvXwo0nFezn zD38m{*f8&l&SzhpEKl$&EA14$*CJ28`Ye~o7d@K9XbseBp}`F3Pa#9JAU06|T9`6l zFm-irKN6SdA>meB3|wI`aaM07kw3z)B{`1_@^t?pE3;vfV)p60$!S&(z1+LPD3`0Z z$9b4O;1tr!C48lTnek@>GNQHy8$ArzhM5#0V9uix`9)MxPwRTV8qZuTH72l6HFZ)z zTF4CM5ON6`szh_syUv2tQYZXRw{%^1NF2Qf|6{NgSYi?X*Kiu%bmWqjgsy5+g4jiW z;Cw9t1P_w#_!ZKkl&B5nRN2Q+>E0)uIWIKo>qW+sZbdOXAUv`opF&21j@VY!mh--l zLI$QOYM{=OCVc)a$PKMwh`Rsv1%}OU5hr8HMqSUWnvLZgIf{>ZLHq!<0H|x)1NjI3 zTNwE#JhJ7&`LalAl#Pcl8cy7x`c@l}mLg|mkvnp9@Y59WY>s^I`)klyGEfi>6OxR7 z&Z*4N>vH6$An+D34KixjgAw7?l4P3dtzpzghg5NWLSXExmeJfToI1$n3pr>r>ycJdt6KkcW5ZVz$w?%?eygofbg+!dSa`S%wViA+Hc|f2i z3O_wQ_>Uo3+?*h2sfF9@!6X8cK2HP|d4}=|q2avXcjNNCnkPr)5P!9xKO#Q*4CveXPOK=jN0~8~# zm!JlCC?yXfR_OoK^DJF}%6~Egfk!i%W`2(Yq z0U?DdI6zhIAcevIvJ#`FbplK(nlm}JrO%N5hY5E)pLqxNe>*HNrvKHpV(J(c7TBGY zO8NMBcHCaWq#K%JKTNsW$rDytw=Jh3`Z9#ksn-w)@AVI?;?*79pe?( zDVkQbGJ_gW-<&6wWP&a;3v*@qk@m}oBOc(IB*jK$AJH0k4vGycmkIGvWG(H9cjNSg zwmxR!2MYQM3Z`Q0*g8hvj;svB0leBIS{Xu7={8a*Vw_~q7D}ulki6&o`59ET zs<5G#h2>9A78ZttX}dSXm;Iq$qzMTBdxr5h<{Ys`*hJqQFI?&1Zh!?W*($p#i~v@s zAgI6@-a=5)G{C)0V)fJY!d&+538YI`>h_vb4bZO%R6N&=tnI_dt*U~Eb($gBq`Vu! zZHqy5?IuhW&23%FKJV)D5c0U`bqOBjh~QO0%GRM`cA+|e>QYE#OIb`+<#A2bUhgVn z1;`gRL^UHx4ZWj;&G;xjgD(*7rVP=obi67d6|BmOju4FKC>TMZ%S+@_@BkhwL)(IC z*0~*Gk$h)ZyeuLx5DaOcnu49BGK!_g7ka3t$QjN*l?-)k3*nW5fEOL>ihzo}V`%5Y zSDp!7fIi_(A`rftOPn>;+Ff1S_;*r-b}cH}-!7K$9b?}8KA7hMobw+l)`D@9`fqLI z!VVRAk#3=`Xrw%T%hT}bBn(EB8= z7NB;&yK{6M?WkS*RUFl##-xNmW~30AktX36?e8H*w;t%;gW#$M#lG{=y|b5#D5_i> zN1a{=ipfOv%0O%`u$W$1ZAL}}RdT?toT1c?Wzn*6VTIa>;T+=q%UUcmYAS4eSGYu! z{zA_DT@hDX{A&A5G;#}a2y4NI6X3tETag{=>f$f!?09m{Pp zOQgadHd3VcZ;7K)9VL9mv(tbT<-i=O}F_xg$KTrYDMEG;xEZsXo`{_l<;LfMu>bD+y+PUte&lL!|9It_N`og#BeLt z%uOMUhJe4!86S$Tr3Mtzgq)JOX}X6ZCk|SMcjD3A#Q_S81XKSy6%4Bk5rKbD>Qgyn z@zYuagy9~`JdZy-M(Tz!6#%OVDAysQ)O!4KZcbsi`<^3|&`?M^>v#D?9a@d|^k4n8 zGxlMnY?@xX()gwVGss-;!%4LC<)#ioq__q^4bQ@wvj1eGyqf5tBp}2_=UMhd^3|t_P+Qw)?j3mCf;q(OQRjma#pucL6 zdb&(2gfepZ;cDdW|9M`BFoM>hph6Xzu+UIs)&$D8l{SPQyK#OoMhr_^!osMhHySM>(xO>0N}V}OG6f1|?3lbP z$V0)3(3D814JkOfDlP)NAw;(K{vJ0c`Q5;j#qZdfJ7ujdsWIC9nXG?Xuk`IMsRUP` zwfcS0xc}w8IfocVCtq`P4AjcF7EK+H04xD7TMyzq1YXtYLpyubu;*-Q9rTi|tsLgI zkU@2%x`C&r7Kt7lqLL}MW%V`pie%=4In!C1IRlo$fgI6eGv$C`=tKAl@$xhDudye2 zXv0OwO@l*AjLJW>ec)aV;?>8F)#H8OyS~*)!;3jnIhqre-PfkYO2X7L%pdGV(F^qI zdryezsz z{nU8FM+O0rgTqO_R0M|dZe;fN@>yzJE;gYcl|>#cNGF~4c5Q9EFqajmK%kpx?>+*O>M+e0 zyOgUnI__c5sH~;?Y9!;JM@fa2=@N1-GFzifXME(+;LejR1Xc7Qn8R=B(u5wq8)_Snc@i5gO0Wt8e#vhQg6qK`+r8N>v?^O(i`u* z*nNg!XQo$SWA0lPU1(%<#7W8Asa_l7r1%uz#0_IThTm}cw^WGGY!o&7Yx=|C#loHF zdca&1`*lbpI)`*bH$AR`dSCc{V+4ccT-zm1vsZe44;k}>LpYLLq&ZB3((ch{Ol`0k zQ{t%5c5;Ft`V>NUbYLO2Sww;$whb`kjzNv}o7)R7M;w8i)rA!wm)z`$xk5!vCH6p8 zrV;D_Cpv=90!}9$3FStnlq_5!BbXN&G)SnP0YjBPMd{Sb|9V0g9&3Wi0ZhTnW}U7; z4Vqw1d3A!NPUZ9_m~DASlL(0~y{u?yUa==1KokYKD-EIQ(bQqWx)>@AM-66dW~(&K zim_9#=Yu?`O))CVvmYQ9$c=p-#uC;bv!8O4RB~) ze&v9XPhHA>J`bsC#s^xjx~_$>nS53(`P+z_??eV5;_C0If^1;333K?kjMOWI=%hui zJvt;&WasQUe{D}x(`V#cu8i3t5P)Y(^EsX`&xuOucr1y1@9P-4gnT7IUOed`$-Agn-2|_0giaOPFyu|P9oN(0r zO|i%R5^kL#KJe*O=BQBFf-16iXG2qg%G-StK{7LhM0IK+5U8H$y_)sg?}a;0YN~+& z9sHPL=z5;sUgY5M#hKi^=nz@BwGl|L)rk=TT9$j)_cE0EucyXXpZL_tx`yThAI>Ps zf-2uO`v?3t^n&DQ*Ctw_Au(8sGX#T{hc&m8_i_uhU4<|yUVQUAg}D5yPlZBiNKKV( zX;TClsP^z?ZS?l`!Tz0kUApnvs!bF-SWJdGe3k&$@(BV8%$V0zZp9 z;if1~in;&N0X9U0d{33CRfF^qVp6 z63mr~5P6g%hOB)e|DoQDUVPE9w%_T_t?#9_KEc_4X2JpWx}4m_vGeX{@%ZGCrgGX0cWF#&JSgN!>wHFKtq3Y+{yU?)$|hna z4pX?plfZG2pqwfHf+@n8l9*D0fl~5aUYsHD9cpWB>8~`7yJ|_iMd4Q5>F$T~bH8!g ziLT$qzp?G$2e<)ADLJtm#ntuPy`gvg3%@(1Y8lrZV{hst5f0qyQ|g=vO^hB|0I-4_!s7YTK*G}CUD!a7*kuN2w=nPN>;pqA8F5E&@N zKEHx;Uc~=!K=DiYzgU~?uhHTEUb3g8K#bE{nBL&unSf=L1QYuriy!Y2>Y3Ry5Fk`$ zuc@|T@U?L0-&VGp%UecV9CVZiy1_`Z>^BTvIbc4u@L_Ku??F5Kk^4LbWET7+c`y)6 zb~r{nHWQp$+5SHOgg|@0@p`I7m}`!vh$K8AIDtO>Ek;UKbCb(5>w?nuj7$R}-LvuD z@i5-rdIFjW=>wA;nr3ypVY$aS<%}L44(X;&KhAba+Ssq+U|LZga#4(;pao#;TSx_> z&3=2vp0EZ8qIF)#CkVwTNRFq);*`Ip{7LgFsevh(UGzK1I8Ron1}K_pZ^xeV#lN-(c5dV!o1{R#@3L9GZe$!wU_}v-O&Lo38k-shh1?3L&ivq%HXg zzLUBVDn<7VH{511bUTd^Z56|KonHpB2t$M460w<5sw%`LSGgV=+>pKAn1hdTR*U|} z*y(V;S|(0%IO3%ZQ1@0$g)n7yB)NjRp3CXVHmYgf04n#`nvUIxaFULQhO%GjiD~~=AoBVnjB2F3w%mbq#*kgmBuV@Q$1d~TyrudaAAH?ba+9cRQ(t>q*L0ow9ey%{aU)U7G_*NbF1qAxkZiag~fwRZGE{TNy+q&ApQ0 zcof71O{`^O8oN&ryNpKl%xw)EeSY^823W-R?`_&7NbPs(Ku#%mIycyNk2`XN`XQrp z`(xM6a(RRb2qHs)GJ%gnsmqM0F?zrfU6cYrJQd#>URgWUAOjAnz^lBkh>ERXi0j1YmDbS{q zsDl!@oZ<6Kv{p1;v7DKM0N2PfC?4&Wdt1LjpX7DbJL5no4({1iz4sv z8+kGxo0%j%UbX+Uplbm3yh0qfW-_InY6Jlt+H(^bo(`EE_J|w?w&di5Vd3_in*j)6 zFNcXP1y|Jah_JA!Gs}z{qHORqY(Y`CgNj(%LBc!;5OZ;ElS92f-x|`>TaThnUeLyL z5hmKB|BEzx`Tc4)QJgc^S=L2KuC255SZA9trxqANUB z8MQ+=5k^$C*X)7R$TUo}HUo1c(Nq&t*(#0QGhjnW%l3Y*B(o&fMCu*Mz*j*#Q(?k< z7%6aMk)0tZrj92corJV}PyiVWs&y$HwNjMMqV$bHjH6JGsGLiJ*NV^p0pU4jv+8wk z`od5B1^OhftI~sAxqkenue)$ZyyIKKg`0k>%>4JW$9NJbZuq`(mzjatJQa$2U={FCQ!*4{ha8mSfOn-zqR_s=nkaTL}L-xbQ!%{D|&G$@5uNc%uI$?Xg`XIUsS`<5|izT9oFw>H2p?NZC$f5li4rHn3(uK9g zjy4lX5;eRu)%2I)h}IUP4l^39>3d9fEaD8CY81y!pdRaZ-F+vuOTZ2IWs7;dK%vYU5(kT@NxD zS)g8_DG9{Or#^-(^otg?^JZ*dPLLH#yD~PZC81l8sU@`s!59-Me|O`2yeXcf$F+~U zrK}~V^j0~eopL5-GpTD~X$e^`BI60rycy6g_?EHTUz&k5`3LAl}a_H z+DO38P+wd&wb@TQlTzi-`BKmd@&Zkik+|M2{85p8<9n8%AR}aC>+M4LrphzXvMw7&k_PjPNme*JR0{}1_lxm7V`?;5EolTY@f5UT1`oC9gAzii1& zT3vE!3z4=LU&tm#zZTRfI$6_q*h~HC-UYWhyDmJndy*dCIpX@h5yh{lR)?J2j$E4V z5;y9ZfB$eNet59O7-h{lnT!%bLWSCnm(gXlBInR*-yBTc?u5PA)Jt3Zj351Dvq|va z6*V%hkLB)ouN{|Lg+xn5j@lQ8tX`zjbaomT>%b?yO0Y@us?^6&<<>f^FgY~(OI0bG z$RJUxctR3kRzHW?HS%+*JS}x7VU3t;3C2o1!0HVwA*+1_Q(HEo0G^1y2Zv!2Q;`I( zsoe;)%4uh}q3NtBQfas^QUbZaNsc27A9gP7{lIU0=41aQ9g=lT8yME#`N)6mQ?dOi zomS_&^DO&3oO+1W0Z+7xR_x?0FB*#2)BKH1Lq*fN@(5L;EGs|wK}EKuZy zHU^vEpYQL+C-}$2M|aj}y=c0A)}kf$f?bzO2s4sx>m>99ara_9p?B7mp7sd>|Uj;LyIB#9m&g9!%D)c%a?KGP=aOUD<5CngzFdU3x zow4!E_*i9QV|aVYh1`NxTYX|MLQ2j{oO>Z0#AYfvRDoRgxR(1svOp$^)TY#E{m%ge z(;NKbW8d}pwo|Vxf34?OEmq>L+UmQK@T(l6p>GC2kgDd^7D<`#fJzPUolmxU^n~SA zbUMc^4jDH`Q@6w3YsLxHYO@({>i1||)^zV+$OakXpYD(7V#~Xmj`VnQVwRc{$u!CK zr2MVJphzV7r86d~$f!d2E~o{WbcvQal+i5j9%-mnd94^>d6;k;70bWh;Cl;^qx)3B4B_+N(>d9?o)jHY9pR%<{tfS+q05GN9}-V@Dm`EJik+K zY@XeH@#{bLac`nSwyx?rSXu&=ebb}9>E`Z-b|4BW~1cfyB6y~$R`btcAnRBT->6oI$E8|dcEd_Xfn87qcMa+42HPPnNZBB zR}{3=Y|u)x$zV+8HYPK7@9sW2=jn8}&0$rGZs@k@o~MYOpF?M5>&D?L%1*Od-DnR4r*Y{Vdunv@i*&nk+nSw-h(b-XZ{ zFsM@SCgf0;y&9R0$li43k9JxSJ!Z9F<|H4Acd3sgXa;GoDJh1Kw^r6n)wVQ?@)GaK zBur?7XjB(f&qT=cWOH5Q1&RTa_8M5@rO0Rt#!vVbL|7)mYll>M$=%6`y84u@4eG=gB3hV*tFk_Dw z_u`cH80{Nz4pyps93XYfBr^b{lFs5`U9llf-Lz6|jC*ExQ=9tnbYqEYU?UY3^(3wP zW=n=yl7Z7kMv8H6Y-)t2q2XwLxv`H_>J4Z+Iw*O=?{R(!5M)?YB7Xpi)bUm;-oG=7 z_wG(ytCLBmbDTTY;8(xg%b*5(wc`07M#nI}ex^9AQC`KEOTpza_{3RS(=B6Adj ziLFeT7B1>=y_pn`^?@=G9fdT5Xd2Q|E<6}|@79daU{KcD4|bxL$CDlJFcx#8y^NFDv?@KxXZnVl#7t2NhOLkV*#wf{JBP0$QN=cK)3n27T34f3^`|LP&3G& zKZfpWA@QPSRgsl*wU{H}4wxN)o@cGf-8H^H`q>Tcy4FhEW8@O7{Gbf{1Yp=3N@5uE z=|}3plr>@a0%_tB=L0C5-OZTnTKS!E&NpQ!ZptRI&bis%xPl49_w5a6{Z!vgXSELL z6DeymEO~6S>5JJpZK8=}T69{U&^od3{otx5RiUa`v zdpEpsyjTCD-+tPy_tI6ct||k&W9#R?=b0;?@-L6NC6BCXm(2@g+(*UZ)CE(J_vM() zYTtpXi?h)2zo)`bR{^A$WU94EiFevWGO1`Ra)sXEKqkWD7PL_4K?w4u!c>>SsI#K9 z=O`_f62LhXtKkP(Zg=VZqX(&5wrM4ANmZ0@joDRt-rG1^K?h8gQ}B&&pwOf!xlxov z?kOnMiu8>L3a1n$;rG?*MckQoJ5)0H#E0L0=mLfH4%g8t%u*Kap09!^ia_&S8c3zD z(wWgjV<`u{&Kww?l6{Q?g3!Q6&u6A=r3i7Fb#`6LxsQyaxE?Im_dsIwD;mcMF zXzWLh9F&ED@8ct*iaxv_;<(l>bOtVBmv4Zf=et~o57l)5e`+3uNU|#rkw;fQ*+l0}@7ijN%1rWeteYDNL{Ed#wdi2`#H&qb5i~ zK9L#t*zOj6RsS_?o@){vS?-{HL)EL&EH3HeR*)WS7AK*e^;U}@ zsEQrfY-F_35XQHnOSqkjv>L6jVJ)%CX?qZj1&(&@Oi@AT7B=`YvZ&*Xqnt&j~q z7(Oy8-F>4fg8BvC48qDT$FHSS1_EaK32F&*Z#lnF?v%gS+@P2J^ixl5&{ejs3J;dG zJKy(&&dpzsO4W#StlQ%C)N^ggR4QpLnaW(}Z8otj8b-9C3>gVzFN5GQ*7KZ35({-! zL8(jWjL{OPdLSuSc@`xO;kq-umGHZR`}`NL z-W+>%0SN=`Ub3X!a18NeLFY!#Uy!MKCL6T6k&4Jjp*PevWl~NBcT2Y)FYHa+a@M6+ zeeiA?aP6zt>GGy^kldDGg_t1ebpog4L%{ul*2Hu^AzNKe)^=l)O&Z>b0l?^5Vq`Lp zO(o=ffWUn0@tbL4CNn=Yd$6#~7Tb0zj0)KT6c#RW!TzHh!d@0V!yuh>jh1+E3AuOp z->Z!brq8jeNDS?Uar|#?v;MK4z3tR}bd|0{Z(!fK@w5N@=Kkq#=5x@a|M)gK!^w1k zEzc%3W5XKLMfO|UG@{)wauW{NN{QX8q(j4%{)3L$XdWjf&YiQut|DHt;VTIbjUHs- zW39%go-oPQT8op9(Jpgjv>(aW_PB19hlX5+4Jh2aklHRu#U~=AS}m^gL7@Er0ck>?(HQC?|9w-Z7dQ5%)Mqn#{YUPJ7q~2uZwv?uF?Fx!T!&H4WhmdrT#JFf*k#YxM8U2SD?CFi?V_4_Xms^2(& zcKc_3^(l|KOjr3j)CTq)5C7LMxT$;cU-z>nJvN?+r`@`M1?-#Pwb-!s=zQFAJ5WL7 zV|*frUzMb%NsU2~OIR8oxz^RB<7P+OaDio)aiIJKd8;fnkym>n3%u6$WH zZmt7Tp8IIjXmc&8FNWtvSsd+;XdHOE=TI|l!~z_}M#D9@Mpm>7YIO?Ax;eEO z)@!hP;^@4^j?0I!?fi%OGxwW&Th*_$KeqD0|8l!KG+e^J?mDyv z_Vwrf%k|6Ma0vLy58X%k z(N*f^xj=p#Y(+2ap*r&mo;nLEKvzOba5iyqYo-IYDwR)l80c3tsL_Sdajw`YBzZLRQc7C18kkWNy-3PA zx}n<%_D{a#trvcc@x$->?Pr|4>O;e{4yA#;^wKZ9gcJ1V ze`fJ$*W%4F<1dPVi?~IDc!73kqaJwVS~D=+(K8fN1B0`T>8#OS`5Vz1nYEK*POwe? zvCi~)_qpyx(%y@7BWxl;*#+$r+}x@~q(FX5MJ+)OgdN@qZ5i@i3uQ|ElsBqXGA`>8 zR<$Sdydn9?D`c3^#|A(L(`J^6MjXy&Km=X4QAkb-@V#jAU(mq83}hLmfPOH}xH1Jf z@b=KYq92QlO_u}^HZ)@>aLQrabPB0A4N*mMGRQjxu;)y&OWpYd`I}W2E!6d*jiG}4 zwS16S^tBVMLsSKFh3TWz+ZeY*IRT?%*|X+H8h&~Gc=304XW`4A^OnoMFsAkIeCzG* zlPY`t@mPn>z`p71%a7DK{mUn`Z*)t
~IH@Ld>Fng}8Y6u_$Ex1l68d}R_5;7vX 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 JO4Km${{W$sw+sLP literal 0 HcmV?d00001 diff --git a/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_fab_todak.imageset/Contents.json b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_fab_todak.imageset/Contents.json new file mode 100644 index 0000000..75f050a --- /dev/null +++ b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_fab_todak.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "img_fab_todak.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "img_fab_todak@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "img_fab_todak@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_fab_todak.imageset/img_fab_todak.png b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_fab_todak.imageset/img_fab_todak.png new file mode 100644 index 0000000000000000000000000000000000000000..774292269e45cad3993b5172259aebdc04a4e94e GIT binary patch literal 5726 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 QzyJUM07*qoM6N<$f^iZk^Z)<= literal 0 HcmV?d00001 diff --git a/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_fab_todak.imageset/img_fab_todak@2x.png b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_fab_todak.imageset/img_fab_todak@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f7daef8d027136de4c8b70929f126ced30b6c0d8 GIT binary patch literal 17909 zcmV)8K*qm`P)2SrzUZ5b?E9+BQWX>esS;$CttBHW zm%Y4fBU{4uz#{|8Gf%)H4}s+$VE6+DCLVZ#Y{b$?&H`!Kk_;jRq6(^wtjazjGa{pp zc<#KvoR53<|BsHor(z{b8hdsY{J@FVMsU z(ey0Q#AL;~-^Y6RgvQ%~e%jk68Vn*0hk^EXBJFPTYd6a0%?+aAAa;7f z)E&YLM9u#gR`m;)|Jl?ceaPE~7T`9h^nCf-^h}_+g}{L7J3!4X@=XN;(?2^qJ8~@M%ta3GewWQX$LqR}2d^}LEr_`lXCYYwqmmt^{+0XHI>P&#z zIriUu=fvd4r>T+l8w^N*UcTKhg|~ja8bb5U-~Zg~x9LOBe&qlI*e1kpkts@`)|2Nd zI(fDdP?<6e*We^)WZP|ynA2WCp|X2Erl9T4r%jz1PY9#l_^^(}(G$q04y7)1 z&SPJeZx89lV$%Y;is4JYD%Smh1AOIAw=dMg_^lGyvrksciYphWBL*)kKrI5ZKLTzN z$W+`_L78@rJRNe=c@WyH+)q<-ZM*P(pkta*b_=xB0U9!TSXE#Dpp~%?6yQJq{?3=1 zIQ$xvC$QpEV8!Raik<6a3hXwgRvGN*K(ql5N$$qhWYHb=^WDVEV^+TWm%NitnuIPT zJ$s}mkLfS^bDdfDowtS}v$?HSA$;RApPTwReL(F40k})GhmTeC#M8CdtJ0GAj7f_f z$tpuk1J3N7zn1XkcT=qMci*pdz5>K_KnZkg^=*_+7In6@l)bc1VRG%UsdZy2h-o6_p!Zi0IQ>78%oqy@EtN1 zR0{{QKDGA+V6fzB7{ALOFG1Fxy#QHTr`sNBgN$s2j11+D zrRHPbyT7MXXIY&u)}A%p9>q@A9!BJ`WGpn|4n|B+e7*TrBc|0*W%K*Svfp!nuY7;w zQWYzJdjTx@W0!ha)^@MlWv_`fX9uExFR8rki)e#dp0*2Rmk}J)T7Y(dNmpu7YotQ4 zKJbFE6#INHyW0)D_%}mm+3yR;?T zOR|=zrS$kAt)6E_+dX~g?%yRz+tBd)9&^nh|JLUh;tazkDw{Vdt*lEu{xDt>jRkyG z5zuAzc^1UZtSTmJRQKm=FAZpIMdfnS#4la`motA%?@@ct0RD?V-TT6DIQTX`!}NKQ zP zT!MS%LQUtMtlJb*d`$Nk9g0|hy8oC1J6k9(B{A-|uIw7cz=AqAn~Io2(g46R$W2t2 zXbG^2cK)p~_RnvX&7I2n?4N7?VZ4qsI{~n}NbdjIku1`nVM76v_8x)!t^qE=JEPk-{W?;-nsUIzZ`1D4mB&_xsSPr7kI%mwnf#6cR#T|?2^6Y* z;Fc#JWk-bl3R7!t`?i)!I=b_nz)t(xq%!sazxF`2ePz%6Mp^N|-km`4xp82fO&hzZ zVg|Gd&Z2`*ve@$$wF3VF75i&;is;ZB?_W8{itY$p6Bokw>#rq{SDVngeED9u!=u>+2=+;H#(%RNU;lz7m-l&L*&{h&atGdo@%fDJ3PLXi@H zhXy8IWbD~MZ5jnMI*T?SM2VwFH9eRmN8b|I9^uQC2J!E$+ihFSI;pQ9=|+`YrD z(}vp{FE?~s6|Z+}LRIt*nu7~dNWis0kgi(i-HW8-)MeN0CifJ~t5s@D)?#N%9C3X5 zJt*5<+N)@FkIfLadG>gWb_WV9oc;~ohf2w!J@Z8HZNrj34e{x>#0?8lb{y zAzY%b*V(hz=2oDc9UE$7pR}}m`|CGl{&@U;@uL;Hb!A6!9KkzCZ?nB^fPeTWTmJ-RrMl6dsLr2i@KQ8IuBlh`EG|HwG*N+($CsK##%qjdcL8lp2Zw zNn>CGrDK-vl%9Q#9Ys^J*Cn`q>20>R1uy~!eCe;i{rIs@>V@T>C_gdDO{dy!Fvvxk z-rPa-99t8j&~-AXZR3b`nF3dL1Kr&Tbc?A4gAAEj3H!|yhwY`U3k|w)P>1=q#x!bq z4YPbsAPq+9_L2NR09$o4kBwv>8<14@OqmWIV=yxut+MIZm2 z9`(lL^j~2D`wG2{79MxUQmK04nVMs{bz26ePn2e2MO`R}M#fw_ozY_g?bEW$a|NZs z)onI=OnDkKS)RH^jLQLsp=}#9Sssf?#Jv=c6{cvTBBe4-+B<{K!Ous^ZN{r?63tAg zXKQ7J`?JQfcg!3g{F;C#k3j~no0lxmNHcTg{bx-&gu)*ebA}2w-DFw3_)Al|>hQ_O zmB&4L%%*ZTEFda`Ks`}TFI6^^q)D<54Tp3iJ}b-CR>O*o1~?jFM7|D< zG6&|3a(3(c=c}5gYce1EiD!yPCWh?O6hkiu=Ily0r@$F-Y;707Z*_niho&C&vuI=d z8feb-06T*EIhuqV?eVGB9kn@AOo|M7e#@Ej6>Y6Wx_hfxKy00Vy-bgT+vDu?=ie9J ze*r-Qm_s4`vrZL=mdbMrumh;k*>4)uP_QW?lUc24BH*oTLCz_-O`$x=cl+G$>ppwN zHuqQt_W8BDtL#-hPztzougP<;6!D@p%dJK`w<=|g$yj{Gd4_$MP5hxzXGjkYlr08# zN0aQgHubf!s^>`elPNEZk~KfY7dU>}giVKq zwkVL=tn^_Qbw%uPI}0|u0j{FfZZp{coY^|c)|Em9;HD6d z20FB3(Ub>pW-$BZp_m$H77bgM2ma;_ok&xY{2TZ@Q}51}%3)b05p91qn`Y($oq3A6 zL61h5ltisu;G~2^dT>9|&wng2#>ZmSk7Utx(|k38HJ`2subjQu0oOJqE!d3tJ}W={ zJxZ*MY*3*zRBR*mdZU3gLaBY}>dsOa9^$79tGr5(#zzJe@_Pkb72T{|Si z;+h76ZSaobhSwjk{9)}8H|wC*<9ZvS$pVx{4}zlO`;4>Ff|=cumrW?X<_?3qZWBiB zSG5o8OCJi)jad78vgVw z@F2?^h#K)$o>=nEEU-6T2{q`Xfcy(-|D?M$*+V5A8d_f+cpLaMj={inZWt7hrg4fk z>Nc*0?^6?ywZtZm-mS3vp`FegIw?sZDWF8>pX6`MvHs$R>OI3)%p3{Qxung6V0nch z-&hR$xHshR=_hJ>>$M^42l1TC=EJ+?BIRGiAe|hcdCt ziUn?7#=jO-Q%#Vljs(cF-A(N7_;W5FA^;r3bb%Mjbh6DjJms8FcnyRU%t_jFkCuq*CDmweD{4YfCHxiW* zn(PY1;oF<7tjXi7JH_l7i)3MAx7%sP$UBWp9@w+n4B$9`#(Lo+%)+rhD>;)qG3R+3QdBqCOnI;oSo@B0v(c!zA?tHx z5+NG@YiuHx_f?0)0fhS)OAmYvOh8;y*ml8|uipyv`WrlHd4|9=f?4$xQ{3F4NcY&| zwZ5k7oU6)HfV2J*My0pdsiTPk-|x&56}|DxgJp5(Zg9;R)O+c7mE_U{>L_>)efk@+ z*JtT1qI}(s$^$aYSY^G7kcW{>4s2G6uYTb@N{&-IT1K99V z&9Vml#95ovCMX}Q1$uRnWhJxYg~K{z(=+=1<{NCwKWIcuL18+<`^?XGk#IyQ`+P?< z___b)F9za48>Lb2qhKt)Mh9D~4+6EkjH*3TlDiI4)(`?6J{f6ar80-Rb~Kp|F(hr- zU4-x>V=`a@;C&mRzFi%}Pkc1eu6wV<{DWj4xq=G zdIl^rCz*ZEF_mpG7r3_?X=Oc#$r2l*+^BLiWo?+~=`L5p6ozGoe!~9@Dm;4%;O3eE z_zDsCNPf&4e{`MKZi39F-!1%G*%+Kr_Q@!Ly$gjViw=85St8r)l{i1n zo{A!ai}{^w{g+?Wtd8?fW-^3{cl&lj_wMT=_p_(<{Euoes{%PuCTeu+udnp6d9~?J zT`a2uwJ5l5hR;KVK#+NIs!;U126#DJ?Mf-o@5&3mFEADNd8XdIVYaEO4$;;9_EJrV zIw&Fy4=5rp&wU)>w?^bRx$hWe6AyGBCKTJy_nE41ud37GJS#3o7}y<_&Fielz*Kv% z+R#y^=0nW7C#IF{ikxRnZi)x~@FC_54D!kY^@_!z-lnJKh&5}S6nnT*1rQwtcY>+6 z`aregGJ8`vYYncX`b0FrkI4f03s>vKrvgvJcp&>F>I{29OQqNZApo~jHNGG0%q7dD?1Nj++di$o$4s(A^(a}66 zfbi+x0hO_%#O4sFJh*aGnL-wIpy3e2fW3H=*)kvd%o%>?*-wR=TjsIj6W)AN$8LU3 z#T6VqGt+keC0Y7-v>0QFGPOp{vr|v9M!mKoKI%Ul2{70*cjS3|K71^r6k8y-KZL?x z+XYr#m?ZYe*{4;RSiY$NaW_R8GhZZp;TbJFAkTh8&#iz4HKBS=0qVwtPSi7=81q$W zhiA=CuJOcT%KQjV)Yw-M0eKFs8+A2~oq`WI2xUobm0f3ysrv8&3DB!+ZZ1UKhXJiT zKe|$Fw@1*ZbqwGx6{XDVT1{)jVtx40JHDsFNNWt&Qh&jw%OdmVfFpK1f%j;}f zunSXyj-cXNK?XTvpiiAfy({)R_k*O)K+Z$Ml?)AI)o}9H$@`@Wh)fud%Fgbj| zCZ&h@)PWUpZ%jDi9M3+(=UWI8{Kw>-V+wxh6_v}XAeVz2Tf^>?w#&nfc^c(MW78yM zrLY91WYYzUu0YzNln_x7&?soQP_`2Fwx~L0v1~J|{C%Ef

{=ZY)-`cE6D*kSIS= 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ℜ+1L5U(n8` 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 LNkvXXu0mjfYU)|+ literal 0 HcmV?d00001 diff --git a/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_warning.imageset/img_warning@2x.png b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_warning.imageset/img_warning@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..77cbc30c587c0f7305c2d00d7a093fcc14449f92 GIT binary patch literal 15731 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 literal 0 HcmV?d00001 diff --git a/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_warning.imageset/img_warning@3x.png b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/img_warning.imageset/img_warning@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..78ddd2708a4fdc18e3cad25f138c196eeca23998 GIT binary patch literal 31315 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 literal 0 HcmV?d00001 diff --git a/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailFeature.swift b/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailFeature.swift new file mode 100644 index 0000000..edc701b --- /dev/null +++ b/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailFeature.swift @@ -0,0 +1,101 @@ +import ComposableArchitecture +import Foundation + +@Reducer +public struct FortuneCategoryDetailFeature { + @ObservableState + public struct State: Equatable, Sendable, Identifiable { + public enum ViewState: Equatable, Sendable { + case loading + case loaded(LuckActionDetail) + case failed(String) + } + + // Identifiable 프로토콜이 요구하는 이름이 린트 정책보다 짧으므로 예외 처리한다. + // swiftlint:disable:next identifier_name + public var id: UUID { luckActionID } + public let luckActionID: UUID + public let category: FortuneCategory + public let categoryScores: [FortuneCategoryScore] + public var viewState: ViewState = .loading + + public init( + luckActionID: UUID, + category: FortuneCategory, + categoryScores: [FortuneCategoryScore] = [] + ) { + self.luckActionID = luckActionID + self.category = category + self.categoryScores = categoryScores + } + } + + public enum Action: Equatable, Sendable { + case task + case response(Result) + case retryTapped + case luckyActionTapped + case todakTapped + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case luckyActionRequested + case todakRequested + } + } + + @Dependency(\.fortuneClient) private var fortuneClient + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .task, .retryTapped: + state.viewState = .loading + let luckActionID = state.luckActionID + return .run { send in + do { + await send(.response(.success(try await fortuneClient.fetchLuckAction(luckActionID)))) + } catch is CancellationError { + return + } catch let error as FortuneClientError { + await send(.response(.failure(error))) + } catch { + await send(.response(.failure(.transport))) + } + } + + case let .response(.success(detail)): + state.viewState = .loaded(detail) + return .none + + case let .response(.failure(error)): + state.viewState = .failed(error.userMessage) + return .none + + case .luckyActionTapped: + return .send(.delegate(.luckyActionRequested)) + + case .todakTapped: + return .send(.delegate(.todakRequested)) + + case .delegate: + return .none + } + } + } +} + +extension FortuneClientError { + var userMessage: String { + switch self { + case .notConfigured: + "서비스를 사용할 수 없어요." + case .server, .httpStatus, .invalidResponse, .unsupportedCategory: + "정보를 불러오지 못했어요." + case .transport: + "네트워크 연결 상태를 확인해주세요." + } + } +} diff --git a/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailView.swift b/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailView.swift new file mode 100644 index 0000000..6911cd5 --- /dev/null +++ b/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailView.swift @@ -0,0 +1,156 @@ +import ComposableArchitecture +import DesignSystem +import SwiftUI + +struct FortuneCategoryDetailView: View { + let store: StoreOf + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + header + + ScrollView { + switch store.viewState { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, minHeight: 240) + + case let .failed(message): + VStack(spacing: 16) { + Text(message) + .dsBody3Regular + .foregroundStyle(Color.ds.gray700) + + DSButton("다시 시도", size: .medium) { + store.send(.retryTapped) + } + } + .frame(maxWidth: .infinity, minHeight: 240) + + case let .loaded(detail): + loadedContent(detail) + } + } + .scrollIndicators(.hidden) + } + .background(Color.ds.white) + .task { + await store.send(.task).finish() + } + } + + private var header: some View { + HStack { + Text("오늘의 \(store.category.title)") + .dsHeading4Bold + .foregroundStyle(Color.ds.gray975) + + Spacer() + + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(Color.ds.gray900) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .padding(.top, 8) + } + + private func loadedContent(_ detail: LuckActionDetail) -> some View { + VStack(alignment: .center, spacing: 0) { + HalfCircularGaugeView(score: detail.score) + .padding(.top, 40) + .padding(.bottom, 48) + + VStack(spacing: 8) { + Text("오늘의 행운 액션") + .dsBody3Medium + .foregroundStyle(Color.ds.primary500) + + Text(detail.title) + .dsBody2SemiBold + .foregroundStyle(Color.ds.gray975) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + .padding(.horizontal, 16) + .background(Color.ds.gray50) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 20) + .padding(.bottom, 24) + + Text(detail.content) + .dsBody3Regular + .foregroundStyle(Color.ds.gray975) + .lineSpacing(6) + .padding(.horizontal, 20) + .padding(.bottom, 40) + + DSPrimaryLargeButton("토닥이에게 더 물어보기") { + store.send(.todakTapped) + } + .padding(.horizontal, 20) + .padding(.bottom, 32) + } + } +} + +private struct HalfCircularGaugeView: View { + let score: Int + + var body: some View { + let level = FortuneMoodLevel(score: Double(score)) + + ZStack(alignment: .bottom) { + ZStack { + Circle() + .trim(from: 0.5, to: 1.0) + .stroke(Color.ds.gray100, style: StrokeStyle(lineWidth: 16, lineCap: .round)) + + Circle() + .trim(from: 0.5, to: 0.5 + CGFloat(score) / 100.0 * 0.5) + .stroke(level.gaugeGradient, style: StrokeStyle(lineWidth: 16, lineCap: .round)) + } + .frame(width: 200, height: 200) + .padding(10) + .frame(height: 110, alignment: .top) + .clipped() + + Text("\(score)점") + .dsHeading1ExtraBold + .foregroundStyle(level.scoreColor) + .padding(.bottom, -8) + } + .frame(width: 220, height: 110) + } +} + +private extension FortuneMoodLevel { + var scoreColor: Color { + switch self { + case .level01: return Color(red: 247/255, green: 139/255, blue: 139/255) + case .level02: return Color(red: 23/255, green: 144/255, blue: 130/255) + case .level03: return Color(red: 138/255, green: 165/255, blue: 255/255) + case .level04: return Color(red: 127/255, green: 115/255, blue: 234/255) + } + } + + var gaugeGradient: LinearGradient { + switch self { + case .level01: + return LinearGradient(colors: [Color(red: 255/255, green: 194/255, blue: 194/255), scoreColor], startPoint: .leading, endPoint: .trailing) + case .level02: + return LinearGradient(colors: [Color(red: 183/255, green: 232/255, blue: 227/255), Color(red: 77/255, green: 213/255, blue: 197/255)], startPoint: .leading, endPoint: .trailing) + case .level03: + return LinearGradient(colors: [Color(red: 192/255, green: 206/255, blue: 255/255), scoreColor], startPoint: .leading, endPoint: .trailing) + case .level04: + return LinearGradient(colors: [Color(red: 138/255, green: 165/255, blue: 255/255), scoreColor], startPoint: .leading, endPoint: .trailing) + } + } +} diff --git a/Projects/Feature/FortuneFeature/Sources/Report/FortuneReportFeature.swift b/Projects/Feature/FortuneFeature/Sources/Report/FortuneReportFeature.swift new file mode 100644 index 0000000..f35c332 --- /dev/null +++ b/Projects/Feature/FortuneFeature/Sources/Report/FortuneReportFeature.swift @@ -0,0 +1,112 @@ +import ComposableArchitecture +import Foundation + +@Reducer +public struct FortuneReportFeature { + @ObservableState + public struct State: Equatable, Sendable { + public enum ViewState: Equatable, Sendable { + case loading + case loaded(FortuneDetailContent) + case failed(String) + } + + public let dailyFortuneID: UUID + public var pendingCategory: FortuneCategory? + public var viewState: ViewState = .loading + @Presents public var categoryDetail: FortuneCategoryDetailFeature.State? + + public init( + dailyFortuneID: UUID, + selectedCategory: FortuneCategory? = nil + ) { + self.dailyFortuneID = dailyFortuneID + self.pendingCategory = selectedCategory + } + } + + public enum Action: Equatable, Sendable { + case task + case response(Result) + case retryTapped + case categoryTapped(FortuneCategoryScore) + case todakTapped + case luckyActionTapped + case categoryDetail(PresentationAction) + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case todakRequested + case luckyActionRequested + } + } + + @Dependency(\.fortuneClient) private var fortuneClient + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .task, .retryTapped: + state.viewState = .loading + let dailyFortuneID = state.dailyFortuneID + return .run { send in + do { + await send(.response(.success(try await fortuneClient.fetchDetail(dailyFortuneID)))) + } catch is CancellationError { + return + } catch let error as FortuneClientError { + await send(.response(.failure(error))) + } catch { + await send(.response(.failure(.transport))) + } + } + + case let .response(.success(content)): + state.viewState = .loaded(content) + if let category = state.pendingCategory, + let item = content.categoryScores.first(where: { $0.category == category }) { + state.categoryDetail = .init( + luckActionID: item.luckActionID, + category: item.category, + categoryScores: content.categoryScores + ) + } + state.pendingCategory = nil + return .none + + case let .response(.failure(error)): + state.viewState = .failed(error.userMessage) + return .none + + case let .categoryTapped(item): + state.categoryDetail = .init( + luckActionID: item.luckActionID, + category: item.category, + categoryScores: loadedContent(from: state)?.categoryScores ?? [] + ) + return .none + + case .todakTapped, + .categoryDetail(.presented(.delegate(.todakRequested))): + return .send(.delegate(.todakRequested)) + + case .luckyActionTapped, + .categoryDetail(.presented(.delegate(.luckyActionRequested))): + return .send(.delegate(.luckyActionRequested)) + + case .categoryDetail, .delegate: + return .none + } + } + .ifLet(\.$categoryDetail, action: \.categoryDetail) { + FortuneCategoryDetailFeature() + } + } + + private func loadedContent(from state: State) -> FortuneDetailContent? { + guard case let .loaded(content) = state.viewState else { return nil } + return content + } +} diff --git a/Projects/Feature/FortuneFeature/Sources/Report/FortuneReportView.swift b/Projects/Feature/FortuneFeature/Sources/Report/FortuneReportView.swift new file mode 100644 index 0000000..e292752 --- /dev/null +++ b/Projects/Feature/FortuneFeature/Sources/Report/FortuneReportView.swift @@ -0,0 +1,445 @@ +import ComposableArchitecture +import DesignSystem +import SwiftUI + +// 리포트 섹션은 플로팅 툴팁 노출에 사용하는 스크롤 임계 상태를 공유한다. +// swiftlint:disable type_body_length + +struct FortuneReportView: View { + @Bindable var store: StoreOf + let backAction: () -> Void + + @State private var didReachCategorySection = false + @State private var showInfoTooltip = false + + var body: some View { + ZStack(alignment: .bottomTrailing) { + Color.fortuneSpaceBase.ignoresSafeArea() + + backgroundImage + + content + + if case .loaded = store.viewState { + todakFloatingButton + .padding(.trailing, 20) + .padding(.bottom, 20) + } + } + .toolbar(.hidden, for: .navigationBar) + .task { + await store.send(.task).finish() + } + .sheet( + item: $store.scope(state: \.categoryDetail, action: \.categoryDetail) + ) { categoryStore in + FortuneCategoryDetailView(store: categoryStore) + .presentationDetents([.fraction(0.8)]) + .presentationDragIndicator(.hidden) + .presentationCornerRadius(28) + } + } + + private var backgroundImage: some View { + GeometryReader { proxy in + FortuneSpaceBackground() + .frame(height: proxy.size.width / (393.0 / 850.0)) + } + .ignoresSafeArea(edges: .top) + } + + @ViewBuilder + private var content: some View { + switch store.viewState { + case .loading: + FortuneLoadingView() + + case let .failed(message): + FortuneFailureView( + message: message, + retryAction: { store.send(.retryTapped) } + ) + + case let .loaded(detail): + ZStack(alignment: .top) { + ScrollView { + VStack(spacing: 0) { + Color.clear.frame(height: 52) + reportBody(detail) + } + } + .scrollIndicators(.hidden) + + reportHeader + } + } + } + + private var reportHeader: some View { + HStack { + Button(action: backAction) { + DSIcon(.chevronLeftNarrow, width: 24, height: 24) + .foregroundStyle(Color.ds.white) + .frame(width: 44, height: 44) + } + .dsIconButtonStyle(.chevronLeftNarrow, width: 24, height: 24) + + Spacer() + + Color.clear.frame(width: 44, height: 44) + } + .padding(.horizontal, 10) + .frame(height: 52) + } + + private func reportBody(_ detail: FortuneDetailContent) -> some View { + VStack(spacing: 24) { + scoreSection(detail) + summarySection(detail) + categorySection(detail) + itemSection(title: "오늘의 행운 아이템", items: detail.luckyItems, imageName: "img_clover", badgeColor: Color.ds.primary300) + itemSection(title: "오늘의 주의 아이템", items: detail.cautionaryItems, imageName: "img_warning", badgeColor: Color.ds.pink300) + + Color.clear.frame(height: 76) + } + .padding(.horizontal, 20) + .padding(.top, 12) + } + + private func scoreSection(_ detail: FortuneDetailContent) -> some View { + VStack(spacing: 16) { + HStack(spacing: 4) { + Text("오늘의 운세") + .dsBody2Medium + .foregroundStyle(Color.ds.primary300) + + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + showInfoTooltip.toggle() + } + } label: { + Image(systemName: "info.circle") + .font(.system(size: 16, weight: .regular)) + .foregroundStyle(Color.ds.whiteOpacity60) + } + } + .overlay(alignment: .top) { + if showInfoTooltip { + VStack(alignment: .center, spacing: 0) { + TopTailShape() + .fill(Color.ds.white.opacity(0.9)) + .frame(width: 12, height: 8) + .padding(.leading, 80) + + Text("오늘의 운세 점수는\n사주 데이터 분석을 바탕으로 나온\n상세운 5가지의 평균 점수예요") + .dsBody3Medium + .foregroundStyle(Color.ds.gray900) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.ds.white.opacity(0.9)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .fixedSize() + .offset(y: 28) + .transition(.opacity.combined(with: .scale(scale: 0.95, anchor: .top))) + } + } + .zIndex(1) + + Text(detail.title) + .multilineTextAlignment(.center) + .dsHeading4Bold + .foregroundStyle(Color.ds.white) + .padding(.horizontal, 16) + .padding(.bottom, 8) + + Text("\(detail.score)점") + .dsHeading1ExtraBold + .foregroundStyle(Color.ds.white) + .padding(.horizontal, 28) + .padding(.vertical, 12) + .background(Color.ds.whiteOpacity20) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + .frame(maxWidth: .infinity) + .padding(.top, 16) + } + + private func summarySection(_ detail: FortuneDetailContent) -> some View { + VStack(spacing: -80) { + FortuneMoodCharacterImage( + moodLevel: FortuneMoodLevel(score: Double(detail.score)) + ) + .frame(width: 193, height: 181) + .accessibilityHidden(true) + .zIndex(1) + + Text(detail.content) + .dsBody1Medium + .foregroundStyle(Color.ds.white) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + .padding(.top, 60) // 추가 패딩으로 이미지 겹치는 부분 아래로 텍스트 밀어냄 + .background(Color.ds.whiteOpacity10) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + } + + private func categorySection(_ detail: FortuneDetailContent) -> some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 6) { + Text("상세운") + .dsBody1Bold + .foregroundStyle(Color.ds.white) + + Text("사주 데이터 분석을 바탕으로 점수를 제공해요") + .dsBody3Regular + .foregroundStyle(Color.ds.whiteOpacity80) + } + + VStack(spacing: 20) { + ForEach(detail.categoryScores, id: \.category) { item in + Button { + store.send(.categoryTapped(item)) + } label: { + VStack(spacing: 8) { + HStack(spacing: 4) { + Text(item.category.title) + .dsBody3Medium + .foregroundStyle(Color.ds.white) + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.ds.whiteOpacity80) + + Spacer() + + Text("\(item.score)점") + .dsBody3SemiBold + .foregroundStyle(Color.ds.white) + } + + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(Color.ds.whiteOpacity10) + Capsule() + .fill(Color.ds.primary500) + .frame(width: proxy.size.width * CGFloat(max(0, min(item.score, 100))) / 100) + } + } + .frame(height: 10) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + + Button { + store.send(.luckyActionTapped) + } label: { + Text("행운 액션 확인하기") + .dsBody2Medium + .foregroundStyle(Color.ds.primary100) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(Color.clear) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.ds.primary300, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .padding(.top, 8) + } + .padding(20) + .background(Color.ds.whiteOpacity10) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .onGeometryChange(for: Bool.self) { proxy in + proxy.frame(in: .global).minY <= 140 + } action: { reached in + guard reached, !didReachCategorySection else { return } + withAnimation(.spring(response: 0.42, dampingFraction: 0.82)) { + didReachCategorySection = true + } + } + } + + private func itemSection(title: String, items: [String], imageName: String, badgeColor: Color) -> some View { + VStack(alignment: .center, spacing: 16) { + Image(imageName, bundle: .module) + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + + Text(title) + .dsBody3Medium + .foregroundStyle(Color.ds.white) + + FlowLayout(spacing: 8) { + ForEach(items, id: \.self) { item in + Text(item) + .dsBody2Regular + .foregroundStyle(badgeColor) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.ds.whiteOpacity10) + .clipShape(Capsule()) + } + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + .padding(.horizontal, 20) + .background(Color.ds.whiteOpacity10) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + + private var todakFloatingButton: some View { + HStack(alignment: .bottom, spacing: 8) { + if didReachCategorySection { + Text("오늘 이 사람과 어디를 갈까?") + .dsBody3Medium + .foregroundStyle(Color.ds.gray900) + .padding(.horizontal, 16) + .padding(.vertical, 5) + .background(Color.ds.white.opacity(0.9)) + .clipShape(Capsule()) + .padding(.bottom, 12) + .transition(.move(edge: .trailing).combined(with: .opacity)) + .zIndex(0) + } + + VStack(spacing: 4) { + Text("AI 토닥이") + .dsCaption2SemiBold + .foregroundStyle(Color.ds.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(red: 193/255, green: 180/255, blue: 246/255)) + ) + .overlay(alignment: .bottom) { + BottomTailShape() + .fill(Color(red: 193/255, green: 180/255, blue: 246/255)) + .frame(width: 8, height: 5) + .offset(y: 5) + } + .padding(.bottom, 4) + + Button { + store.send(.todakTapped) + } label: { + Image("img_fab_todak", bundle: .module) + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.ds.primary300, lineWidth: 1)) + .shadow(color: Color.black.opacity(0.3), radius: 10, y: 4) + } + .buttonStyle(.plain) + .accessibilityLabel("토닥이에게 물어보기") + } + .zIndex(1) + } + } +} + +// swiftlint:enable type_body_length + +private struct FlowLayout: Layout { + let spacing: CGFloat + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + let width = proposal.width ?? 0 + var horizontalOffset: CGFloat = 0 + var verticalOffset: CGFloat = 0 + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if horizontalOffset > 0, horizontalOffset + size.width > width { + horizontalOffset = 0 + verticalOffset += rowHeight + spacing + rowHeight = 0 + } + horizontalOffset += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + return CGSize(width: width, height: verticalOffset + rowHeight) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + var rows: [[LayoutSubview]] = [] + var currentRow: [LayoutSubview] = [] + var currentRowWidth: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if !currentRow.isEmpty, currentRowWidth + spacing + size.width > bounds.width { + rows.append(currentRow) + currentRow = [subview] + currentRowWidth = size.width + } else { + currentRow.append(subview) + currentRowWidth += (currentRow.count == 1 ? 0 : spacing) + size.width + } + } + if !currentRow.isEmpty { + rows.append(currentRow) + } + + var verticalOffset = bounds.minY + for row in rows { + let rowWidth = row.reduce(0) { $0 + $1.sizeThatFits(.unspecified).width } + spacing * CGFloat(row.count - 1) + var horizontalOffset = bounds.minX + (bounds.width - rowWidth) / 2 + var rowHeight: CGFloat = 0 + + for subview in row { + let size = subview.sizeThatFits(.unspecified) + subview.place( + at: CGPoint(x: horizontalOffset, y: verticalOffset), + proposal: ProposedViewSize(size) + ) + horizontalOffset += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + verticalOffset += rowHeight + spacing + } + } +} + +struct BottomTailShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: rect.width, y: 0)) + path.addLine(to: CGPoint(x: rect.width / 2, y: rect.height)) + path.closeSubpath() + return path + } +} + +struct TopTailShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: 0, y: rect.height)) + path.addLine(to: CGPoint(x: rect.width, y: rect.height)) + path.addLine(to: CGPoint(x: rect.width / 2, y: 0)) + path.closeSubpath() + return path + } +} From 20c5f860aacf3d886f447a73386cfae3468929b4 Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Fri, 14 Aug 2026 23:45:56 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat(fortune):=20=EC=82=AC=EC=A3=BC?= =?UTF-8?q?=ED=92=80=EC=9D=B4=203=EC=A2=85(=EC=83=81=EB=8C=80=EB=B0=A9=20?= =?UTF-8?q?=EA=B6=81=ED=95=A9=C2=B7=ED=83=9D=EC=9D=BC=C2=B7=EC=97=B0?= =?UTF-8?q?=EC=9A=B4)=20=ED=99=94=EB=A9=B4=20=EB=B0=8F=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EA=B5=AC=ED=98=84=20(#7?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상대방 궁합(CompatibilityView) 내 정보/상대방 사주 및 결과 화면 구현 (#81) - 택일 운세(DayFortuneView) 목적 및 날짜 선택, 결과 화면 구현 (#80) - 연도별 운세(YearFortuneView) 연도 선택 및 결과 화면 구현 (#82) - FortuneClient Live 엔드포인트 연동 및 DTO 매핑 구현 (#84) - 마이페이지 및 궁합 화면에 DSSajuPillarCell 적용 --- .../Example/Sources/ExampleContentView.swift | 152 ++- .../Sources/FortuneExampleFixtures.swift | 51 +- .../Assets.xcassets/Icons/Contents.json | 6 + .../Contents.json | 16 + .../fortune_calendar_export.svg | 5 + .../fortune_share.imageset/Contents.json | 16 + .../fortune_share.imageset/fortune_share.svg | 7 + .../Compatibility/CompatibilityFeature.swift | 368 ++++++ .../Compatibility/CompatibilityView.swift | 1028 +++++++++++++++++ .../DayFortune/DayFortuneFeature.swift | 160 +++ .../Sources/DayFortune/DayFortuneView.swift | 766 ++++++++++++ .../Sources/Domain/FortuneHomeContent.swift | 8 +- .../Sources/Domain/FortuneSajuModels.swift | 358 ++++++ .../Sources/FortuneClient+Live.swift | 556 ++++++++- .../Sources/FortuneClient.swift | 50 +- .../Sources/FortuneFeature.swift | 89 +- .../FortuneFeature/Sources/FortuneView.swift | 85 +- .../Sources/Views/FortuneHomeView.swift | 6 +- .../Views/FortuneResultComponents.swift | 67 ++ .../YearFortune/YearFortuneFeature.swift | 80 ++ .../Sources/YearFortune/YearFortuneView.swift | 343 ++++++ .../Tests/FortuneClientTests.swift | 350 +++++- .../Tests/FortuneFeatureTests.swift | 68 +- .../Tests/FortuneFollowupFeatureTests.swift | 181 +++ .../Sources/MyPageSajuDetailView.swift | 38 +- 25 files changed, 4720 insertions(+), 134 deletions(-) create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/Contents.json create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_calendar_export.imageset/Contents.json create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_calendar_export.imageset/fortune_calendar_export.svg create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_share.imageset/Contents.json create mode 100644 Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_share.imageset/fortune_share.svg create mode 100644 Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift create mode 100644 Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift create mode 100644 Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneFeature.swift create mode 100644 Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift create mode 100644 Projects/Feature/FortuneFeature/Sources/Domain/FortuneSajuModels.swift create mode 100644 Projects/Feature/FortuneFeature/Sources/Views/FortuneResultComponents.swift create mode 100644 Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneFeature.swift create mode 100644 Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneView.swift create mode 100644 Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift diff --git a/Projects/Feature/FortuneFeature/Example/Sources/ExampleContentView.swift b/Projects/Feature/FortuneFeature/Example/Sources/ExampleContentView.swift index 1dc2763..11f95f3 100644 --- a/Projects/Feature/FortuneFeature/Example/Sources/ExampleContentView.swift +++ b/Projects/Feature/FortuneFeature/Example/Sources/ExampleContentView.swift @@ -11,33 +11,147 @@ struct ExampleContentView: View { ) ) { FortuneFeature() + } withDependencies: { + $0.fortuneClient.fetchDetail = { _ in + try await Task.sleep(for: .seconds(0.5)) + return .example(score: 65) + } + $0.fortuneClient.fetchLuckAction = { _ in + try await Task.sleep(for: .seconds(0.5)) + return LuckActionDetail( + id: UUID(), + category: .relationship, + score: 85, + title: "따뜻한 차 마시기", + content: "따뜻한 차를 마시면 마음이 안정되고 행운이 깃들 거예요.", + isAchieved: false + ) + } + $0.fortuneClient.fetchMySaju = { + try await Task.sleep(for: .seconds(0.5)) + return SajuChartDetail( + id: UUID(), + name: "나의 성향", + gender: .female, + birthDate: Date(), + calendarType: .solar, + birthTime: nil, + isBirthTimeUnknown: true, + pillars: [ + SajuPillar(type: .year, heavenlyStem: SajuSymbol(hanja: "甲", reading: "갑", elementLabel: "큰 나무", elementHanja: "목"), earthlyBranch: SajuSymbol(hanja: "辰", reading: "진", elementLabel: "용", elementHanja: "토"), stemTenGod: "비견", branchTenGod: "편재"), + SajuPillar(type: .month, heavenlyStem: SajuSymbol(hanja: "丙", reading: "병", elementLabel: "태양", elementHanja: "화"), earthlyBranch: SajuSymbol(hanja: "寅", reading: "인", elementLabel: "호랑이", elementHanja: "목"), stemTenGod: "식신", branchTenGod: "비견"), + SajuPillar(type: .day, heavenlyStem: SajuSymbol(hanja: "戊", reading: "무", elementLabel: "큰 산", elementHanja: "토"), earthlyBranch: SajuSymbol(hanja: "申", reading: "신", elementLabel: "원숭이", elementHanja: "금"), stemTenGod: nil, branchTenGod: "식신") + ] + ) + } + $0.fortuneClient.fetchPartners = { + try await Task.sleep(for: .seconds(0.5)) + return [ + FortunePartner(id: UUID(), name: "토닥이", relationship: .friend) + ] + } + $0.fortuneClient.fetchPartnerSaju = { _ in + try await Task.sleep(for: .seconds(0.5)) + return SajuChartDetail( + id: UUID(), + name: "상대방 성향", + gender: .male, + birthDate: Date(), + calendarType: .solar, + birthTime: nil, + isBirthTimeUnknown: true, + pillars: [ + SajuPillar(type: .year, heavenlyStem: SajuSymbol(hanja: "丁", reading: "정", elementLabel: "등불", elementHanja: "화"), earthlyBranch: SajuSymbol(hanja: "卯", reading: "묘", elementLabel: "토끼", elementHanja: "목"), stemTenGod: "정인", branchTenGod: "정관"), + SajuPillar(type: .month, heavenlyStem: SajuSymbol(hanja: "己", reading: "기", elementLabel: "작은 흙", elementHanja: "토"), earthlyBranch: SajuSymbol(hanja: "酉", reading: "유", elementLabel: "닭", elementHanja: "금"), stemTenGod: "겁재", branchTenGod: "상관") + ] + ) + } + $0.fortuneClient.registerPartner = { _ in UUID() } + $0.fortuneClient.createCompatibility = { _, _ in + CompatibilityResult( + id: UUID(), + partnerName: "토닥이", + relationship: .friend, + score: 95, + headline: "최고의 궁합", + subheadline: "서로에게 큰 힘이 됩니다", + summary: "두 분의 기운이 조화롭게 어우러져 서로의 부족한 점을 채워주는 훌륭한 관계입니다.", + totalAnalysis: "전체적인 사주의 흐름이 서로를 지탱하며 시너지를 만들어냅니다.", + analysisBasis: "목(木)과 화(火)의 기운이 상생하여 발전적인 방향으로 나아갑니다.", + elements: [ + FortuneElementScore(element: .wood, percentage: 40), + FortuneElementScore(element: .fire, percentage: 30), + FortuneElementScore(element: .earth, percentage: 20), + FortuneElementScore(element: .metal, percentage: 10) + ] + ) + } + $0.fortuneClient.createDayFortunes = { purpose, dates in + try await Task.sleep(for: .seconds(1)) + return Array( + dates.enumerated().map { index, date in + DayFortuneResult( + id: UUID(), + purpose: purpose, + targetDate: date, + score: 90 - (index * 5), + title: index == 0 ? "최적의 날짜입니다!" : "무난한 날짜입니다.", + content: "이 날은 기운이 매우 긍정적으로 작용하여 원하시는 바를 순조롭게 이룰 수 있습니다.", + categories: [ + FortuneCategoryStar(category: .achievement, star: 5), + FortuneCategoryStar(category: .money, star: 4), + FortuneCategoryStar(category: .health, star: 3) + ] + ) + } + .sorted { $0.score > $1.score } + .prefix(3) + ) + } + $0.fortuneClient.createYearFortune = { _ in + try await Task.sleep(for: .seconds(1)) + return YearFortuneResult( + id: UUID(), + year: 2026, + score: 90, + title: "새로운 기회가 찾아오는 희망찬 한 해", + content: "그동안 준비했던 일들이 결실을 맺으며, 특히 하반기에 큰 성취를 이룰 수 있습니다.", + categories: [ + FortuneCategoryStar(category: .money, star: 5), + FortuneCategoryStar(category: .achievement, star: 4), + FortuneCategoryStar(category: .relationship, star: 4) + ] + ) + } } var body: some View { FortuneView(store: store) .overlay(alignment: .bottomTrailing) { - Menu { - Picker("Mood Level", selection: $selectedScenario) { - ForEach(MoodScenario.allCases, id: \.self) { scenario in - Text(scenario.title) - .tag(scenario) + if store.path.isEmpty && store.categoryDetail == nil { + Menu { + Picker("Mood Level", selection: $selectedScenario) { + ForEach(MoodScenario.allCases, id: \.self) { scenario in + Text(scenario.title) + .tag(scenario) + } } + } label: { + Label(selectedScenario.title, systemImage: "face.smiling") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + .padding(.horizontal, 14) + .frame(height: 40) + .background(.regularMaterial, in: Capsule()) + .overlay { + Capsule() + .stroke(.secondary.opacity(0.3), lineWidth: 1) + } + .shadow(color: .black.opacity(0.12), radius: 6, y: 2) } - } label: { - Label(selectedScenario.title, systemImage: "face.smiling") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.primary) - .padding(.horizontal, 14) - .frame(height: 40) - .background(.regularMaterial, in: Capsule()) - .overlay { - Capsule() - .stroke(.secondary.opacity(0.3), lineWidth: 1) - } - .shadow(color: .black.opacity(0.12), radius: 6, y: 2) + .padding(.trailing, 16) + .padding(.bottom, 16) } - .padding(.trailing, 16) - .padding(.bottom, 16) } .onChange(of: selectedScenario) { _, scenario in store.send( diff --git a/Projects/Feature/FortuneFeature/Example/Sources/FortuneExampleFixtures.swift b/Projects/Feature/FortuneFeature/Example/Sources/FortuneExampleFixtures.swift index 0c9dc2e..5dc1845 100644 --- a/Projects/Feature/FortuneFeature/Example/Sources/FortuneExampleFixtures.swift +++ b/Projects/Feature/FortuneFeature/Example/Sources/FortuneExampleFixtures.swift @@ -10,11 +10,52 @@ extension FortuneHomeContent { scoreDescription: "흐름 좋은 날", title: "흘렸던 땀방울이 달콤한 결실로 돌아오는 하루예요.", categoryScores: [ - FortuneCategoryScore(category: .relationship, score: 45), - FortuneCategoryScore(category: .love, score: 74), - FortuneCategoryScore(category: .achievement, score: 38), - FortuneCategoryScore(category: .health, score: 62), - FortuneCategoryScore(category: .money, score: 90) + FortuneCategoryScore( + luckActionID: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + category: .relationship, + score: 45 + ), + FortuneCategoryScore( + luckActionID: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, + category: .love, + score: 74 + ), + FortuneCategoryScore( + luckActionID: UUID(uuidString: "00000000-0000-0000-0000-000000000003")!, + category: .achievement, + score: 38 + ), + FortuneCategoryScore( + luckActionID: UUID(uuidString: "00000000-0000-0000-0000-000000000004")!, + category: .health, + score: 62 + ), + FortuneCategoryScore( + luckActionID: UUID(uuidString: "00000000-0000-0000-0000-000000000005")!, + category: .money, + score: 90 + ) + ] + ) + } +} + +extension FortuneDetailContent { + static func example(score: Int = 65) -> Self { + Self( + dailyFortuneID: UUID(uuidString: "A0B1C2D3-E4F5-4678-9012-3456789ABCDE")!, + fortuneDate: Date(timeIntervalSince1970: 1_788_969_600), + score: score, + title: "오늘은 무난하고 평범한 하루예요", + content: "주변 사람들과의 관계가 한층 부드러워지는 시기예요. 평소에는 무심코 지나쳤던 인연 속에서 뜻밖의 도움이나 따뜻한 위로를 얻게 될 수 있어요. 작은 오해가 있었다면 먼저 손을 내밀어 보세요. 진심 어린 한마디가 관계의 흐름을 좋은 방향으로 이끌어 줄 거예요.", + luckyItems: ["따뜻한 차", "노란색 아이템", "산책"], + cautionaryItems: ["충동구매", "과식"], + categoryScores: [ + FortuneCategoryScore(luckActionID: UUID(), category: .relationship, score: 45), + FortuneCategoryScore(luckActionID: UUID(), category: .love, score: 74), + FortuneCategoryScore(luckActionID: UUID(), category: .achievement, score: 38), + FortuneCategoryScore(luckActionID: UUID(), category: .health, score: 62), + FortuneCategoryScore(luckActionID: UUID(), category: .money, score: 90) ] ) } diff --git a/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/Contents.json b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_calendar_export.imageset/Contents.json b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_calendar_export.imageset/Contents.json new file mode 100644 index 0000000..023b5de --- /dev/null +++ b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_calendar_export.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "fortune_calendar_export.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_calendar_export.imageset/fortune_calendar_export.svg b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_calendar_export.imageset/fortune_calendar_export.svg new file mode 100644 index 0000000..3ad8cd7 --- /dev/null +++ b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_calendar_export.imageset/fortune_calendar_export.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_share.imageset/Contents.json b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_share.imageset/Contents.json new file mode 100644 index 0000000..5685de0 --- /dev/null +++ b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_share.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "fortune_share.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_share.imageset/fortune_share.svg b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_share.imageset/fortune_share.svg new file mode 100644 index 0000000..0210700 --- /dev/null +++ b/Projects/Feature/FortuneFeature/Resources/Assets.xcassets/Icons/fortune_share.imageset/fortune_share.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift new file mode 100644 index 0000000..e4e3e9d --- /dev/null +++ b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift @@ -0,0 +1,368 @@ +import ComposableArchitecture +import Foundation +import Model + +// 등록, 선택, 결과 상태는 하나의 화면 단위 상태 머신으로 구성한다. +// swiftlint:disable type_body_length + +@Reducer +public struct CompatibilityFeature { + private enum CancelID { + case partnerSaju + } + + public struct InitialData: Equatable, Sendable { + public let mySaju: SajuChartDetail + public let partners: [FortunePartner] + + public init(mySaju: SajuChartDetail, partners: [FortunePartner]) { + self.mySaju = mySaju + self.partners = partners + } + } + + @ObservableState + public struct State: Equatable, Sendable { + public enum ViewState: Equatable, Sendable { + case loading + case loaded + case failed(String) + } + + public var viewState: ViewState = .loading + public var partners: [FortunePartner] = [] + public var selectedPartnerID: UUID? + public var mySaju: SajuChartDetail? + public var selectedPartnerSaju: SajuChartDetail? + public var isPartnerPickerPresented = false + public var isRegistrationPresented = false + public var isSubmitting = false + public var errorMessage: String? + public var result: CompatibilityResult? + + public var name = "" + public var gender: Gender? + public var calendarType: BirthDateCalendar = .solar + public var birthDate = Date(timeIntervalSince1970: 946_684_800) + public var birthTime: BirthTimePeriod? + public var isBirthTimeUnknown = false + public var relationship: Relationship? + + public init() {} + + public var selectedPartner: FortunePartner? { + partners.first { $0.id == selectedPartnerID } + } + + public var nameValidationMessage: String? { + guard !name.isEmpty else { return nil } + guard name.count <= 10 else { return "최대 10글자까지 입력 가능해요." } + guard name.allSatisfy({ $0.isLetter || $0.isNumber }) else { + return "한글, 영문, 숫자만 입력할 수 있어요." + } + return nil + } + + public var canRegister: Bool { + !name.isEmpty + && nameValidationMessage == nil + && gender != nil + && (birthTime != nil || isBirthTimeUnknown) + && relationship != nil + && !isSubmitting + } + } + + public enum Action: Equatable, Sendable { + case task + case initialResponse(Result) + case partnersResponse(Result<[FortunePartner], FortuneClientError>) + case partnerSajuResponse(UUID, Result) + case retryTapped + case partnerPickerPresented(Bool) + case partnerSelected(UUID) + case registrationPresented(Bool) + case nameChanged(String) + case genderChanged(Gender?) + case calendarTypeChanged(BirthDateCalendar) + case birthDateChanged(Date) + case birthTimeChanged(BirthTimePeriod?) + case birthTimeUnknownChanged(Bool) + case relationshipChanged(Relationship?) + case registerTapped + case registrationResponse(Result) + case compatibilityTapped + case compatibilityResponse(Result) + case resultBackTapped + case shareTapped + case todakTapped + case myInfoEditTapped + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case todakRequested + case myInfoEditRequested + } + } + + @Dependency(\.fortuneClient) private var fortuneClient + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .task, .retryTapped: + state.viewState = .loading + return fetchInitialData() + + case let .initialResponse(.success(data)): + state.mySaju = data.mySaju + state.partners = data.partners + if state.selectedPartnerID == nil || state.selectedPartner == nil { + state.selectedPartnerID = data.partners.first?.id + } + state.viewState = .loaded + state.errorMessage = nil + state.selectedPartnerSaju = nil + guard let partnerID = state.selectedPartnerID else { return .none } + return fetchPartnerSaju(partnerID) + + case let .initialResponse(.failure(error)): + state.viewState = .failed(error.userMessage) + return .none + + case let .partnersResponse(.success(partners)): + state.partners = partners + if state.selectedPartnerID == nil || state.selectedPartner == nil { + state.selectedPartnerID = partners.first?.id + } + state.viewState = .loaded + state.errorMessage = nil + state.selectedPartnerSaju = nil + guard let partnerID = state.selectedPartnerID else { return .none } + return fetchPartnerSaju(partnerID) + + case let .partnersResponse(.failure(error)): + state.viewState = .failed(error.userMessage) + return .none + + case let .partnerPickerPresented(isPresented): + state.isPartnerPickerPresented = isPresented + return .none + + case let .partnerSelected(partnerID): + state.selectedPartnerID = partnerID + state.selectedPartnerSaju = nil + state.isPartnerPickerPresented = false + return fetchPartnerSaju(partnerID) + + case let .partnerSajuResponse(partnerID, .success(chart)): + guard state.selectedPartnerID == partnerID else { return .none } + state.selectedPartnerSaju = chart + state.errorMessage = nil + return .none + + case let .partnerSajuResponse(partnerID, .failure(error)): + guard state.selectedPartnerID == partnerID else { return .none } + state.selectedPartnerSaju = nil + state.errorMessage = error.userMessage + return .none + + case let .registrationPresented(isPresented): + state.isRegistrationPresented = isPresented + if isPresented { + resetForm(state: &state) + } + return .none + + case let .nameChanged(name): + state.name = String(name.prefix(11)) + return .none + + case let .genderChanged(gender): + state.gender = gender + return .none + + case let .calendarTypeChanged(type): + state.calendarType = type + return .none + + case let .birthDateChanged(date): + state.birthDate = date + return .none + + case let .birthTimeChanged(time): + state.birthTime = time + if time != nil { state.isBirthTimeUnknown = false } + return .none + + case let .birthTimeUnknownChanged(isUnknown): + state.isBirthTimeUnknown = isUnknown + if isUnknown { state.birthTime = nil } + return .none + + case let .relationshipChanged(relationship): + state.relationship = relationship + return .none + + case .registerTapped: + guard + state.canRegister, + let gender = state.gender, + let relationship = state.relationship + else { return .none } + + state.isSubmitting = true + state.errorMessage = nil + let input = PartnerRegistrationInput( + name: state.name, + gender: gender, + calendarType: state.calendarType, + birthDate: state.birthDate, + birthTime: state.birthTime, + isBirthTimeUnknown: state.isBirthTimeUnknown, + relationship: relationship + ) + return .run { send in + do { + await send(.registrationResponse(.success(try await fortuneClient.registerPartner(input)))) + } catch let error as FortuneClientError { + await send(.registrationResponse(.failure(error))) + } catch { + await send(.registrationResponse(.failure(.transport))) + } + } + + case let .registrationResponse(.success(partnerID)): + state.isSubmitting = false + state.isRegistrationPresented = false + state.selectedPartnerID = partnerID + return fetchPartners() + + case let .registrationResponse(.failure(error)): + state.isSubmitting = false + state.errorMessage = error.userMessage + return .none + + case .compatibilityTapped: + guard + let partner = state.selectedPartner, + !state.isSubmitting + else { return .none } + state.isSubmitting = true + state.errorMessage = nil + return .run { send in + do { + let result = try await fortuneClient.createCompatibility( + partner.id, + partner.name + ) + await send(.compatibilityResponse(.success(result))) + } catch let error as FortuneClientError { + await send(.compatibilityResponse(.failure(error))) + } catch { + await send(.compatibilityResponse(.failure(.transport))) + } + } + + case let .compatibilityResponse(.success(result)): + state.isSubmitting = false + state.result = result + return .none + + case let .compatibilityResponse(.failure(error)): + state.isSubmitting = false + state.errorMessage = error.userMessage + return .none + + case .resultBackTapped: + state.result = nil + return .none + + case .todakTapped: + return .send(.delegate(.todakRequested)) + + case .myInfoEditTapped: + return .send(.delegate(.myInfoEditRequested)) + + case .shareTapped, .delegate: + return .none + } + } + } + + private func fetchInitialData() -> Effect { + .run { send in + do { + async let mySaju = fortuneClient.fetchMySaju() + async let partners = fortuneClient.fetchPartners() + let resolvedMySaju = try await mySaju + let resolvedPartners = try await partners + await send( + .initialResponse( + .success( + InitialData( + mySaju: resolvedMySaju, + partners: resolvedPartners + ) + ) + ) + ) + } catch is CancellationError { + return + } catch let error as FortuneClientError { + await send(.initialResponse(.failure(error))) + } catch { + await send(.initialResponse(.failure(.transport))) + } + } + } + + private func fetchPartners() -> Effect { + .run { send in + do { + await send(.partnersResponse(.success(try await fortuneClient.fetchPartners()))) + } catch is CancellationError { + return + } catch let error as FortuneClientError { + await send(.partnersResponse(.failure(error))) + } catch { + await send(.partnersResponse(.failure(.transport))) + } + } + } + + private func fetchPartnerSaju(_ partnerID: UUID) -> Effect { + .run { send in + do { + await send( + .partnerSajuResponse( + partnerID, + .success(try await fortuneClient.fetchPartnerSaju(partnerID)) + ) + ) + } catch is CancellationError { + return + } catch let error as FortuneClientError { + await send(.partnerSajuResponse(partnerID, .failure(error))) + } catch { + await send(.partnerSajuResponse(partnerID, .failure(.transport))) + } + } + .cancellable(id: CancelID.partnerSaju, cancelInFlight: true) + } + + private func resetForm(state: inout State) { + state.name = "" + state.gender = nil + state.calendarType = .solar + state.birthDate = Date(timeIntervalSince1970: 946_684_800) + state.birthTime = nil + state.isBirthTimeUnknown = false + state.relationship = nil + state.errorMessage = nil + } +} + +// swiftlint:enable type_body_length diff --git a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift new file mode 100644 index 0000000..0eea3de --- /dev/null +++ b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift @@ -0,0 +1,1028 @@ +// swiftlint:disable file_length +import ComposableArchitecture +import DesignSystem +import Model +import SwiftUI + +struct CompatibilityView: View { + @Bindable var store: StoreOf + let backAction: () -> Void + + @State private var selectedPartnerIndex: Int = 0 + + init(store: StoreOf, backAction: @escaping () -> Void) { + self.store = store + self.backAction = backAction + } + + var body: some View { + ZStack { + switch store.viewState { + case .loading: + ZStack { + Color.ds.white.ignoresSafeArea() + FortuneLoadingView() + } + + case let .failed(message): + ZStack { + Color.ds.white.ignoresSafeArea() + FortuneFailureView(message: message) { + store.send(.retryTapped) + } + } + + case .loaded: + if store.result != nil { + CompatibilityResultContent(store: store) + } else if store.isRegistrationPresented { + PartnerRegistrationView(store: store) + .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .trailing))) + } else { + CompatibilityMainContent(store: store, backAction: backAction) + .transition(.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .leading))) + } + } + } + .animation(.easeInOut(duration: 0.25), value: store.isRegistrationPresented) + .animation(.easeInOut(duration: 0.25), value: store.result != nil) + .toolbar(.hidden, for: .navigationBar) + .task { + store.send(.task) + } + .dsWheelPickerSheet( + isPresented: $store.isPartnerPickerPresented.sending(\.partnerPickerPresented), + layout: .single, + title: "궁합 상대 선택", + actionTitle: "추가", + onSave: { + store.send(.partnerPickerPresented(false)) + store.send(.registrationPresented(true)) + } + ) { + DSSingleWheelPicker( + items: partnerWheelItems, + selection: $selectedPartnerIndex, + accessibilityLabel: "궁합 상대" + ) + } + .onAppear { + syncSelectedPartnerIndex() + } + .onChange(of: store.selectedPartnerID) { _, _ in + syncSelectedPartnerIndex() + } + .onChange(of: selectedPartnerIndex) { _, newIndex in + if store.partners.indices.contains(newIndex) { + store.send(.partnerSelected(store.partners[newIndex].id)) + } + } + } + + private var partnerWheelItems: [DSWheelPickerItem] { + guard !store.partners.isEmpty else { + return [DSWheelPickerItem(value: 0, title: "등록된 상대 없음")] + } + return store.partners.enumerated().map { index, partner in + DSWheelPickerItem( + value: index, + title: "\(partner.name) (\(partner.relationship.title))" + ) + } + } + + private func syncSelectedPartnerIndex() { + if let selectedID = store.selectedPartnerID, + let index = store.partners.firstIndex(where: { $0.id == selectedID }) { + selectedPartnerIndex = index + } else { + selectedPartnerIndex = 0 + } + } +} + +// MARK: - 메인 화면 + +private struct CompatibilityMainContent: View { + @Bindable var store: StoreOf + let backAction: () -> Void + + var body: some View { + ZStack { + Color.ds.white.ignoresSafeArea() + + VStack(spacing: 0) { + DSHeaderSub( + title: "상대방 궁합", + leftItem: DSHeaderActionItem( + identifier: "back", + icon: .chevronLeftNarrow, + action: backAction + ) + ) + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + mySajuSection + + partnerSection + + if let errorMessage = store.errorMessage { + FortuneErrorBanner(message: errorMessage) + } + + submitButton + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 40) + } + .scrollIndicators(.hidden) + } + + if store.isSubmitting { + ZStack { + Color.black.opacity(0.4).ignoresSafeArea() + FortuneLoadingView() + } + } + } + } + + @ViewBuilder + private var mySajuSection: some View { + if let mySaju = store.mySaju { + VStack(alignment: .leading, spacing: 12) { + Text("내 정보") + .dsHeading3Bold + .foregroundStyle(Color.ds.gray975) + + SajuChartCard( + chart: mySaju, + fallbackName: "토닥이", + relationshipTitle: nil, + onEditTapped: { + store.send(.myInfoEditTapped) + } + ) + } + } + } + + private var partnerSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("상대방 정보") + .dsHeading3Bold + .foregroundStyle(Color.ds.gray975) + + if let partner = store.selectedPartner { + selectedPartnerCard(partner: partner) + } else { + emptyPartnerCard + } + } + } + + private func selectedPartnerCard(partner: FortunePartner) -> some View { + VStack(alignment: .leading, spacing: 12) { + if let chart = store.selectedPartnerSaju { + SajuChartCard( + chart: chart, + fallbackName: partner.name, + relationshipTitle: partner.relationship.title, + onEditTapped: { + store.send(.partnerPickerPresented(true)) + } + ) + } else { + HStack(spacing: 12) { + ProgressView() + Text("\(partner.name)님의 사주 정보를 불러오고 있어요.") + .dsBody3Regular + .foregroundStyle(Color.ds.gray700) + } + .frame(maxWidth: .infinity, minHeight: 120) + .background(Color.ds.gray50) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + } + } + + private var emptyPartnerCard: some View { + Button { + if store.partners.isEmpty { + store.send(.registrationPresented(true)) + } else { + store.send(.partnerPickerPresented(true)) + } + } label: { + VStack(spacing: 16) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color.ds.gray200) + .frame(width: 48, height: 48) + + Image(systemName: "plus") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(Color.ds.gray600) + } + + Text("궁합 볼 상대방의 정보를 입력해 주세요.") + .dsBody3Regular + .foregroundStyle(Color.ds.gray800) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 36) + .background(Color.ds.gray50) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + .buttonStyle(.plain) + } + + private var submitButton: some View { + DSPrimaryLargeButton("무료로 궁합 보기") { + store.send(.compatibilityTapped) + } + .disabled(store.selectedPartnerID == nil || store.isSubmitting) + } +} + + + +// MARK: - 상대방 등록 화면 (Push Screen) + +private struct PartnerRegistrationView: View { + @Bindable var store: StoreOf + @State private var isDatePickerPresented = false + @State private var isTimePickerPresented = false + + @State private var pickerYear = 1999 + @State private var pickerMonth = 1 + @State private var pickerDay = 1 + @State private var pickerBirthTimeRawValue = BirthTimePeriod.inTime.rawValue + + private let calendar = Calendar.current + + var body: some View { + ZStack { + Color.ds.white.ignoresSafeArea() + + VStack(spacing: 0) { + FortuneHeaderView( + title: "상대방 정보 입력", + isDark: false + ) { + store.send(.registrationPresented(false)) + } + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + DSEnterName( + text: $store.name.sending(\.nameChanged), + validationState: nameValidationState + ) + + DSSelectGender( + selection: $store.gender.sending(\.genderChanged) + ) + + DSSelectLunarOrSolarCalendar( + selection: calendarTypeBinding + ) + + DSEnterDateOfBirth( + selection: birthDateBinding, + isFocused: isDatePickerPresented + ) { + prepareBirthDatePicker() + isDatePickerPresented = true + } + + DSEnterTimeOfBirth( + selection: $store.birthTime.sending(\.birthTimeChanged), + isFocused: isTimePickerPresented, + isTimeUnknown: $store.isBirthTimeUnknown.sending(\.birthTimeUnknownChanged) + ) { + prepareBirthTimePicker() + isTimePickerPresented = true + } + + DSSelectRelationship( + selection: $store.relationship.sending(\.relationshipChanged) + ) + + if let errorMessage = store.errorMessage { + FortuneErrorBanner(message: errorMessage) + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 100) + } + .scrollIndicators(.hidden) + } + + if store.isSubmitting { + ZStack { + Color.black.opacity(0.4).ignoresSafeArea() + FortuneLoadingView() + } + } + } + .safeAreaInset(edge: .bottom) { + VStack(spacing: 0) { + DSPrimaryLargeButton("저장하기") { + store.send(.registerTapped) + } + .disabled(!store.canRegister) + .opacity(store.canRegister ? 1.0 : 0.4) + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 12) + } + .background( + LinearGradient( + colors: [ + Color.ds.white.opacity(0), + Color.ds.white.opacity(0.95), + Color.ds.white + ], + startPoint: .top, + endPoint: .bottom + ) + ) + } + .dsWheelPickerSheet( + isPresented: $isDatePickerPresented, + layout: .date, + title: "생년월일 입력", + onSave: saveBirthDate + ) { + DSMultiWheelPicker( + layout: .date, + columns: [ + DSWheelPickerColumn( + items: birthYearItems, + selection: $pickerYear, + accessibilityLabel: "연도" + ), + DSWheelPickerColumn( + items: birthMonthItems, + selection: $pickerMonth, + accessibilityLabel: "월", + isCircular: true + ), + DSWheelPickerColumn( + items: birthDayItems, + selection: $pickerDay, + accessibilityLabel: "일" + ) + ] + ) + } + .dsWheelPickerSheet( + isPresented: $isTimePickerPresented, + layout: .single, + title: "태어난 시각 선택", + onSave: saveBirthTime + ) { + DSSingleWheelPicker( + items: birthTimeItems, + selection: $pickerBirthTimeRawValue, + accessibilityLabel: "태어난 시각" + ) + } + .onChange(of: pickerYear) { _, _ in normalizeBirthDatePicker() } + .onChange(of: pickerMonth) { _, _ in normalizeBirthDatePicker() } + } + + private var nameValidationState: DSTextFieldValidationState { + if let message = store.nameValidationMessage { + return .error(message: message) + } + return .none + } + + private var calendarTypeBinding: Binding { + Binding( + get: { store.calendarType }, + set: { if let type = $0 { store.send(.calendarTypeChanged(type)) } } + ) + } + + private var birthDateBinding: Binding { + Binding( + get: { store.birthDate.toBirthDate }, + set: { if let birthDate = $0 { store.send(.birthDateChanged(birthDate.toDate)) } } + ) + } + + private var currentDateComponents: DateComponents { + calendar.dateComponents([.year, .month, .day], from: Date()) + } + + private var birthYearItems: [DSWheelPickerItem] { + (1900...(currentDateComponents.year ?? 1999)).map { + DSWheelPickerItem(value: $0, title: "\($0)년") + } + } + + private var birthMonthItems: [DSWheelPickerItem] { + let maximumMonth = pickerYear == currentDateComponents.year + ? currentDateComponents.month ?? 12 + : 12 + return (1...maximumMonth).map { + DSWheelPickerItem(value: $0, title: "\($0)월") + } + } + + private var birthDayItems: [DSWheelPickerItem] { + let date = calendar.date(from: DateComponents(year: pickerYear, month: pickerMonth)) ?? Date() + let daysInMonth = calendar.range(of: .day, in: .month, for: date)?.count ?? 28 + let maximumDay = pickerYear == currentDateComponents.year + && pickerMonth == currentDateComponents.month + ? min(daysInMonth, currentDateComponents.day ?? daysInMonth) + : daysInMonth + return (1...maximumDay).map { + DSWheelPickerItem(value: $0, title: "\($0)일") + } + } + + private var birthTimeItems: [DSWheelPickerItem] { + BirthTimePeriod.allCases.map { period in + DSWheelPickerItem( + value: period.rawValue, + title: DSEnterTimeOfBirth.pickerTitle(for: period) + ) + } + } + + private func prepareBirthDatePicker() { + let selected = store.birthDate.toBirthDate + pickerYear = selected.year + pickerMonth = selected.month + pickerDay = selected.day + normalizeBirthDatePicker() + } + + private func prepareBirthTimePicker() { + pickerBirthTimeRawValue = (store.birthTime ?? .inTime).rawValue + } + + private func saveBirthDate() { + let newBirthDate = BirthDate(year: pickerYear, month: pickerMonth, day: pickerDay) + store.send(.birthDateChanged(newBirthDate.toDate)) + isDatePickerPresented = false + } + + private func saveBirthTime() { + store.send(.birthTimeChanged(BirthTimePeriod(rawValue: pickerBirthTimeRawValue))) + isTimePickerPresented = false + } + + private func normalizeBirthDatePicker() { + if let maximumMonth = birthMonthItems.last?.value { + pickerMonth = min(pickerMonth, maximumMonth) + } + if let maximumDay = birthDayItems.last?.value { + pickerDay = min(pickerDay, maximumDay) + } + } +} + +// MARK: - 결과 화면 + +private struct CompatibilityResultContent: View { + @Bindable var store: StoreOf + + var body: some View { + if let result = store.result { + ZStack(alignment: .top) { + resultBackgroundImage + + VStack(spacing: 0) { + resultHeader + + ScrollView { + VStack(spacing: 20) { + resultHeaderSection(result: result) + + scoreAndSummaryCard(result: result) + + sajuStructureCard + + elementsCard(elements: result.elements) + + totalAnalysisCard(result: result) + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 40) + } + .scrollIndicators(.hidden) + } + } + .background(Color.fortuneSpaceBase) + .safeAreaInset(edge: .bottom, spacing: 0) { + FortuneTodakInquiryBar(tooltip: "오늘 이 사람과 어디를 갈까?") { + store.send(.todakTapped) + } + } + } + } + + private var resultHeader: some View { + HStack { + Button { + store.send(.resultBackTapped) + } label: { + DSIcon(.chevronLeftNarrow, width: 24, height: 24) + .foregroundStyle(Color.ds.white) + .frame(width: 44, height: 44) + } + .dsIconButtonStyle(.chevronLeftNarrow, width: 24, height: 24) + + Spacer() + + Text("상대방 궁합 결과") + .dsBody2SemiBold + .foregroundStyle(Color.ds.white) + + Spacer() + + Button { + store.send(.shareTapped) + } label: { + shareIconAsset + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundStyle(Color.ds.white) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("공유하기") + } + .padding(.horizontal, 10) + .frame(height: 52) + } + + private func resultHeaderSection(result: CompatibilityResult) -> some View { + VStack(spacing: 12) { + HStack(spacing: 8) { + Text(result.relationship.title) + .dsCaption1Medium + .foregroundStyle(Color.ds.white) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(Color.ds.primary600) + .clipShape(Capsule()) + + Text("\(result.partnerName)님과 나의") + .dsBody2Regular + .foregroundStyle(Color.ds.whiteOpacity80) + } + + Text(result.headline) + .dsHeading1Bold + .foregroundStyle(Color.ds.white) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + private func scoreAndSummaryCard(result: CompatibilityResult) -> some View { + VStack(spacing: 24) { + FortuneResultScoreRing(score: result.score, caption: "궁합 점수") + + VStack(alignment: .leading, spacing: 12) { + Text(result.subheadline) + .dsHeading2Bold + .foregroundStyle(Color.ds.white) + + Text(result.summary) + .dsBody3Regular + .foregroundStyle(Color.ds.whiteOpacity80) + .lineSpacing(6) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + .padding(24) + .background(Color.ds.whiteOpacity10) + .clipShape(RoundedRectangle(cornerRadius: 24)) + } + + @ViewBuilder + private var sajuStructureCard: some View { + if let mySaju = store.mySaju, let partnerSaju = store.selectedPartnerSaju { + VStack(alignment: .leading, spacing: 16) { + Text("사주 구조") + .dsBody1Bold + .foregroundStyle(Color.ds.white) + + HStack(alignment: .center, spacing: 8) { + Color.clear + .frame(width: 38) + + HStack(spacing: 8) { + ForEach(["시", "일", "월", "년"], id: \.self) { title in + Text(title) + .dsCaption1Regular + .foregroundStyle(Color.ds.whiteOpacity60) + .frame(maxWidth: .infinity) + } + } + } + + CompactSajuRow( + title: "나", + symbols: mySaju.pillars.sorted { $0.type.sortOrder < $1.type.sortOrder }.map(\.heavenlyStem) + ) + CompactSajuRow( + title: store.result?.partnerName ?? "토닥", + symbols: partnerSaju.pillars.sorted { $0.type.sortOrder < $1.type.sortOrder }.map(\.earthlyBranch) + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + .background(Color.ds.whiteOpacity10) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + } + + private func elementsCard(elements: [FortuneElementScore]) -> some View { + VStack(alignment: .leading, spacing: 16) { + Text("오행 시각화") + .dsBody1Bold + .foregroundStyle(Color.ds.white) + + VStack(spacing: 12) { + ForEach(elements) { elemScore in + HStack(spacing: 12) { + Text(elemScore.element.titleWithHanja) + .dsBody3Medium + .foregroundStyle(Color.ds.white) + .frame(width: 38, alignment: .leading) + + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.ds.whiteOpacity10) + + RoundedRectangle(cornerRadius: 4) + .fill( + LinearGradient( + colors: [Color.ds.primary300, Color.ds.primary400], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: max(0, geo.size.width * CGFloat(elemScore.percentage) / 100.0)) + } + } + .frame(height: 8) + + Text("\(elemScore.percentage)%") + .dsCaption1Medium + .foregroundStyle(Color.ds.whiteOpacity80) + .frame(width: 36, alignment: .trailing) + } + } + } + + Text("서로의 기운이 조화롭게 보완되는 균형 상태입니다.") + .dsBody3Regular + .foregroundStyle(Color.ds.whiteOpacity80) + .padding(.top, 4) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + .background(Color.ds.whiteOpacity10) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + + private func totalAnalysisCard(result: CompatibilityResult) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("궁합 총운 분석") + .dsBody1Bold + .foregroundStyle(Color.ds.white) + + Text(result.totalAnalysis) + .dsBody3Regular + .foregroundStyle(Color.ds.whiteOpacity80) + .lineSpacing(6) + + if !result.analysisBasis.isEmpty { + Divider() + .background(Color.ds.whiteOpacity10) + .padding(.vertical, 4) + + Text(result.analysisBasis) + .dsCaption1Regular + .foregroundStyle(Color.ds.whiteOpacity60) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + .background(Color.ds.whiteOpacity10) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + + private var resultBackgroundImage: some View { + GeometryReader { proxy in + FortuneSpaceBackground() + .frame(height: proxy.size.width / (393.0 / 850.0)) + } + .ignoresSafeArea(edges: .top) + } + + private var shareIconAsset: Image { + FortuneFeatureAsset.fortuneShare.swiftUIImage + } +} + +// MARK: - 공용 도우미 + +private extension FortuneElement { + var titleWithHanja: String { + switch self { + case .wood: "목 木" + case .fire: "화 火" + case .earth: "토 土" + case .metal: "금 金" + case .water: "수 水" + } + } +} + +private struct SajuChartCard: View { + let chart: SajuChartDetail + let fallbackName: String + let relationshipTitle: String? + var onEditTapped: (() -> Void)? = nil + + private var sortedPillars: [SajuPillar] { + chart.pillars.sorted { $0.type.sortOrder < $1.type.sortOrder } + } + + private var titleText: String { + let name = chart.name?.nonEmpty ?? fallbackName + if let gender = chart.gender { + return "\(name) · \(gender.displayTitle)" + } else { + return name + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(titleText) + .dsBody1Bold + .foregroundStyle(Color.ds.gray975) + + if let relationshipTitle { + Text(relationshipTitle) + .dsCaption1Medium + .foregroundStyle(Color.ds.primary600) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.ds.primary50) + .clipShape(Capsule()) + } + } + + Text(birthInformation) + .dsBody3Regular + .foregroundStyle(Color.ds.gray800) + } + + Spacer() + + if let onEditTapped { + Button(action: onEditTapped) { + Text("변경") + .dsCaption1SemiBold + .foregroundStyle(Color.ds.primary600) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Color.ds.white) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.ds.primary300, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + + Divider() + .foregroundStyle(Color.ds.gray200) + + HStack(alignment: .top, spacing: 12) { + ForEach(sortedPillars) { pillar in + SajuPillarColumn(pillar: pillar, isDark: false) + .frame(maxWidth: .infinity) + } + } + } + .padding(20) + .background(Color.ds.gray50) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + private var birthInformation: String { + let date = Self.dateFormatter.string(from: chart.birthDate) + let calendar = chart.calendarType.map { $0.displayTitle } ?? "양력" + let time: String + if chart.isBirthTimeUnknown { + time = "태어난 시각 모름" + } else if let birthTime = chart.birthTime { + time = birthTime.displayText + } else { + time = "" + } + return [date + " " + calendar, time].filter { !$0.isEmpty }.joined(separator: " · ") + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy.MM.dd" + return formatter + }() +} + +private struct CompactSajuRow: View { + let title: String + let symbols: [SajuSymbol] + + var body: some View { + HStack(alignment: .center, spacing: 8) { + Text(title) + .dsBody2SemiBold + .foregroundStyle(Color.ds.white) + .lineLimit(1) + .frame(width: 38, alignment: .leading) + + HStack(spacing: 8) { + ForEach(Array(symbols.enumerated()), id: \.offset) { _, symbol in + DSSajuPillarCell( + hanja: symbol.hanja, + reading: symbol.reading, + element: DSSajuElement.from(hanja: symbol.hanja) + ) + .frame(maxWidth: .infinity) + } + } + } + } +} + +private struct SajuPillarColumn: View { + let pillar: SajuPillar + let isDark: Bool + + var body: some View { + VStack(spacing: 6) { + Text(pillar.type.title) + .dsCaption3Medium + .foregroundStyle(isDark ? Color.ds.whiteOpacity60 : Color.ds.gray600) + + Text(pillar.type == .day ? (pillar.stemTenGod ?? "일원") : (pillar.stemTenGod ?? "-")) + .dsCaption3Regular + .foregroundStyle(isDark ? Color.ds.whiteOpacity80 : Color.ds.gray975) + .lineLimit(1) + .minimumScaleFactor(0.7) + + DSSajuPillarCell( + hanja: pillar.heavenlyStem.hanja, + reading: pillar.heavenlyStem.reading, + element: DSSajuElement.from(hanja: pillar.heavenlyStem.hanja) + ) + .frame(maxWidth: .infinity) + + DSSajuPillarCell( + hanja: pillar.earthlyBranch.hanja, + reading: pillar.earthlyBranch.reading, + element: DSSajuElement.from(hanja: pillar.earthlyBranch.hanja) + ) + .frame(maxWidth: .infinity) + + Text(pillar.branchTenGod) + .dsCaption3Regular + .foregroundStyle(isDark ? Color.ds.whiteOpacity80 : Color.ds.gray975) + .lineLimit(1) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.7) + + if let twelveLifeStage = pillar.twelveLifeStage { + Text(twelveLifeStage) + .dsCaption3Regular + .foregroundStyle(isDark ? Color.ds.whiteOpacity60 : Color.ds.gray700) + .lineLimit(1) + .multilineTextAlignment(.center) + .minimumScaleFactor(0.7) + } + } + } +} + +private struct FortuneHeaderView: View { + let title: String + let isDark: Bool + let action: () -> Void + + var body: some View { + HStack { + Button(action: action) { + DSIcon(.chevronLeftNarrow, width: 24, height: 24) + .foregroundStyle(isDark ? Color.ds.white : Color.ds.gray975) + .frame(width: 44, height: 44) + } + .dsIconButtonStyle(.chevronLeftNarrow, width: 24, height: 24) + + Spacer() + + Text(title) + .dsBody2SemiBold + .foregroundStyle(isDark ? Color.ds.white : Color.ds.gray975) + + Spacer() + + Color.clear.frame(width: 44, height: 44) + } + .padding(.horizontal, 10) + .frame(height: 52) + } +} + +private struct FortuneErrorBanner: View { + let message: String + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.ds.red500) + + Text(message) + .dsBody3Medium + .foregroundStyle(Color.ds.red500) + + Spacer() + } + .padding(12) + .background(Color.ds.red500.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} + +private extension Date { + var toBirthDate: BirthDate { + let calendar = Calendar.current + let year = calendar.component(.year, from: self) + let month = calendar.component(.month, from: self) + let day = calendar.component(.day, from: self) + return BirthDate(year: year, month: month, day: day) + } +} + +private extension BirthDate { + var toDate: Date { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + return Calendar.current.date(from: components) ?? Date() + } +} + +private extension Gender { + var displayTitle: String { + switch self { + case .male: "남성" + case .female: "여성" + } + } +} + +private extension BirthDateCalendar { + var displayTitle: String { + switch self { + case .solar: "양력" + case .lunar: "음력" + } + } +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneFeature.swift b/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneFeature.swift new file mode 100644 index 0000000..6fe5303 --- /dev/null +++ b/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneFeature.swift @@ -0,0 +1,160 @@ +import ComposableArchitecture +import Foundation + +@Reducer +public struct DayFortuneFeature { + @ObservableState + public struct State: Equatable, Sendable { + public var purpose: DayFortunePurpose = .travel + public var selectedDates: [Date] = [] + public var isCalendarPresented = false + public var isSubmitting = false + public var errorMessage: String? + public var showLimitToast = false + public var results: [DayFortuneResult] = [] + public var selectedResultID: UUID? + + public init() {} + + public var selectedResult: DayFortuneResult? { + results.first { $0.id == selectedResultID } ?? results.first + } + + public var canSubmit: Bool { + (1...5).contains(selectedDates.count) && !isSubmitting + } + } + + public enum Action: Equatable, Sendable { + case purposeSelected(DayFortunePurpose) + case calendarPresented(Bool) + case dateTapped(Date) + case resetDatesTapped + case datesConfirmed + case hideToast + case createTapped + case response(Result<[DayFortuneResult], FortuneClientError>) + case resultSelected(UUID) + case resultBackTapped + case shareTapped + case calendarExportTapped + case todakTapped + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case todakRequested + } + } + + private enum ToastCancelID { case toast } + + @Dependency(\.fortuneClient) private var fortuneClient + @Dependency(\.date.now) private var now + @Dependency(\.continuousClock) private var clock + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .purposeSelected(purpose): + state.purpose = purpose + return .none + + case let .calendarPresented(isPresented): + state.isCalendarPresented = isPresented + state.errorMessage = nil + state.showLimitToast = false + return .cancel(id: ToastCancelID.toast) + + case let .dateTapped(date): + let calendar = Calendar.current + let day = calendar.startOfDay(for: date) + guard day >= calendar.startOfDay(for: now) else { + return .none + } + + if let index = state.selectedDates.firstIndex(where: { calendar.isDate($0, inSameDayAs: day) }) { + state.selectedDates.remove(at: index) + state.errorMessage = nil + state.showLimitToast = false + return .cancel(id: ToastCancelID.toast) + } else if state.selectedDates.count >= 5 { + state.showLimitToast = true + state.errorMessage = nil + return .run { send in + try await clock.sleep(for: .seconds(2)) + await send(.hideToast) + } + .cancellable(id: ToastCancelID.toast, cancelInFlight: true) + } else { + state.selectedDates.append(day) + state.selectedDates.sort() + state.errorMessage = nil + state.showLimitToast = false + return .cancel(id: ToastCancelID.toast) + } + + case .resetDatesTapped: + state.selectedDates.removeAll() + state.errorMessage = nil + state.showLimitToast = false + return .cancel(id: ToastCancelID.toast) + + case .hideToast: + state.showLimitToast = false + return .none + + case .datesConfirmed: + guard !state.selectedDates.isEmpty else { + state.errorMessage = "후보 날짜를 1개 이상 선택해주세요." + return .none + } + state.isCalendarPresented = false + return .none + + case .createTapped: + guard state.canSubmit else { return .none } + state.isSubmitting = true + state.errorMessage = nil + let purpose = state.purpose + let dates = state.selectedDates + return .run { send in + do { + await send(.response(.success(try await fortuneClient.createDayFortunes(purpose, dates)))) + } catch let error as FortuneClientError { + await send(.response(.failure(error))) + } catch { + await send(.response(.failure(.transport))) + } + } + + case let .response(.success(results)): + state.isSubmitting = false + state.results = Array(results.sorted { $0.score > $1.score }.prefix(3)) + state.selectedResultID = state.results.first?.id + return .none + + case let .response(.failure(error)): + state.isSubmitting = false + state.errorMessage = error.userMessage + return .none + + case let .resultSelected(resultID): + state.selectedResultID = resultID + return .none + + case .resultBackTapped: + state.results = [] + state.selectedResultID = nil + return .none + + case .todakTapped: + return .send(.delegate(.todakRequested)) + + case .shareTapped, .calendarExportTapped, .delegate: + return .none + } + } + } +} diff --git a/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift b/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift new file mode 100644 index 0000000..cc5b6d1 --- /dev/null +++ b/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift @@ -0,0 +1,766 @@ +// swiftlint:disable file_length +import ComposableArchitecture +import DesignSystem +import SwiftUI + +struct DayFortuneView: View { + @Bindable var store: StoreOf + let backAction: () -> Void + + init(store: StoreOf, backAction: @escaping () -> Void) { + self.store = store + self.backAction = backAction + } + + var body: some View { + ZStack { + if store.results.isEmpty { + DayFortuneFormContent(store: store, backAction: backAction) + } else { + DayFortuneResultContent(store: store) + } + } + .toolbar(.hidden, for: .navigationBar) + .sheet( + isPresented: $store.isCalendarPresented.sending(\.calendarPresented) + ) { + DayFortuneCalendarSheet(store: store) + .presentationDetents([.fraction(0.92), .large]) + .presentationDragIndicator(.hidden) + .presentationCornerRadius(28) + } + } +} + +// MARK: - 입력 화면 + +private struct DayFortuneFormContent: View { + @Bindable var store: StoreOf + let backAction: () -> Void + + var body: some View { + ZStack { + Color.ds.white.ignoresSafeArea() + + VStack(spacing: 0) { + FortuneHeaderView(title: "택일 운세", isDark: false, action: backAction) + + ScrollView { + VStack(alignment: .leading, spacing: 32) { + formHeader + + purposeSection + + dateSelectionSection + + if let errorMessage = store.errorMessage, !store.isCalendarPresented { + FortuneErrorBanner(message: errorMessage) + } + } + .padding(.horizontal, 20) + .padding(.top, 24) + .padding(.bottom, 100) + } + .scrollIndicators(.hidden) + } + + if store.isSubmitting { + ZStack { + Color.black.opacity(0.4).ignoresSafeArea() + FortuneLoadingView() + } + } + } + .safeAreaInset(edge: .bottom) { + VStack(spacing: 0) { + DSButton("무료로 택일 보기") { + store.send(.createTapped) + } + .disabled(!store.canSubmit) + .opacity(store.canSubmit ? 1.0 : 0.4) + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 12) + } + .background( + LinearGradient( + colors: [ + Color.ds.white.opacity(0), + Color.ds.white.opacity(0.95), + Color.ds.white + ], + startPoint: .top, + endPoint: .bottom + ) + ) + } + } + + private var formHeader: some View { + Text("무슨 날을 정하시나요?") + .dsHeading3Bold + .foregroundStyle(Color.ds.gray975) + } + + private var purposeSection: some View { + DayFortuneFlowLayout(spacing: 12) { + ForEach(DayFortunePurpose.allCases, id: \.self) { purpose in + let isSelected = store.purpose == purpose + Button { + store.send(.purposeSelected(purpose)) + } label: { + Text(purpose.title) + .dsBody2SemiBold + .foregroundStyle(isSelected ? Color.ds.white : Color.ds.gray700) + .padding(.horizontal, 20) + .frame(height: 48) + .background(isSelected ? Color.ds.primary500 : Color.ds.gray100) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + } + } + + private var dateSelectionSection: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("후보 날짜") + .dsHeading3Bold + .foregroundStyle(Color.ds.gray975) + + Text("(5개까지 가능)") + .dsBody3Regular + .foregroundStyle(Color.ds.gray600) + + Spacer() + } + + Button { + store.send(.calendarPresented(true)) + } label: { + HStack { + if store.selectedDates.isEmpty { + Text("후보 날짜 선택") + .dsBody2Regular + .foregroundStyle(Color.ds.gray500) + } else { + Text(formattedDatesSummary) + .dsBody2Medium + .foregroundStyle(Color.ds.gray975) + .lineLimit(1) + } + + Spacer() + + DSIcon(.chevronSmallBottom, width: 20, height: 20) + .foregroundStyle(Color.ds.gray600) + } + .padding(.horizontal, 16) + .frame(height: 48) + .background(Color.ds.gray50) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + + if !store.selectedDates.isEmpty { + DayFortuneFlowLayout(spacing: 6) { + ForEach(store.selectedDates, id: \.self) { date in + HStack(spacing: 6) { + Text(DayFortuneDateFormatter.shortDate(date)) + .dsBody3Medium + .foregroundStyle(Color.ds.primary700) + + Button { + store.send(.dateTapped(date)) + } label: { + DSIcon(.closeLine, width: 12, height: 12) + .foregroundStyle(Color.ds.primary700) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.ds.primary50) + .clipShape(Capsule()) + } + } + } + } + } + + private var formattedDatesSummary: String { + store.selectedDates.map { DayFortuneDateFormatter.shortDate($0) }.joined(separator: ", ") + } +} + +// MARK: - 달력 시트 + +private struct DayFortuneCalendarSheet: View { + @Bindable var store: StoreOf + + var body: some View { + ZStack(alignment: .bottom) { + VStack(spacing: 0) { + // Drag Handle + Capsule() + .fill(Color.ds.gray300) + .frame(width: 36, height: 5) + .padding(.top, 12) + .padding(.bottom, 12) + + // Header + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text("후보 날짜 선택") + .dsBody1Bold + .foregroundStyle(Color.ds.gray975) + + Text("5개까지 날짜 선택 가능해요") + .dsCaption1Regular + .foregroundStyle(Color.ds.gray600) + } + + Spacer() + + Button { + store.send(.calendarPresented(false)) + } label: { + DSIcon(.closeLine, width: 24, height: 24) + .foregroundStyle(Color.ds.gray800) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.bottom, 16) + + // Scrollable Multi-Month Calendar + ScrollView(showsIndicators: false) { + CalendarPickerView( + selectedDates: store.selectedDates, + onDateTapped: { date in + store.send(.dateTapped(date)) + } + ) + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 100) + } + } + + // Bottom Action Bar + VStack(spacing: 0) { + HStack(spacing: 8) { + Button { + store.send(.resetDatesTapped) + } label: { + Text("초기화") + .dsBody2SemiBold + .foregroundStyle(store.selectedDates.isEmpty ? Color.ds.gray400 : Color.ds.primary600) + .frame(width: 96, height: 52) + .background(store.selectedDates.isEmpty ? Color.ds.gray100 : Color.ds.primary50) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + .disabled(store.selectedDates.isEmpty) + + DSButton("선택 완료") { + store.send(.datesConfirmed) + } + .disabled(store.selectedDates.isEmpty) + .opacity(store.selectedDates.isEmpty ? 0.4 : 1.0) + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 16) + } + .background( + LinearGradient( + colors: [ + Color.ds.white.opacity(0), + Color.ds.white.opacity(0.95), + Color.ds.white + ], + startPoint: .top, + endPoint: .bottom + ) + ) + + if store.showLimitToast { + DSToast(compact: "후보 날짜는 5개까지만 선택 가능합니다.") + .padding(.bottom, 84) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.2), value: store.showLimitToast) + } +} + +// MARK: - 결과 화면 + +private struct DayFortuneResultContent: View { + @Bindable var store: StoreOf + + var body: some View { + ZStack(alignment: .top) { + Color.fortuneSpaceBase.ignoresSafeArea() + + resultBackgroundImage + + VStack(spacing: 0) { + resultHeader + + ScrollView { + VStack(spacing: 20) { + resultIntroduction + + if store.results.count > 1 { + candidateSelectorTabs + } + + if let selectedResult = store.selectedResult { + resultCard(selectedResult) + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 32) + } + .scrollIndicators(.hidden) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + FortuneTodakInquiryBar(tooltip: "시간대는 언제가 좋아?") { + store.send(.todakTapped) + } + } + } + + private var resultHeader: some View { + HStack(spacing: 4) { + Button { + store.send(.resultBackTapped) + } label: { + DSIcon(.chevronLeftNarrow, width: 24, height: 24) + .foregroundStyle(Color.ds.white) + .frame(width: 44, height: 44) + } + .dsIconButtonStyle(.chevronLeftNarrow, width: 24, height: 24) + + Spacer() + + Text("택일 운세 결과") + .dsBody2SemiBold + .foregroundStyle(Color.ds.white) + + Spacer() + + Button { + store.send(.calendarExportTapped) + } label: { + calendarExportIconAsset + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundStyle(Color.ds.white) + .frame(width: 36, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("캘린더 내보내기") + + Button { + store.send(.shareTapped) + } label: { + shareIconAsset + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundStyle(Color.ds.white) + .frame(width: 36, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("공유하기") + } + .padding(.horizontal, 10) + .frame(height: 52) + } + + @ViewBuilder + private var resultIntroduction: some View { + if let selectedResult = store.selectedResult { + VStack(spacing: 14) { + Text(selectedResult.purpose.title) + .dsBody3Medium + .foregroundStyle(Color.ds.white) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Color.ds.primary600) + .clipShape(Capsule()) + + Text("선택하신 후보 일자 중\n최고의 기운을 찾아봤어요") + .dsHeading2Bold + .foregroundStyle(Color.ds.white) + .multilineTextAlignment(.center) + } + } + } + + private var candidateSelectorTabs: some View { + HStack(spacing: 8) { + ForEach(store.results) { result in + let isSelected = result.id == store.selectedResultID + Button { + store.send(.resultSelected(result.id)) + } label: { + Text(DayFortuneDateFormatter.tabDate(result.targetDate)) + .dsBody2Medium + .foregroundStyle(isSelected ? Color.ds.white : Color.ds.whiteOpacity80) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity) + .frame(height: 54) + .background(isSelected ? Color.ds.primary600 : Color.ds.whiteOpacity05) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke( + isSelected ? Color.ds.primary300 : Color.ds.whiteOpacity30, + lineWidth: 1 + ) + } + } + .buttonStyle(.plain) + } + } + } + + private func resultCard(_ result: DayFortuneResult) -> some View { + VStack(spacing: 16) { + mainScoreCard(result) + + summaryAnalysisCard(result) + } + } + + private func mainScoreCard(_ result: DayFortuneResult) -> some View { + VStack(spacing: 24) { + FortuneResultScoreRing(score: result.score, caption: "운세 점수") + + Text(result.title) + .dsHeading2Bold + .foregroundStyle(Color.ds.white) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + + if !result.categories.isEmpty { + categoryStarsRow(result.categories) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 24) + .padding(.horizontal, 20) + .background(Color.ds.whiteOpacity10) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + private func categoryStarsRow(_ categories: [FortuneCategoryStar]) -> some View { + HStack(alignment: .top, spacing: 0) { + ForEach(categories.prefix(3)) { catStar in + VStack(spacing: 6) { + Text(catStar.category.title) + .dsCaption1Regular + .foregroundStyle(Color.ds.whiteOpacity60) + + HStack(spacing: 4) { + let filledCount = min(3, max(1, catStar.star > 3 ? Int(round(Double(catStar.star) * 3.0 / 5.0)) : catStar.star)) + ForEach(1...3, id: \.self) { starIndex in + let isFilled = starIndex <= filledCount + if isFilled { + Image(systemName: "star.fill") + .font(.system(size: 13)) + .foregroundStyle(Color.ds.primary300) + } else { + Image(systemName: "star.fill") + .font(.system(size: 13)) + .foregroundStyle(Color.ds.whiteOpacity05) + .overlay { + Image(systemName: "star") + .font(.system(size: 13)) + .foregroundStyle(Color.ds.whiteOpacity30) + } + } + } + } + } + .frame(maxWidth: .infinity) + } + } + .padding(.top, 4) + } + + private func summaryAnalysisCard(_ result: DayFortuneResult) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text("요약 분석") + .dsBody1Bold + .foregroundStyle(Color.ds.white) + + Text(result.content) + .dsBody3Regular + .foregroundStyle(Color.ds.whiteOpacity80) + .lineSpacing(6) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + .background(Color.ds.whiteOpacity10) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + private var resultBackgroundImage: some View { + GeometryReader { proxy in + FortuneSpaceBackground() + .frame(height: proxy.size.width / (393.0 / 850.0)) + } + .ignoresSafeArea(edges: .top) + } + + private var shareIconAsset: Image { + FortuneFeatureAsset.fortuneShare.swiftUIImage + } + + private var calendarExportIconAsset: Image { + FortuneFeatureAsset.fortuneCalendarExport.swiftUIImage + } +} + +// MARK: - 공용 뷰와 도우미 + +private struct FortuneHeaderView: View { + let title: String + let isDark: Bool + let action: () -> Void + + var body: some View { + HStack { + Button(action: action) { + DSIcon(.chevronLeftNarrow, width: 24, height: 24) + .foregroundStyle(isDark ? Color.ds.white : Color.ds.gray975) + .frame(width: 44, height: 44) + } + .dsIconButtonStyle(.chevronLeftNarrow, width: 24, height: 24) + + Spacer() + + Text(title) + .dsBody2SemiBold + .foregroundStyle(isDark ? Color.ds.white : Color.ds.gray975) + + Spacer() + + Color.clear.frame(width: 44, height: 44) + } + .padding(.horizontal, 10) + .frame(height: 52) + } +} + +private struct FortuneErrorBanner: View { + let message: String + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.ds.red500) + + Text(message) + .dsBody3Medium + .foregroundStyle(Color.ds.red500) + + Spacer() + } + .padding(12) + .background(Color.ds.red500.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} + +private enum DayFortuneDateFormatter { + static func shortDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "M월 d일" + return formatter.string(from: date) + } + + static func tabDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "M.d(E)" + return formatter.string(from: date) + } + + static func fullDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy년 M월 d일 (E)" + return formatter.string(from: date) + } +} + +private struct CalendarPickerView: View { + let selectedDates: [Date] + let onDateTapped: (Date) -> Void + + private let calendar = Calendar.current + private let weekdays = ["일", "월", "화", "수", "목", "금", "토"] + + private var months: [Date] { + let startOfCurrentMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: Date())) ?? Date() + return (0..<12).compactMap { offset in + calendar.date(byAdding: .month, value: offset, to: startOfCurrentMonth) + } + } + + var body: some View { + LazyVStack(spacing: 36) { + ForEach(months, id: \.self) { month in + monthSection(for: month) + } + } + } + + private func monthSection(for month: Date) -> some View { + VStack(spacing: 16) { + Text(monthTitle(for: month)) + .dsBody1Bold + .foregroundStyle(Color.ds.gray975) + .frame(maxWidth: .infinity, alignment: .center) + + HStack { + ForEach(weekdays, id: \.self) { day in + Text(day) + .dsCaption1Medium + .foregroundStyle(Color.ds.gray500) + .frame(maxWidth: .infinity) + } + } + + let days = daysInMonth(for: month) + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: 8) { + ForEach(0.. some View { + let isSelected = selectedDates.contains { calendar.isDate($0, inSameDayAs: date) } + let isToday = calendar.isDateInToday(date) + let isPast = calendar.startOfDay(for: date) < calendar.startOfDay(for: Date()) + + Button { + onDateTapped(date) + } label: { + VStack(spacing: 1) { + Text("\(calendar.component(.day, from: date))") + .dsBody2Medium + .foregroundStyle( + isSelected ? Color.ds.white : (isPast ? Color.ds.gray300 : (isToday ? Color.ds.primary600 : Color.ds.gray975)) + ) + + if isToday { + Text("오늘") + .dsCaption3Medium + .foregroundStyle(isSelected ? Color.ds.white : Color.ds.primary600) + } + } + .frame(width: 38, height: 38) + .background( + isSelected ? Color.ds.primary600 : Color.clear + ) + .clipShape(Circle()) + } + .disabled(isPast) + .buttonStyle(.plain) + } + + private func monthTitle(for date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy.MM" + return formatter.string(from: date) + } + + private func daysInMonth(for month: Date) -> [Date?] { + guard let firstDay = calendar.date(from: calendar.dateComponents([.year, .month], from: month)) else { + return [] + } + + let firstWeekday = calendar.component(.weekday, from: firstDay) - 1 + let numberOfDays = calendar.range(of: .day, in: .month, for: month)?.count ?? 0 + + var days: [Date?] = Array(repeating: nil, count: firstWeekday) + for day in 0.. CGSize { + let width = proposal.width ?? 0 + var currentX: CGFloat = 0 + var currentY: CGFloat = 0 + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if currentX > 0, currentX + size.width > width { + currentX = 0 + currentY += rowHeight + spacing + rowHeight = 0 + } + currentX += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + return CGSize(width: width, height: currentY + rowHeight) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + var currentX = bounds.minX + var currentY = bounds.minY + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if currentX > bounds.minX, currentX + size.width > bounds.maxX { + currentX = bounds.minX + currentY += rowHeight + spacing + rowHeight = 0 + } + subview.place(at: CGPoint(x: currentX, y: currentY), proposal: ProposedViewSize(size)) + currentX += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} diff --git a/Projects/Feature/FortuneFeature/Sources/Domain/FortuneHomeContent.swift b/Projects/Feature/FortuneFeature/Sources/Domain/FortuneHomeContent.swift index 62ce459..b15646f 100644 --- a/Projects/Feature/FortuneFeature/Sources/Domain/FortuneHomeContent.swift +++ b/Projects/Feature/FortuneFeature/Sources/Domain/FortuneHomeContent.swift @@ -32,10 +32,16 @@ public struct FortuneHomeContent: Equatable, Sendable { } public struct FortuneCategoryScore: Equatable, Sendable { + public let luckActionID: UUID public let category: FortuneCategory public let score: Int - public init(category: FortuneCategory, score: Int) { + public init( + luckActionID: UUID, + category: FortuneCategory, + score: Int + ) { + self.luckActionID = luckActionID self.category = category self.score = score } diff --git a/Projects/Feature/FortuneFeature/Sources/Domain/FortuneSajuModels.swift b/Projects/Feature/FortuneFeature/Sources/Domain/FortuneSajuModels.swift new file mode 100644 index 0000000..ac1efbf --- /dev/null +++ b/Projects/Feature/FortuneFeature/Sources/Domain/FortuneSajuModels.swift @@ -0,0 +1,358 @@ +// 도메인 결과 타입은 프로토콜 필수 프로퍼티 이름이 `id`인 Identifiable을 준수한다. +// swiftlint:disable identifier_name + +import Foundation +import Model + +public struct LuckActionDetail: Equatable, Sendable { + public let id: UUID + public let category: FortuneCategory + public let score: Int + public let title: String + public let content: String + public let isAchieved: Bool + + public init( + id: UUID, + category: FortuneCategory, + score: Int, + title: String, + content: String, + isAchieved: Bool + ) { + self.id = id + self.category = category + self.score = score + self.title = title + self.content = content + self.isAchieved = isAchieved + } +} + +public struct FortunePartner: Equatable, Identifiable, Sendable { + public let id: UUID + public let name: String + public let relationship: FortuneRelationship + public let gender: Gender? + public let birthDate: Date? + public let calendarType: BirthDateCalendar? + public let birthTime: BirthTimePeriod? + public let isBirthTimeUnknown: Bool + + public init( + id: UUID, + name: String, + relationship: FortuneRelationship, + gender: Gender? = nil, + birthDate: Date? = nil, + calendarType: BirthDateCalendar? = nil, + birthTime: BirthTimePeriod? = nil, + isBirthTimeUnknown: Bool = false + ) { + self.id = id + self.name = name + self.relationship = relationship + self.gender = gender + self.birthDate = birthDate + self.calendarType = calendarType + self.birthTime = birthTime + self.isBirthTimeUnknown = isBirthTimeUnknown + } +} + +public enum FortuneRelationship: String, Equatable, Sendable { + case partner + case friend + case colleague + case family + case other + + public var title: String { + switch self { + case .partner: "연인" + case .friend: "친구" + case .colleague: "동료" + case .family: "가족" + case .other: "기타" + } + } +} + +public struct PartnerRegistrationInput: Equatable, Sendable { + public let name: String + public let gender: Gender + public let calendarType: BirthDateCalendar + public let birthDate: Date + public let birthTime: BirthTimePeriod? + public let isBirthTimeUnknown: Bool + public let relationship: Relationship + + public init( + name: String, + gender: Gender, + calendarType: BirthDateCalendar, + birthDate: Date, + birthTime: BirthTimePeriod?, + isBirthTimeUnknown: Bool, + relationship: Relationship + ) { + self.name = name + self.gender = gender + self.calendarType = calendarType + self.birthDate = birthDate + self.birthTime = birthTime + self.isBirthTimeUnknown = isBirthTimeUnknown + self.relationship = relationship + } +} + +public struct SajuChartDetail: Equatable, Identifiable, Sendable { + public let id: UUID + public let name: String? + public let gender: Gender? + public let birthDate: Date + public let calendarType: BirthDateCalendar? + public let birthTime: BirthTimePeriod? + public let isBirthTimeUnknown: Bool + public let pillars: [SajuPillar] + + public init( + id: UUID, + name: String?, + gender: Gender?, + birthDate: Date, + calendarType: BirthDateCalendar?, + birthTime: BirthTimePeriod?, + isBirthTimeUnknown: Bool, + pillars: [SajuPillar] + ) { + self.id = id + self.name = name + self.gender = gender + self.birthDate = birthDate + self.calendarType = calendarType + self.birthTime = birthTime + self.isBirthTimeUnknown = isBirthTimeUnknown + self.pillars = pillars + } +} + +public struct SajuPillar: Equatable, Identifiable, Sendable { + public var id: SajuPillarType { type } + public let type: SajuPillarType + public let heavenlyStem: SajuSymbol + public let earthlyBranch: SajuSymbol + public let stemTenGod: String? + public let branchTenGod: String + public let twelveLifeStage: String? + + public init( + type: SajuPillarType, + heavenlyStem: SajuSymbol, + earthlyBranch: SajuSymbol, + stemTenGod: String?, + branchTenGod: String, + twelveLifeStage: String? = nil + ) { + self.type = type + self.heavenlyStem = heavenlyStem + self.earthlyBranch = earthlyBranch + self.stemTenGod = stemTenGod + self.branchTenGod = branchTenGod + self.twelveLifeStage = twelveLifeStage + } +} + +public enum SajuPillarType: String, Equatable, Sendable { + case hour = "HOUR" + case day = "DAY" + case month = "MONTH" + case year = "YEAR" + + public var title: String { + switch self { + case .hour: "시주" + case .day: "일주" + case .month: "월주" + case .year: "년주" + } + } + + public var sortOrder: Int { + switch self { + case .hour: 0 + case .day: 1 + case .month: 2 + case .year: 3 + } + } +} + +public struct SajuSymbol: Equatable, Sendable { + public let hanja: String + public let reading: String + public let elementLabel: String + public let elementHanja: String + + public init( + hanja: String, + reading: String, + elementLabel: String, + elementHanja: String + ) { + self.hanja = hanja + self.reading = reading + self.elementLabel = elementLabel + self.elementHanja = elementHanja + } +} + +public enum FortuneElement: String, CaseIterable, Equatable, Sendable { + case wood + case fire + case earth + case metal + case water + + public var title: String { + switch self { + case .wood: "목" + case .fire: "화" + case .earth: "토" + case .metal: "금" + case .water: "수" + } + } +} + +public struct FortuneElementScore: Equatable, Identifiable, Sendable { + public var id: FortuneElement { element } + public let element: FortuneElement + public let percentage: Int + + public init(element: FortuneElement, percentage: Int) { + self.element = element + self.percentage = percentage + } +} + +public struct CompatibilityResult: Equatable, Identifiable, Sendable { + public let id: UUID + public let partnerName: String + public let relationship: FortuneRelationship + public let score: Int + public let headline: String + public let subheadline: String + public let summary: String + public let totalAnalysis: String + public let analysisBasis: String + public let elements: [FortuneElementScore] + + public init( + id: UUID, + partnerName: String, + relationship: FortuneRelationship, + score: Int, + headline: String, + subheadline: String, + summary: String, + totalAnalysis: String, + analysisBasis: String, + elements: [FortuneElementScore] + ) { + self.id = id + self.partnerName = partnerName + self.relationship = relationship + self.score = score + self.headline = headline + self.subheadline = subheadline + self.summary = summary + self.totalAnalysis = totalAnalysis + self.analysisBasis = analysisBasis + self.elements = elements + } +} + +public enum DayFortunePurpose: String, CaseIterable, Equatable, Sendable { + case contractMoving = "CONTRACT_MOVING" + case businessOpening = "BUSINESS_OPENING" + case travel = "TRAVEL" + case confessionDating = "CONFESSION_DATING" + case examInterview = "EXAM_INTERVIEW" + + public var title: String { + switch self { + case .contractMoving: "계약 ∙ 이사" + case .businessOpening: "개업" + case .travel: "여행" + case .confessionDating: "고백 ∙ 소개팅" + case .examInterview: "시험 ∙ 면접" + } + } +} + +public struct FortuneCategoryStar: Equatable, Identifiable, Sendable { + public var id: FortuneCategory { category } + public let category: FortuneCategory + public let star: Int + + public init(category: FortuneCategory, star: Int) { + self.category = category + self.star = star + } +} + +public struct DayFortuneResult: Equatable, Identifiable, Sendable { + public let id: UUID + public let purpose: DayFortunePurpose + public let targetDate: Date + public let score: Int + public let title: String + public let content: String + public let categories: [FortuneCategoryStar] + + public init( + id: UUID, + purpose: DayFortunePurpose, + targetDate: Date, + score: Int, + title: String, + content: String, + categories: [FortuneCategoryStar] + ) { + self.id = id + self.purpose = purpose + self.targetDate = targetDate + self.score = score + self.title = title + self.content = content + self.categories = categories + } +} + +public struct YearFortuneResult: Equatable, Identifiable, Sendable { + public let id: UUID + public let year: Int + public let score: Int + public let title: String + public let content: String + public let categories: [FortuneCategoryStar] + + public init( + id: UUID, + year: Int, + score: Int, + title: String, + content: String, + categories: [FortuneCategoryStar] + ) { + self.id = id + self.year = year + self.score = score + self.title = title + self.content = content + self.categories = categories + } +} + +// swiftlint:enable identifier_name diff --git a/Projects/Feature/FortuneFeature/Sources/FortuneClient+Live.swift b/Projects/Feature/FortuneFeature/Sources/FortuneClient+Live.swift index 9c8b26b..66d312b 100644 --- a/Projects/Feature/FortuneFeature/Sources/FortuneClient+Live.swift +++ b/Projects/Feature/FortuneFeature/Sources/FortuneClient+Live.swift @@ -1,4 +1,8 @@ +// OpenAPI 전송 DTO는 `id`처럼 서버에서 정의한 필드 이름을 그대로 유지한다. +// swiftlint:disable file_length identifier_name cyclomatic_complexity + import Foundation +import Model import NetworkCore public extension FortuneClient { @@ -9,6 +13,34 @@ public extension FortuneClient { }, fetchDetail: { dailyFortuneID in try await performFetchDetail(httpClient: httpClient, id: dailyFortuneID) + }, + fetchLuckAction: { luckActionID in + try await performFetchLuckAction(httpClient: httpClient, id: luckActionID) + }, + fetchMySaju: { + try await performFetchMySaju(httpClient: httpClient) + }, + fetchPartners: { + try await performFetchPartners(httpClient: httpClient) + }, + fetchPartnerSaju: { partnerID in + try await performFetchPartnerSaju(httpClient: httpClient, partnerID: partnerID) + }, + registerPartner: { input in + try await performRegisterPartner(httpClient: httpClient, input: input) + }, + createCompatibility: { partnerID, partnerName in + try await performCreateCompatibility( + httpClient: httpClient, + partnerID: partnerID, + partnerName: partnerName + ) + }, + createDayFortunes: { purpose, dates in + try await performCreateDayFortunes(httpClient: httpClient, purpose: purpose, dates: dates) + }, + createYearFortune: { year in + try await performCreateYearFortune(httpClient: httpClient, year: year) } ) } @@ -40,6 +72,113 @@ private func performFetchDetail(httpClient: HTTPClient, id dailyFortuneID: UUID) } } +private func performFetchLuckAction(httpClient: HTTPClient, id: UUID) async throws -> LuckActionDetail { + try await performRequest { + let endpoint = Endpoint.get("/api/v1/luck-actions/\(id.uuidString.lowercased())") + let response: CommonResponseDTO = try await httpClient.request(endpoint) + return try validateAndExtractData(response).toDomain() + } +} + +private func performFetchPartners(httpClient: HTTPClient) async throws -> [FortunePartner] { + try await performRequest { + let endpoint = Endpoint.get("/api/v1/saju/partners") + let response: CommonResponseDTO<[PartnerSajuSummaryResponseDTO]> = try await httpClient.request(endpoint) + return try validateAndExtractData(response).map { try $0.toDomain() } + } +} + +private func performFetchMySaju(httpClient: HTTPClient) async throws -> SajuChartDetail { + try await performRequest { + let endpoint = Endpoint.get("/api/v1/saju/me") + let response: CommonResponseDTO = try await httpClient.request(endpoint) + return try validateAndExtractData(response).toDomain() + } +} + +private func performFetchPartnerSaju( + httpClient: HTTPClient, + partnerID: UUID +) async throws -> SajuChartDetail { + try await performRequest { + let endpoint = Endpoint.get("/api/v1/saju/partners/\(partnerID.uuidString.lowercased())") + let response: CommonResponseDTO = try await httpClient.request(endpoint) + return try validateAndExtractData(response).toDomain() + } +} + +private func performRegisterPartner( + httpClient: HTTPClient, + input: PartnerRegistrationInput +) async throws -> UUID { + try await performRequest { + let endpoint = try Endpoint.post( + "/api/v1/saju/partners", + body: RegisterPartnerSajuRequestDTO(input: input) + ) + let response: CommonResponseDTO = try await httpClient.request(endpoint) + return try validateAndExtractData(response).linkID + } +} + +private func performCreateCompatibility( + httpClient: HTTPClient, + partnerID: UUID, + partnerName: String +) async throws -> CompatibilityResult { + try await performRequest { + let endpoint = Endpoint( + method: .post, + path: "/api/v1/compatibilities/\(partnerID.uuidString.lowercased())" + ) + let response: CommonResponseDTO = try await httpClient.request(endpoint) + return try validateAndExtractData(response).toDomain(fallbackPartnerName: partnerName) + } +} + +private func performCreateDayFortunes( + httpClient: HTTPClient, + purpose: DayFortunePurpose, + dates: [Date] +) async throws -> [DayFortuneResult] { + guard (1...5).contains(dates.count) else { + throw FortuneClientError.invalidResponse + } + + return try await performRequest { + let body = CreateDaySelectionFortuneRequestDTO( + purpose: purpose.rawValue, + targetDates: dates.map(formatFortuneDate) + ) + let endpoint = try Endpoint.post("/api/v1/day-fortunes", body: body) + let response: CommonResponseDTO<[DaySelectionFortuneResponseDTO]> = try await httpClient.request(endpoint) + return try validateAndExtractData(response).map { try $0.toDomain() } + } +} + +private func performCreateYearFortune( + httpClient: HTTPClient, + year: Int +) async throws -> YearFortuneResult { + try await performRequest { + let endpoint = Endpoint(method: .post, path: "/api/v1/year-fortunes/\(year)") + let response: CommonResponseDTO = try await httpClient.request(endpoint) + return try validateAndExtractData(response).toDomain() + } +} + +private func performRequest( + _ operation: () async throws -> Value +) async throws -> Value { + do { + return try await operation() + } catch is CancellationError { + throw CancellationError() + } catch { + throw mapHTTPClientError(error) + } +} + private func validateAndExtractData(_ response: CommonResponseDTO) throws -> T { guard response.success else { if let code = response.code { @@ -100,7 +239,11 @@ private struct TodayFortuneResponseDTO: Decodable { } let mappedCategoryScores = try luckActionScores.map { scoreDTO in let category = try mapCategory(scoreDTO.fortuneCategory) - return FortuneCategoryScore(category: category, score: scoreDTO.score) + return FortuneCategoryScore( + luckActionID: scoreDTO.id, + category: category, + score: scoreDTO.score + ) } return FortuneHomeContent( dailyFortuneID: dailyFortuneID, @@ -140,7 +283,11 @@ private struct DailyFortuneResponseDTO: Decodable { } let mappedCategoryScores = try luckActionScores.map { scoreDTO in let category = try mapCategory(scoreDTO.fortuneCategory) - return FortuneCategoryScore(category: category, score: scoreDTO.score) + return FortuneCategoryScore( + luckActionID: scoreDTO.id, + category: category, + score: scoreDTO.score + ) } return FortuneDetailContent( dailyFortuneID: dailyFortuneID, @@ -156,19 +303,317 @@ private struct DailyFortuneResponseDTO: Decodable { } private struct LuckActionScoreResponseDTO: Decodable { + let id: UUID let fortuneCategory: String let score: Int } +private struct LuckActionResponseDTO: Decodable { + let id: UUID + let fortuneCategory: String + let score: Int + let title: String + let content: String + let achieved: Bool + + func toDomain() throws -> LuckActionDetail { + LuckActionDetail( + id: id, + category: try mapCategory(fortuneCategory), + score: score, + title: title, + content: content, + isAchieved: achieved + ) + } +} + +private struct PartnerSajuSummaryResponseDTO: Decodable { + let linkID: UUID + let relationshipType: CodeLabelResponseDTO? + let name: String? + let gender: String + let birthDate: String + let calendarType: String + let birthTime: String + let isTimeUnknown: Bool + + enum CodingKeys: String, CodingKey { + case linkID = "linkId" + case relationshipType + case name + case gender + case birthDate + case calendarType + case birthTime + case isTimeUnknown + } + + func toDomain() throws -> FortunePartner { + let resolvedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName: String + if let resolvedName, !resolvedName.isEmpty { + displayName = resolvedName + } else { + displayName = "상대방" + } + let relationship = try relationshipType.map { try mapRelationship($0.code) } ?? .other + + return FortunePartner( + id: linkID, + name: displayName, + relationship: relationship, + gender: mapGender(gender), + birthDate: parseFortuneDate(birthDate), + calendarType: mapCalendarType(calendarType), + birthTime: mapBirthTime(birthTime), + isBirthTimeUnknown: isTimeUnknown + ) + } +} + +private struct CodeLabelResponseDTO: Decodable { + let code: String + let label: String +} + +private struct SajuChartDetailResponseDTO: Decodable { + let linkID: UUID + let name: String? + let gender: String + let birthDate: String + let calendarType: String + let birthTime: String + let isTimeUnknown: Bool + let pillars: [PillarResponseDTO] + + enum CodingKeys: String, CodingKey { + case linkID = "linkId" + case name + case gender + case birthDate + case calendarType + case birthTime + case isTimeUnknown + case pillars + } + + func toDomain() throws -> SajuChartDetail { + guard let mappedBirthDate = parseFortuneDate(birthDate) else { + throw FortuneClientError.invalidResponse + } + + return SajuChartDetail( + id: linkID, + name: name, + gender: mapGender(gender), + birthDate: mappedBirthDate, + calendarType: mapCalendarType(calendarType), + birthTime: mapBirthTime(birthTime), + isBirthTimeUnknown: isTimeUnknown, + pillars: try pillars.map { try $0.toDomain() }.sorted { $0.type.sortOrder < $1.type.sortOrder } + ) + } +} + +private struct PillarResponseDTO: Decodable { + let pillarType: String + let heavenlyStem: SymbolResponseDTO + let earthlyBranch: SymbolResponseDTO + let stemSipseong: CodeLabelResponseDTO? + let branchSipseong: CodeLabelResponseDTO + let sibiunseong: CodeLabelResponseDTO? + + func toDomain() throws -> SajuPillar { + guard let type = SajuPillarType(rawValue: pillarType) else { + throw FortuneClientError.invalidResponse + } + + return SajuPillar( + type: type, + heavenlyStem: heavenlyStem.toDomain(), + earthlyBranch: earthlyBranch.toDomain(), + stemTenGod: stemSipseong?.label, + branchTenGod: branchSipseong.label, + twelveLifeStage: sibiunseong?.label + ) + } +} + +private struct SymbolResponseDTO: Decodable { + let hanja: String + let reading: String + let element: ElementResponseDTO + + func toDomain() -> SajuSymbol { + SajuSymbol( + hanja: hanja, + reading: reading, + elementLabel: element.label, + elementHanja: element.hanja + ) + } +} + +private struct RegisterPartnerSajuRequestDTO: Encodable { + let name: String + let gender: String + let calendarType: String + let birthDate: String + let birthTime: String + let isTimeUnknown: Bool + let relationshipType: String + + init(input: PartnerRegistrationInput) { + name = input.name + gender = input.gender.serverValue + calendarType = input.calendarType.serverValue + birthDate = formatFortuneDate(input.birthDate) + isTimeUnknown = input.isBirthTimeUnknown + birthTime = input.isBirthTimeUnknown ? "12:00:00" : (input.birthTime?.serverValue ?? "12:00:00") + relationshipType = input.relationship.serverValue + } +} + +private struct RegisterPartnerSajuResponseDTO: Decodable { + let linkID: UUID + + enum CodingKeys: String, CodingKey { + case linkID = "linkId" + } +} + +private struct CompatibilityResponseDTO: Decodable { + let id: UUID + let partnerName: String? + let relationshipType: CodeLabelResponseDTO + let score: Int + let headline: String + let subheadline: String + let summary: String + let totalAnalysis: String + let analysisBasis: String + let ohaengs: [CompatibilityOhaengResponseDTO] + + func toDomain(fallbackPartnerName: String) throws -> CompatibilityResult { + return CompatibilityResult( + id: id, + partnerName: partnerName.flatMap { $0.isEmpty ? nil : $0 } ?? fallbackPartnerName, + relationship: try mapRelationship(relationshipType.code), + score: score, + headline: headline, + subheadline: subheadline, + summary: summary, + totalAnalysis: totalAnalysis, + analysisBasis: analysisBasis, + elements: try ohaengs.map { try $0.toDomain() } + ) + } +} + +private struct CompatibilityOhaengResponseDTO: Decodable { + let element: ElementResponseDTO + let percentage: Int + + func toDomain() throws -> FortuneElementScore { + FortuneElementScore( + element: try mapElement(element.code), + percentage: percentage + ) + } +} + +private struct ElementResponseDTO: Decodable { + let code: String + let label: String + let hanja: String +} + +private struct CreateDaySelectionFortuneRequestDTO: Encodable { + let purpose: String + let targetDates: [String] +} + +private struct DaySelectionFortuneResponseDTO: Decodable { + let id: UUID + let purpose: String + let targetDate: String + let score: Int + let title: String + let content: String + let fortuneCategories: [FortuneCategoryStarResponseDTO] + + func toDomain() throws -> DayFortuneResult { + guard + let mappedPurpose = DayFortunePurpose(rawValue: purpose), + let mappedDate = parseFortuneDate(targetDate) + else { + throw FortuneClientError.invalidResponse + } + + return DayFortuneResult( + id: id, + purpose: mappedPurpose, + targetDate: mappedDate, + score: score, + title: title, + content: content, + categories: try fortuneCategories.map { try $0.toDomain() } + ) + } +} + +private struct YearSelectionFortuneResponseDTO: Decodable { + let id: UUID + let year: Int + let score: Int + let title: String + let content: String + let fortuneCategories: [FortuneCategoryStarResponseDTO] + + func toDomain() throws -> YearFortuneResult { + YearFortuneResult( + id: id, + year: year, + score: score, + title: title, + content: content, + categories: try fortuneCategories.map { try $0.toDomain() } + ) + } +} + +private struct FortuneCategoryStarResponseDTO: Decodable { + let fortuneCategory: String + let star: Int + + func toDomain() throws -> FortuneCategoryStar { + FortuneCategoryStar( + category: try mapCategory(fortuneCategory), + star: star + ) + } +} + private func parseFortuneDate(_ string: String) -> Date? { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) + // API 값은 시간대가 없는 달력 날짜다. 사용자 시간대로 파싱해 + // 화면 표시와 요청 포맷이 같은 날짜를 유지하도록 한다. + formatter.timeZone = .current formatter.isLenient = false return formatter.date(from: string) } +private func formatFortuneDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + return formatter.string(from: date) +} + private func mapCategory(_ categoryString: String) throws -> FortuneCategory { switch categoryString { case "RELATIONSHIP": @@ -185,3 +630,108 @@ private func mapCategory(_ categoryString: String) throws -> FortuneCategory { throw FortuneClientError.unsupportedCategory(categoryString) } } + +private func mapRelationship(_ value: String) throws -> FortuneRelationship { + switch value { + case "LOVER": .partner + case "FRIEND": .friend + case "COWORKER": .colleague + case "FAMILY": .family + case "ETC": .other + default: throw FortuneClientError.invalidResponse + } +} + +private func mapGender(_ value: String) -> Gender? { + switch value { + case "MALE": .male + case "FEMALE": .female + default: nil + } +} + +private func mapCalendarType(_ value: String) -> BirthDateCalendar? { + switch value { + case "SOLAR": .solar + case "LUNAR": .lunar + default: nil + } +} + +private func mapBirthTime(_ value: String) -> BirthTimePeriod? { + switch value { + case "JASI": .jaTime + case "CHUKSI": .chukTime + case "INSI": .inTime + case "MYOSI": .myoTime + case "JINSI": .jinTime + case "SASI": .saTime + case "OSI": .oTime + case "MISI": .miTime + case "SINSI": .sinTime + case "YUSI": .yuTime + case "SULSI": .sulTime + case "HAESI": .haeTime + default: nil + } +} + +private func mapElement(_ value: String) throws -> FortuneElement { + switch value.uppercased() { + case "WOOD", "MOK": .wood + case "FIRE", "HWA": .fire + case "EARTH", "TO": .earth + case "METAL", "GEUM": .metal + case "WATER", "SU": .water + default: throw FortuneClientError.invalidResponse + } +} + +private extension Gender { + var serverValue: String { + switch self { + case .male: "MALE" + case .female: "FEMALE" + } + } +} + +private extension BirthDateCalendar { + var serverValue: String { + switch self { + case .solar: "SOLAR" + case .lunar: "LUNAR" + } + } +} + +private extension Relationship { + var serverValue: String { + switch self { + case .partner: "LOVER" + case .friend: "FRIEND" + case .colleague: "COWORKER" + } + } +} + +private extension BirthTimePeriod { + var serverValue: String { + switch self { + case .jaTime: "JASI" + case .chukTime: "CHUKSI" + case .inTime: "INSI" + case .myoTime: "MYOSI" + case .jinTime: "JINSI" + case .saTime: "SASI" + case .oTime: "OSI" + case .miTime: "MISI" + case .sinTime: "SINSI" + case .yuTime: "YUSI" + case .sulTime: "SULSI" + case .haeTime: "HAESI" + } + } +} + +// swiftlint:enable file_length identifier_name cyclomatic_complexity diff --git a/Projects/Feature/FortuneFeature/Sources/FortuneClient.swift b/Projects/Feature/FortuneFeature/Sources/FortuneClient.swift index 1cab7c9..c3ca4d2 100644 --- a/Projects/Feature/FortuneFeature/Sources/FortuneClient.swift +++ b/Projects/Feature/FortuneFeature/Sources/FortuneClient.swift @@ -4,13 +4,37 @@ import Foundation public struct FortuneClient: Sendable { public var fetchToday: @Sendable () async throws -> FortuneHomeContent public var fetchDetail: @Sendable (UUID) async throws -> FortuneDetailContent + public var fetchLuckAction: @Sendable (UUID) async throws -> LuckActionDetail + public var fetchMySaju: @Sendable () async throws -> SajuChartDetail + public var fetchPartners: @Sendable () async throws -> [FortunePartner] + public var fetchPartnerSaju: @Sendable (UUID) async throws -> SajuChartDetail + public var registerPartner: @Sendable (PartnerRegistrationInput) async throws -> UUID + public var createCompatibility: @Sendable (UUID, String) async throws -> CompatibilityResult + public var createDayFortunes: @Sendable (DayFortunePurpose, [Date]) async throws -> [DayFortuneResult] + public var createYearFortune: @Sendable (Int) async throws -> YearFortuneResult public init( fetchToday: @escaping @Sendable () async throws -> FortuneHomeContent, - fetchDetail: @escaping @Sendable (UUID) async throws -> FortuneDetailContent + fetchDetail: @escaping @Sendable (UUID) async throws -> FortuneDetailContent, + fetchLuckAction: @escaping @Sendable (UUID) async throws -> LuckActionDetail, + fetchMySaju: @escaping @Sendable () async throws -> SajuChartDetail, + fetchPartners: @escaping @Sendable () async throws -> [FortunePartner], + fetchPartnerSaju: @escaping @Sendable (UUID) async throws -> SajuChartDetail, + registerPartner: @escaping @Sendable (PartnerRegistrationInput) async throws -> UUID, + createCompatibility: @escaping @Sendable (UUID, String) async throws -> CompatibilityResult, + createDayFortunes: @escaping @Sendable (DayFortunePurpose, [Date]) async throws -> [DayFortuneResult], + createYearFortune: @escaping @Sendable (Int) async throws -> YearFortuneResult ) { self.fetchToday = fetchToday self.fetchDetail = fetchDetail + self.fetchLuckAction = fetchLuckAction + self.fetchMySaju = fetchMySaju + self.fetchPartners = fetchPartners + self.fetchPartnerSaju = fetchPartnerSaju + self.registerPartner = registerPartner + self.createCompatibility = createCompatibility + self.createDayFortunes = createDayFortunes + self.createYearFortune = createYearFortune } } @@ -42,6 +66,30 @@ public extension FortuneClient { }, fetchDetail: { _ in throw FortuneClientError.notConfigured + }, + fetchLuckAction: { _ in + throw FortuneClientError.notConfigured + }, + fetchMySaju: { + throw FortuneClientError.notConfigured + }, + fetchPartners: { + throw FortuneClientError.notConfigured + }, + fetchPartnerSaju: { _ in + throw FortuneClientError.notConfigured + }, + registerPartner: { _ in + throw FortuneClientError.notConfigured + }, + createCompatibility: { _, _ in + throw FortuneClientError.notConfigured + }, + createDayFortunes: { _, _ in + throw FortuneClientError.notConfigured + }, + createYearFortune: { _ in + throw FortuneClientError.notConfigured } ) } diff --git a/Projects/Feature/FortuneFeature/Sources/FortuneFeature.swift b/Projects/Feature/FortuneFeature/Sources/FortuneFeature.swift index b2cbca4..c86e286 100644 --- a/Projects/Feature/FortuneFeature/Sources/FortuneFeature.swift +++ b/Projects/Feature/FortuneFeature/Sources/FortuneFeature.swift @@ -3,10 +3,18 @@ import Foundation @Reducer public struct FortuneFeature { + @Reducer + public enum Path { + case report(FortuneReportFeature) + case compatibility(CompatibilityFeature) + case dayFortune(DayFortuneFeature) + case yearFortune(YearFortuneFeature) + } + public init() {} @ObservableState - public struct State: Equatable, Sendable { + public struct State: Equatable { public enum ViewState: Equatable, Sendable { case loading case loaded(FortuneHomeContent) @@ -15,6 +23,8 @@ public struct FortuneFeature { public var viewState: ViewState public var isRefreshing: Bool + public var path = StackState() + @Presents public var categoryDetail: FortuneCategoryDetailFeature.State? public init( viewState: ViewState = .loading, @@ -23,12 +33,18 @@ public struct FortuneFeature { self.viewState = viewState self.isRefreshing = isRefreshing } + + public var isShowingDetail: Bool { + !path.isEmpty + } } - public enum Action: Equatable, Sendable { + public enum Action: Equatable { case view(ViewAction) case todayFortuneResponse(Result) + case path(StackActionOf) case delegate(Delegate) + case categoryDetail(PresentationAction) public enum ViewAction: Equatable, Sendable { case task @@ -43,13 +59,9 @@ public struct FortuneFeature { } public enum Delegate: Equatable, Sendable { - case notificationRequested - case fortuneReportRequested( - dailyFortuneID: UUID, - selectedCategory: FortuneCategory? - ) - case fortuneReadingRequested(FortuneReading) + case todakRequested case luckyActionRequested + case myPageRequested } } @@ -99,24 +111,58 @@ public struct FortuneFeature { } case .view(.notificationButtonTapped): - return .send(.delegate(.notificationRequested)) + return .none case .view(.fortuneReportButtonTapped): - return routeToFortuneReport(state: state, selectedCategory: nil) + routeToFortuneReport(state: &state, selectedCategory: nil) + return .none case let .view(.fortuneCategoryTapped(category)): - return routeToFortuneReport(state: state, selectedCategory: category) + if case let .loaded(content) = state.viewState, + let item = content.categoryScores.first(where: { $0.category == category }) { + state.categoryDetail = .init( + luckActionID: item.luckActionID, + category: item.category, + categoryScores: content.categoryScores + ) + } + return .none case let .view(.fortuneReadingTapped(reading)): - return .send(.delegate(.fortuneReadingRequested(reading))) + switch reading { + case .compatibility: + state.path.append(.compatibility(.init())) + case .dateSelection: + state.path.append(.dayFortune(.init())) + case .yearly: + state.path.append(.yearFortune(.init())) + } + return .none case .view(.luckyActionBannerTapped): return .send(.delegate(.luckyActionRequested)) - case .delegate: + case .path(.element(id: _, action: .report(.delegate(.todakRequested)))), + .path(.element(id: _, action: .compatibility(.delegate(.todakRequested)))), + .path(.element(id: _, action: .dayFortune(.delegate(.todakRequested)))), + .path(.element(id: _, action: .yearFortune(.delegate(.todakRequested)))): + return .send(.delegate(.todakRequested)) + + case .path(.element(id: _, action: .report(.delegate(.luckyActionRequested)))), + .categoryDetail(.presented(.delegate(.luckyActionRequested))): + return .send(.delegate(.luckyActionRequested)) + + case .path(.element(id: _, action: .compatibility(.delegate(.myInfoEditRequested)))): + return .send(.delegate(.myPageRequested)) + + case .path, .delegate, .categoryDetail: return .none } } + .forEach(\.path, action: \.path) + .ifLet(\.$categoryDetail, action: \.categoryDetail) { + FortuneCategoryDetailFeature() + } } private func fetchTodayFortuneEffect() -> Effect { @@ -125,7 +171,7 @@ public struct FortuneFeature { let content = try await fortuneClient.fetchToday() await send(.todayFortuneResponse(.success(content))) } catch is CancellationError { - // Task cancelled: do not convert cancellation into failure UI + // 취소된 작업은 실패 UI로 전환하지 않는다. } catch let error as FortuneClientError { await send(.todayFortuneResponse(.failure(error))) } catch { @@ -136,16 +182,16 @@ public struct FortuneFeature { } private func routeToFortuneReport( - state: State, + state: inout State, selectedCategory: FortuneCategory? - ) -> Effect { + ) { guard case let .loaded(content) = state.viewState else { - return .none + return } - return .send( - .delegate( - .fortuneReportRequested( + state.path.append( + .report( + .init( dailyFortuneID: content.dailyFortuneID, selectedCategory: selectedCategory ) @@ -166,3 +212,6 @@ public struct FortuneFeature { } } } + +extension FortuneFeature.Path.State: Equatable {} +extension FortuneFeature.Path.Action: Equatable {} diff --git a/Projects/Feature/FortuneFeature/Sources/FortuneView.swift b/Projects/Feature/FortuneFeature/Sources/FortuneView.swift index 86eb411..c9df995 100644 --- a/Projects/Feature/FortuneFeature/Sources/FortuneView.swift +++ b/Projects/Feature/FortuneFeature/Sources/FortuneView.swift @@ -3,36 +3,60 @@ import DesignSystem import SwiftUI public struct FortuneView: View { - private let store: StoreOf + @Bindable private var store: StoreOf public init(store: StoreOf) { self.store = store } public var body: some View { - ZStack { - rootBackgroundColor.ignoresSafeArea() - - switch store.viewState { - case .loading: - FortuneLoadingView() - - case let .loaded(content): - FortuneHomeView( - content: content, - action: { store.send(.view($0)) }, - onRefresh: { - await store.send(.view(.refresh)).finish() - } - ) - - case let .failed(message): - FortuneFailureView( - message: message, - retryAction: { - store.send(.view(.retryButtonTapped)) - } - ) + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + ZStack { + rootBackgroundColor.ignoresSafeArea() + + switch store.viewState { + case .loading: + FortuneLoadingView() + + case let .loaded(content): + FortuneHomeView( + content: content, + action: { store.send(.view($0)) }, + onRefresh: { + await store.send(.view(.refresh)).finish() + } + ) + + case let .failed(message): + FortuneFailureView( + message: message, + retryAction: { + store.send(.view(.retryButtonTapped)) + } + ) + } + } + } destination: { pathStore in + switch pathStore.case { + case let .report(store): + FortuneReportView(store: store) { + popLastDestination() + } + + case let .compatibility(store): + CompatibilityView(store: store) { + popLastDestination() + } + + case let .dayFortune(store): + DayFortuneView(store: store) { + popLastDestination() + } + + case let .yearFortune(store): + YearFortuneView(store: store) { + popLastDestination() + } } } .task { @@ -41,6 +65,14 @@ public struct FortuneView: View { .onDisappear { store.send(.view(.requestCancelled)) } + .sheet( + item: $store.scope(state: \.categoryDetail, action: \.categoryDetail) + ) { categoryStore in + FortuneCategoryDetailView(store: categoryStore) + .presentationDetents([.fraction(0.8)]) + .presentationDragIndicator(.hidden) + .presentationCornerRadius(28) + } } private var rootBackgroundColor: Color { @@ -51,4 +83,9 @@ public struct FortuneView: View { return Color.ds.black } } + + private func popLastDestination() { + guard let destinationID = store.path.ids.last else { return } + store.send(.path(.popFrom(id: destinationID))) + } } diff --git a/Projects/Feature/FortuneFeature/Sources/Views/FortuneHomeView.swift b/Projects/Feature/FortuneFeature/Sources/Views/FortuneHomeView.swift index 755d63e..ff5228b 100644 --- a/Projects/Feature/FortuneFeature/Sources/Views/FortuneHomeView.swift +++ b/Projects/Feature/FortuneFeature/Sources/Views/FortuneHomeView.swift @@ -48,7 +48,7 @@ struct FortuneHomeView: View { .padding(.top, 28) } .padding(.top, 36) - .padding(.bottom, 40) + .padding(.bottom, 100) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.ds.white) .clipShape( @@ -100,7 +100,7 @@ struct FortuneHomeView: View { } } -private struct FortuneSpaceBackground: View { +struct FortuneSpaceBackground: View { private static let sourceAspectRatio = CGFloat(393.0 / 850.0) var body: some View { @@ -122,7 +122,7 @@ private struct FortuneSpaceBackground: View { } } -private extension Color { +extension Color { static let fortuneSpaceBase = Color( red: 0, green: 1.0 / 255.0, diff --git a/Projects/Feature/FortuneFeature/Sources/Views/FortuneResultComponents.swift b/Projects/Feature/FortuneFeature/Sources/Views/FortuneResultComponents.swift new file mode 100644 index 0000000..20202fd --- /dev/null +++ b/Projects/Feature/FortuneFeature/Sources/Views/FortuneResultComponents.swift @@ -0,0 +1,67 @@ +import DesignSystem +import SwiftUI + +struct FortuneResultScoreRing: View { + let score: Int + let caption: String + + var body: some View { + ZStack { + Circle() + .stroke(Color.ds.whiteOpacity10, lineWidth: 14) + + Circle() + .trim(from: 0, to: CGFloat(max(0, min(score, 100))) / 100) + .stroke( + LinearGradient( + colors: [Color.ds.primary200, Color.ds.primary400], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + style: StrokeStyle(lineWidth: 14, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + + VStack(spacing: 4) { + Text("\(score)") + .font(.system(size: 44, weight: .bold)) + .foregroundStyle(Color.ds.white) + + Text(caption) + .dsBody3Medium + .foregroundStyle(Color.ds.whiteOpacity60) + } + } + .frame(width: 172, height: 172) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(caption) \(score)점") + } +} + +struct FortuneTodakInquiryBar: View { + let tooltip: String + let action: () -> Void + + var body: some View { + VStack(spacing: -6) { + DSTooltip(tooltip, variant: .standard) + .zIndex(1) + + DSPrimaryLargeButton("토닥이에게 더 물어보기", action: action) + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 8) + .background( + LinearGradient( + colors: [ + Color.fortuneSpaceBase.opacity(0), + Color.fortuneSpaceBase.opacity(0.85), + Color.fortuneSpaceBase + ], + startPoint: .top, + endPoint: .bottom + ) + ) + } +} diff --git a/Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneFeature.swift b/Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneFeature.swift new file mode 100644 index 0000000..8b154ad --- /dev/null +++ b/Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneFeature.swift @@ -0,0 +1,80 @@ +import ComposableArchitecture +import Foundation + +@Reducer +public struct YearFortuneFeature { + @ObservableState + public struct State: Equatable, Sendable { + public var selectedYear: Int + public var isSubmitting = false + public var errorMessage: String? + public var result: YearFortuneResult? + + public init(selectedYear: Int = Calendar.current.component(.year, from: Date())) { + self.selectedYear = selectedYear + } + } + + public enum Action: Equatable, Sendable { + case yearSelected(Int) + case createTapped + case response(Result) + case resultBackTapped + case shareTapped + case todakTapped + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case todakRequested + } + } + + @Dependency(\.fortuneClient) private var fortuneClient + + public init() {} + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .yearSelected(year): + state.selectedYear = year + return .none + + case .createTapped: + guard !state.isSubmitting else { return .none } + state.isSubmitting = true + state.errorMessage = nil + let year = state.selectedYear + return .run { send in + do { + await send(.response(.success(try await fortuneClient.createYearFortune(year)))) + } catch let error as FortuneClientError { + await send(.response(.failure(error))) + } catch { + await send(.response(.failure(.transport))) + } + } + + case let .response(.success(result)): + state.isSubmitting = false + state.result = result + return .none + + case let .response(.failure(error)): + state.isSubmitting = false + state.errorMessage = error.userMessage + return .none + + case .resultBackTapped: + state.result = nil + return .none + + case .todakTapped: + return .send(.delegate(.todakRequested)) + + case .shareTapped, .delegate: + return .none + } + } + } +} diff --git a/Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneView.swift b/Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneView.swift new file mode 100644 index 0000000..cdbb587 --- /dev/null +++ b/Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneView.swift @@ -0,0 +1,343 @@ +import ComposableArchitecture +import DesignSystem +import SwiftUI + +struct YearFortuneView: View { + @Bindable var store: StoreOf + let backAction: () -> Void + + init(store: StoreOf, backAction: @escaping () -> Void) { + self.store = store + self.backAction = backAction + } + + var body: some View { + ZStack { + if let result = store.result { + YearFortuneResultContent(store: store, result: result) + } else { + YearFortuneFormContent(store: store, backAction: backAction) + } + } + .toolbar(.hidden, for: .navigationBar) + } +} + +// MARK: - 입력 화면 + +private struct YearFortuneFormContent: View { + @Bindable var store: StoreOf + let backAction: () -> Void + + var body: some View { + ZStack { + Color.ds.white.ignoresSafeArea() + + VStack(spacing: 0) { + YearFortuneHeaderView(title: "연도별 운세", isDark: false, action: backAction) + + ScrollView { + VStack(alignment: .leading, spacing: 28) { + formHeader + + yearSelectionSection + + if let errorMessage = store.errorMessage { + YearFortuneErrorBanner(message: errorMessage) + } + + submitButton + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 40) + } + .scrollIndicators(.hidden) + } + + if store.isSubmitting { + ZStack { + Color.black.opacity(0.4).ignoresSafeArea() + FortuneLoadingView() + } + } + } + } + + private var formHeader: some View { + VStack(alignment: .leading, spacing: 8) { + Text("운세를 확인할 연도를 선택해 주세요") + .dsHeading3Bold + .foregroundStyle(Color.ds.gray975) + + Text("신년 운세와 한 해 동안의 종합적인 기운을 미리 살펴보세요.") + .dsBody3Regular + .foregroundStyle(Color.ds.gray700) + } + } + + private var yearSelectionSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("연도 선택") + .dsBody2SemiBold + .foregroundStyle(Color.ds.gray975) + + let currentYear = Calendar.current.component(.year, from: Date()) + let candidateYears = Array((currentYear - 5)...(currentYear + 5)) + + LazyVGrid( + columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], + spacing: 10 + ) { + ForEach(candidateYears, id: \.self) { year in + let isSelected = store.selectedYear == year + Button { + store.send(.yearSelected(year)) + } label: { + Text("\(String(year))년") + .dsBody3Medium + .foregroundStyle(isSelected ? Color.ds.white : Color.ds.gray800) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(isSelected ? Color.ds.primary600 : Color.ds.gray100) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay { + if isSelected { + RoundedRectangle(cornerRadius: 14) + .stroke(Color.ds.primary300, lineWidth: 1) + } + } + } + .buttonStyle(.plain) + } + } + } + } + + private var submitButton: some View { + DSButton("운세 확인하기") { + store.send(.createTapped) + } + .disabled(store.isSubmitting) + .opacity(store.isSubmitting ? 0.4 : 1.0) + .padding(.top, 12) + } +} + +// MARK: - 결과 화면 + +private struct YearFortuneResultContent: View { + @Bindable var store: StoreOf + let result: YearFortuneResult + + var body: some View { + ZStack(alignment: .top) { + Color.fortuneSpaceBase.ignoresSafeArea() + + resultBackgroundImage + + VStack(spacing: 0) { + resultHeader + + ScrollView { + VStack(spacing: 24) { + resultCard + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 32) + } + .scrollIndicators(.hidden) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + FortuneTodakInquiryBar(tooltip: "시간대는 언제가 좋아?") { + store.send(.todakTapped) + } + } + } + + private var resultHeader: some View { + HStack { + Button { + store.send(.resultBackTapped) + } label: { + DSIcon(.chevronLeftNarrow, width: 24, height: 24) + .foregroundStyle(Color.ds.white) + .frame(width: 44, height: 44) + } + .dsIconButtonStyle(.chevronLeftNarrow, width: 24, height: 24) + + Spacer() + + Text("연도별 운세 결과") + .dsBody2SemiBold + .foregroundStyle(Color.ds.white) + + Spacer() + + Button { + store.send(.shareTapped) + } label: { + shareIconAsset + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .foregroundStyle(Color.ds.white) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("공유하기") + } + .padding(.horizontal, 10) + .frame(height: 52) + } + + private var resultCard: some View { + VStack(spacing: 24) { + VStack(spacing: 12) { + Text("\(String(result.year))년 운세") + .dsBody3Medium + .foregroundStyle(Color.ds.white) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Color.ds.primary600) + .clipShape(Capsule()) + + Text("\(String(result.year))년의 운세") + .dsHeading2Bold + .foregroundStyle(Color.ds.white) + } + + VStack(spacing: 12) { + FortuneResultScoreRing(score: result.score, caption: "연간 종합 점수") + + Text(result.title) + .dsBody1Bold + .foregroundStyle(Color.ds.white) + .multilineTextAlignment(.center) + .padding(.top, 4) + + if !result.categories.isEmpty { + categoryHighlights + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .padding(.horizontal, 16) + .background(Color.ds.whiteOpacity10) + .clipShape(RoundedRectangle(cornerRadius: 24)) + + summaryCard + } + } + + private var categoryHighlights: some View { + HStack(alignment: .top, spacing: 12) { + ForEach(result.categories.prefix(3)) { category in + VStack(spacing: 6) { + Text(category.category.title) + .dsCaption1Regular + .foregroundStyle(Color.ds.whiteOpacity60) + + HStack(spacing: 2) { + ForEach(1...5, id: \.self) { starIndex in + Image(systemName: starIndex <= category.star ? "star.fill" : "star") + .font(.system(size: 11)) + .foregroundStyle( + starIndex <= category.star + ? Color.ds.primary300 + : Color.ds.whiteOpacity20 + ) + } + } + } + .frame(maxWidth: .infinity) + } + } + .padding(.top, 12) + } + + private var summaryCard: some View { + VStack(alignment: .leading, spacing: 12) { + Text("종합 총평") + .dsBody1Bold + .foregroundStyle(Color.ds.white) + + Text(result.content) + .dsBody3Regular + .foregroundStyle(Color.ds.whiteOpacity80) + .lineSpacing(6) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + .background(Color.ds.whiteOpacity10) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + + private var resultBackgroundImage: some View { + GeometryReader { proxy in + FortuneSpaceBackground() + .frame(height: proxy.size.width / (393.0 / 850.0)) + } + .ignoresSafeArea(edges: .top) + } + + private var shareIconAsset: Image { + FortuneFeatureAsset.fortuneShare.swiftUIImage + } +} + +// MARK: - 공용 뷰 + +private struct YearFortuneHeaderView: View { + let title: String + let isDark: Bool + let action: () -> Void + + var body: some View { + HStack { + Button(action: action) { + DSIcon(.chevronLeftNarrow, width: 24, height: 24) + .foregroundStyle(isDark ? Color.ds.white : Color.ds.gray975) + .frame(width: 44, height: 44) + } + .dsIconButtonStyle(.chevronLeftNarrow, width: 24, height: 24) + + Spacer() + + Text(title) + .dsBody2SemiBold + .foregroundStyle(isDark ? Color.ds.white : Color.ds.gray975) + + Spacer() + + Color.clear.frame(width: 44, height: 44) + } + .padding(.horizontal, 10) + .frame(height: 52) + } +} + +private struct YearFortuneErrorBanner: View { + let message: String + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.ds.red500) + + Text(message) + .dsBody3Medium + .foregroundStyle(Color.ds.red500) + + Spacer() + } + .padding(12) + .background(Color.ds.red500.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneClientTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneClientTests.swift index fbbb9cb..e1f454f 100644 --- a/Projects/Feature/FortuneFeature/Tests/FortuneClientTests.swift +++ b/Projects/Feature/FortuneFeature/Tests/FortuneClientTests.swift @@ -1,5 +1,9 @@ import ComposableArchitecture +// 공용 전송 fixture를 함께 사용하므로 통합 형태의 클라이언트 계약 테스트를 한 파일에 둔다. +// swiftlint:disable file_length type_body_length + import Foundation +import Model import NetworkCore import Testing @testable import FortuneFeature @@ -18,8 +22,8 @@ struct FortuneClientTests { "score": 65.4, "title": "좋은 하루", "luckActionScores": [ - { "fortuneCategory": "RELATIONSHIP", "score": 90 }, - { "fortuneCategory": "LOVE", "score": 80 } + { "id": "00000000-0000-0000-0000-000000000001", "fortuneCategory": "RELATIONSHIP", "score": 90 }, + { "id": "00000000-0000-0000-0000-000000000002", "fortuneCategory": "LOVE", "score": 80 } ] } } @@ -42,6 +46,7 @@ struct FortuneClientTests { #expect(fortune.title == "좋은 하루") #expect(fortune.scoreDescription == nil) #expect(fortune.categoryScores.count == 2) + #expect(fortune.categoryScores[0].luckActionID == UUID(uuidString: "00000000-0000-0000-0000-000000000001")) #expect(fortune.categoryScores[0].category == .relationship) #expect(fortune.categoryScores[0].score == 90) #expect(fortune.categoryScores[1].category == .love) @@ -65,7 +70,7 @@ struct FortuneClientTests { "luckyItems": ["행운의 열쇠"], "cautionaryItems": ["지각"], "luckActionScores": [ - { "fortuneCategory": "MONEY", "score": 100 } + { "id": "00000000-0000-0000-0000-000000000003", "fortuneCategory": "MONEY", "score": 100 } ] } } @@ -179,7 +184,11 @@ struct FortuneClientTests { "score": 88, "title": "좋은 하루", "luckActionScores": [ - { "fortuneCategory": "UNKNOWN_CATEGORY_XYZ", "score": 70 } + { + "id": "00000000-0000-0000-0000-000000000004", + "fortuneCategory": "UNKNOWN_CATEGORY_XYZ", + "score": 70 + } ] } } @@ -221,6 +230,337 @@ struct FortuneClientTests { try await client.fetchToday() } } + + @Test("행운 액션 단건 응답을 카테고리 상세 도메인으로 매핑한다") + func testFetchLuckActionMapping() async throws { + let luckActionID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")! + let client = makeClient { request in + #expect(request.url?.path == "/api/v1/luck-actions/\(luckActionID.uuidString.lowercased())") + let json = """ + { + "success": true, + "code": "200", + "data": { + "id": "33333333-3333-3333-3333-333333333333", + "fortuneCategory": "LOVE", + "score": 84, + "title": "마음을 전해보세요", + "content": "솔직한 한마디가 관계를 가까이 만들어요.", + "achieved": false + } + } + """ + return (Data(json.utf8), try makeHTTPResponse(url: request.url!, statusCode: 200)) + } + + let detail = try await client.fetchLuckAction(luckActionID) + #expect(detail.id == luckActionID) + #expect(detail.category == .love) + #expect(detail.title == "마음을 전해보세요") + } + + @Test("상대방 목록의 코드 객체와 생년 정보를 매핑한다") + func testFetchPartnersMapping() async throws { + let client = makeClient { request in + #expect(request.url?.path == "/api/v1/saju/partners") + let json = """ + { + "success": true, + "code": "200", + "data": [{ + "linkId": "44444444-4444-4444-4444-444444444444", + "relationshipType": { "code": "LOVER", "label": "연인" }, + "name": "토실이", + "gender": "FEMALE", + "birthDate": "1999-02-13", + "calendarType": "SOLAR", + "birthTime": "SINSI", + "isTimeUnknown": false + }] + } + """ + return (Data(json.utf8), try makeHTTPResponse(url: request.url!, statusCode: 200)) + } + + let partners = try await client.fetchPartners() + #expect(partners.count == 1) + #expect(partners[0].name == "토실이") + #expect(partners[0].relationship == .partner) + #expect(partners[0].birthTime == .sinTime) + } + + @Test("상대방 목록의 선택 필드가 누락되어도 목록 전체를 실패시키지 않는다") + func testFetchPartnersMapsOptionalSummaryFields() async throws { + let client = makeClient { request in + let json = """ + { + "success": true, + "code": "200", + "data": [{ + "linkId": "45454545-4545-4545-4545-454545454545", + "gender": "MALE", + "birthDate": "2000-01-01", + "calendarType": "LUNAR", + "birthTime": "UNKNOWN", + "isTimeUnknown": true + }] + } + """ + return (Data(json.utf8), try makeHTTPResponse(url: request.url!, statusCode: 200)) + } + + let partner = try #require(try await client.fetchPartners().first) + #expect(partner.name == "상대방") + #expect(partner.relationship == .other) + #expect(partner.isBirthTimeUnknown) + } + + @Test("내 사주 상세의 사주 기둥과 오행 정보를 매핑한다") + func testFetchMySajuMapping() async throws { + let client = makeClient { request in + #expect(request.url?.path == "/api/v1/saju/me") + let json = Self.sajuDetailJSON( + linkID: "10101010-1010-1010-1010-101010101010", + name: "토닥이" + ) + return (Data(json.utf8), try makeHTTPResponse(url: request.url!, statusCode: 200)) + } + + let chart = try await client.fetchMySaju() + #expect(chart.name == "토닥이") + #expect(chart.gender == .female) + #expect(chart.calendarType == .solar) + #expect(chart.birthTime == .oTime) + #expect(chart.pillars.map(\.type) == [.hour, .day, .month, .year]) + #expect(chart.pillars[0].heavenlyStem.hanja == "辛") + #expect(chart.pillars[0].heavenlyStem.elementLabel == "금") + #expect(chart.pillars[0].stemTenGod == "식신") + #expect(chart.pillars[0].branchTenGod == "비견") + } + + @Test("선택한 상대방 사주 상세 경로와 nullable 이름을 매핑한다") + func testFetchPartnerSajuMapping() async throws { + let partnerID = UUID(uuidString: "20202020-2020-2020-2020-202020202020")! + let client = makeClient { request in + #expect(request.url?.path == "/api/v1/saju/partners/\(partnerID.uuidString.lowercased())") + let json = Self.sajuDetailJSON(linkID: partnerID.uuidString, name: nil) + return (Data(json.utf8), try makeHTTPResponse(url: request.url!, statusCode: 200)) + } + + let chart = try await client.fetchPartnerSaju(partnerID) + #expect(chart.id == partnerID) + #expect(chart.name == nil) + #expect(chart.pillars.count == 4) + } + + @Test("상대방 등록 요청을 서버 enum과 날짜 형식으로 인코딩한다") + func testRegisterPartnerRequest() async throws { + let responseID = UUID(uuidString: "55555555-5555-5555-5555-555555555555")! + let client = makeClient { request in + #expect(request.httpMethod == "POST") + #expect(request.url?.path == "/api/v1/saju/partners") + let body = try #require(request.httpBody) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(json["gender"] as? String == "MALE") + #expect(json["calendarType"] as? String == "LUNAR") + #expect(json["birthTime"] as? String == "JASI") + #expect(json["relationshipType"] as? String == "COWORKER") + #expect(json["birthDate"] as? String == "2000-01-01") + + let response = """ + { "success": true, "code": "200", "data": { "linkId": "55555555-5555-5555-5555-555555555555" } } + """ + return (Data(response.utf8), try makeHTTPResponse(url: request.url!, statusCode: 200)) + } + + let input = PartnerRegistrationInput( + name: "동료1", + gender: .male, + calendarType: .lunar, + birthDate: try #require(makeDate("2000-01-01")), + birthTime: .jaTime, + isBirthTimeUnknown: false, + relationship: .colleague + ) + #expect(try await client.registerPartner(input) == responseID) + } + + @Test("궁합 생성은 선택 상대 이름을 nullable 응답의 안전한 fallback으로 사용한다") + func testCreateCompatibilityUsesSelectedPartnerNameFallback() async throws { + let partnerID = UUID(uuidString: "88888888-8888-8888-8888-888888888888")! + let client = makeClient { request in + #expect(request.httpMethod == "POST") + #expect(request.url?.path == "/api/v1/compatibilities/\(partnerID.uuidString.lowercased())") + let response = """ + { + "success": true, + "code": "200", + "data": { + "id": "99999999-9999-9999-9999-999999999999", + "relationshipType": { "code": "FRIEND", "label": "친구" }, + "score": 82, + "headline": "편안한 사이", + "subheadline": "대화가 잘 통해요", + "summary": "서로를 이해해요", + "totalAnalysis": "서버 종합 분석", + "analysisBasis": "서버 분석 근거", + "ohaengs": [{ + "element": { "code": "WOOD", "label": "목", "hanja": "木" }, + "percentage": 25 + }] + } + } + """ + return (Data(response.utf8), try makeHTTPResponse(url: request.url!, statusCode: 200)) + } + + let result = try await client.createCompatibility(partnerID, "토실이") + #expect(result.partnerName == "토실이") + #expect(result.relationship == .friend) + #expect(result.elements == [.init(element: .wood, percentage: 25)]) + } + + @Test("택일 운세 요청과 복수 결과를 매핑한다") + func testCreateDayFortunes() async throws { + let client = makeClient { request in + #expect(request.httpMethod == "POST") + #expect(request.url?.path == "/api/v1/day-fortunes") + let body = try #require(request.httpBody) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(json["purpose"] as? String == "TRAVEL") + #expect(json["targetDates"] as? [String] == ["2026-08-15"]) + + let response = """ + { + "success": true, + "code": "200", + "data": [{ + "id": "66666666-6666-6666-6666-666666666666", + "purpose": "TRAVEL", + "targetDate": "2026-08-15", + "score": 91, + "title": "출발하기 좋은 날", + "content": "새로운 풍경이 좋은 기운을 줘요.", + "fortuneCategories": [{ "fortuneCategory": "HEALTH", "star": 4 }] + }] + } + """ + return (Data(response.utf8), try makeHTTPResponse(url: request.url!, statusCode: 200)) + } + + let results = try await client.createDayFortunes(.travel, [try #require(makeDate("2026-08-15"))]) + #expect(results.count == 1) + #expect(results[0].score == 91) + #expect(results[0].categories == [.init(category: .health, star: 4)]) + } + + @Test("연도별 운세 생성 응답은 서버가 제공한 섹션만 매핑한다") + func testCreateYearFortune() async throws { + let client = makeClient { request in + #expect(request.httpMethod == "POST") + #expect(request.url?.path == "/api/v1/year-fortunes/2027") + let response = """ + { + "success": true, + "code": "200", + "data": { + "id": "77777777-7777-7777-7777-777777777777", + "year": 2027, + "score": 85, + "title": "도전이 결실을 맺는 해", + "content": "차근차근 쌓은 노력이 드러나요.", + "fortuneCategories": [{ "fortuneCategory": "ACHIEVEMENT", "star": 5 }] + } + } + """ + return (Data(response.utf8), try makeHTTPResponse(url: request.url!, statusCode: 200)) + } + + let result = try await client.createYearFortune(2027) + #expect(result.year == 2027) + #expect(result.categories == [.init(category: .achievement, star: 5)]) + } + + private static func sajuDetailJSON(linkID: String, name: String?) -> String { + let nameJSON = name.map { "\"\($0)\"" } ?? "null" + return """ + { + "success": true, + "code": "200", + "data": { + "linkId": "\(linkID)", + "name": \(nameJSON), + "gender": "FEMALE", + "birthDate": "2001-05-30", + "calendarType": "SOLAR", + "birthTime": "OSI", + "isTimeUnknown": false, + "pillars": [ + { + "pillarType": "YEAR", + "heavenlyStem": { + "hanja": "丁", "reading": "정", + "element": { "code": "FIRE", "label": "화", "hanja": "火" } + }, + "earthlyBranch": { + "hanja": "丑", "reading": "축", + "element": { "code": "EARTH", "label": "토", "hanja": "土" } + }, + "stemSipseong": { "code": "SIKSIN", "label": "식신" }, + "branchSipseong": { "code": "BIGYEON", "label": "비견" } + }, + { + "pillarType": "HOUR", + "heavenlyStem": { + "hanja": "辛", "reading": "신", + "element": { "code": "METAL", "label": "금", "hanja": "金" } + }, + "earthlyBranch": { + "hanja": "未", "reading": "미", + "element": { "code": "EARTH", "label": "토", "hanja": "土" } + }, + "stemSipseong": { "code": "SIKSIN", "label": "식신" }, + "branchSipseong": { "code": "BIGYEON", "label": "비견" } + }, + { + "pillarType": "DAY", + "heavenlyStem": { + "hanja": "己", "reading": "기", + "element": { "code": "EARTH", "label": "토", "hanja": "土" } + }, + "earthlyBranch": { + "hanja": "巳", "reading": "사", + "element": { "code": "FIRE", "label": "화", "hanja": "火" } + }, + "stemSipseong": null, + "branchSipseong": { "code": "GWANDAE", "label": "관대" } + }, + { + "pillarType": "MONTH", + "heavenlyStem": { + "hanja": "癸", "reading": "계", + "element": { "code": "WATER", "label": "수", "hanja": "水" } + }, + "earthlyBranch": { + "hanja": "卯", "reading": "묘", + "element": { "code": "WOOD", "label": "목", "hanja": "木" } + }, + "stemSipseong": { "code": "PYEONJAE", "label": "편재" }, + "branchSipseong": { "code": "GWANDAE", "label": "관대" } + } + ] + } + } + """ + } +} + +private func makeDate(_ value: String) -> Date? { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter.date(from: value) } private func makeClient(transport: @escaping HTTPClient.Transport) -> FortuneClient { @@ -244,3 +584,5 @@ private func makeHTTPResponse(url: URL, statusCode: Int) throws -> HTTPURLRespon ) ) } + +// swiftlint:enable file_length type_body_length diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift index df432a8..70f8459 100644 --- a/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift +++ b/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift @@ -248,20 +248,25 @@ extension FortuneFeatureTests { await #expect(throws: FortuneClientError.notConfigured) { try await client.fetchDetail(UUID(0)) } + await #expect(throws: FortuneClientError.notConfigured) { + try await client.fetchLuckAction(UUID(1)) + } + await #expect(throws: FortuneClientError.notConfigured) { + try await client.fetchPartners() + } } - @Test("알림 버튼 탭을 외부 이벤트로 전달한다") - func notificationTapDelegates() async { + @Test("범위에서 제외된 알림 버튼은 화면 전환을 만들지 않는다") + func notificationTapIsNoop() async { let store = TestStore(initialState: FortuneFeature.State()) { FortuneFeature() } await store.send(.view(.notificationButtonTapped)) - await store.receive(.delegate(.notificationRequested)) } - @Test("운세 리포트 버튼 탭을 선택 카테고리 없이 전달한다") - func reportTapDelegates() async { + @Test("운세 리포트 버튼 탭은 Fortune 내부 경로를 추가한다") + func reportTapPushesInternalPath() async { let dailyFortuneID = UUID(7) let store = TestStore( initialState: FortuneFeature.State( @@ -271,50 +276,59 @@ extension FortuneFeatureTests { FortuneFeature() } - await store.send(.view(.fortuneReportButtonTapped)) - await store.receive( - .delegate( - .fortuneReportRequested( - dailyFortuneID: dailyFortuneID, - selectedCategory: nil + await store.send(.view(.fortuneReportButtonTapped)) { + $0.path.append( + .report( + .init( + dailyFortuneID: dailyFortuneID, + selectedCategory: nil + ) ) ) - ) + } } - @Test("상세운 카드를 탭하면 모든 선택 카테고리를 전달한다") - func categoryTapDelegates() async { + @Test("상세운 카드는 선택 카테고리의 바텀시트를 연다") + func categoryTapPushesSelectedReport() async { for category in FortuneCategory.allCases { let dailyFortuneID = UUID(8) + let fixture = FortuneHomeContent.fixture(dailyFortuneID: dailyFortuneID) let store = TestStore( initialState: FortuneFeature.State( - viewState: .loaded(.fixture(dailyFortuneID: dailyFortuneID)) + viewState: .loaded(fixture) ) ) { FortuneFeature() } - await store.send(.view(.fortuneCategoryTapped(category))) - await store.receive( - .delegate( - .fortuneReportRequested( - dailyFortuneID: dailyFortuneID, - selectedCategory: category - ) + let item = fixture.categoryScores.first(where: { $0.category == category })! + await store.send(.view(.fortuneCategoryTapped(category))) { + $0.categoryDetail = .init( + luckActionID: item.luckActionID, + category: item.category, + categoryScores: fixture.categoryScores ) - ) + } } } - @Test("모든 사주 풀이 카드 탭을 외부 이벤트로 전달한다") - func readingTapDelegates() async { + @Test("모든 사주 풀이 카드는 대응하는 Fortune 내부 경로를 추가한다") + func readingTapPushesInternalPath() async { for reading in FortuneReading.allCases { let store = TestStore(initialState: FortuneFeature.State()) { FortuneFeature() } - await store.send(.view(.fortuneReadingTapped(reading))) - await store.receive(.delegate(.fortuneReadingRequested(reading))) + await store.send(.view(.fortuneReadingTapped(reading))) { + switch reading { + case .compatibility: + $0.path.append(.compatibility(.init())) + case .dateSelection: + $0.path.append(.dayFortune(.init())) + case .yearly: + $0.path.append(.yearFortune(.init())) + } + } } } diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift new file mode 100644 index 0000000..8fba222 --- /dev/null +++ b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift @@ -0,0 +1,181 @@ +import ComposableArchitecture +import Foundation +import Testing +@testable import FortuneFeature + +@MainActor +struct FortuneFollowupFeatureTests { + @Test("선택 카테고리로 리포트에 진입하면 상세 조회 후 바텀시트를 연다") + func reportLoadsAndPresentsRequestedCategory() async { + let dailyID = UUID(10) + let actionID = UUID(11) + let detail = FortuneDetailContent( + dailyFortuneID: dailyID, + fortuneDate: Date(timeIntervalSince1970: 0), + score: 72, + title: "좋은 하루", + content: "서버 종합 운세", + luckyItems: [], + cautionaryItems: [], + categoryScores: [ + .init(luckActionID: actionID, category: .love, score: 84) + ] + ) + let store = TestStore( + initialState: FortuneReportFeature.State( + dailyFortuneID: dailyID, + selectedCategory: .love + ) + ) { + FortuneReportFeature() + } withDependencies: { + $0.fortuneClient.fetchDetail = { _ in detail } + } + + await store.send(.task) + await store.receive(.response(.success(detail))) { + $0.viewState = .loaded(detail) + $0.pendingCategory = nil + $0.categoryDetail = .init( + luckActionID: actionID, + category: .love, + categoryScores: detail.categoryScores + ) + } + } + + @Test("상세운의 행운 액션은 App 조립용 delegate로 변환된다") + func categoryDetailLuckyActionDelegates() async { + let store = TestStore( + initialState: FortuneCategoryDetailFeature.State( + luckActionID: UUID(12), + category: .money + ) + ) { + FortuneCategoryDetailFeature() + } + + await store.send(.luckyActionTapped) + await store.receive(.delegate(.luckyActionRequested)) + } + + @Test("택일 후보 날짜는 최대 5개를 유지하고 초과 선택 시 토스트 경고를 표시한다") + func dayFortuneLimitsCandidateDates() async { + let now = Date(timeIntervalSince1970: 1_788_969_600) + let clock = TestClock() + let store = TestStore(initialState: DayFortuneFeature.State()) { + DayFortuneFeature() + } withDependencies: { + $0.date.now = now + $0.continuousClock = clock + } + + for offset in 0..<5 { + let date = now.addingTimeInterval(TimeInterval(offset * 86_400)) + await store.send(.dateTapped(date)) { + $0.selectedDates.append(Calendar.current.startOfDay(for: date)) + $0.selectedDates.sort() + } + } + + await store.send(.dateTapped(now.addingTimeInterval(5 * 86_400))) { + $0.showLimitToast = true + } + await clock.advance(by: .seconds(2)) + await store.receive(.hideToast) { + $0.showLimitToast = false + } + } + + @Test("택일 후보 날짜 초기화 시 선택 목록이 초기화된다") + func dayFortuneResetsCandidateDates() async { + let now = Date(timeIntervalSince1970: 1_788_969_600) + var state = DayFortuneFeature.State() + state.selectedDates = [now] + let store = TestStore(initialState: state) { + DayFortuneFeature() + } + + await store.send(.resetDatesTapped) { + $0.selectedDates = [] + } + } + + @Test("연도별 운세 응답은 로딩을 종료하고 서버 결과를 보존한다") + func yearFortuneStoresServerResult() async { + let result = YearFortuneResult( + id: UUID(13), + year: 2027, + score: 85, + title: "도전의 해", + content: "서버 총평", + categories: [.init(category: .achievement, star: 5)] + ) + let store = TestStore( + initialState: YearFortuneFeature.State(selectedYear: 2027) + ) { + YearFortuneFeature() + } withDependencies: { + $0.fortuneClient.createYearFortune = { _ in result } + } + + await store.send(.createTapped) { + $0.isSubmitting = true + } + await store.receive(.response(.success(result))) { + $0.isSubmitting = false + $0.result = result + } + } + + @Test("궁합 진입 시 내 사주와 상대방 목록 및 선택 상대 사주를 순서대로 불러온다") + func compatibilityLoadsSajuCharts() async { + let myChart = makeSajuChart(id: UUID(20), name: "나") + let partnerID = UUID(21) + let partner = FortunePartner( + id: partnerID, + name: "상대", + relationship: .partner + ) + let partnerChart = makeSajuChart(id: partnerID, name: "상대") + let store = TestStore(initialState: CompatibilityFeature.State()) { + CompatibilityFeature() + } withDependencies: { + $0.fortuneClient.fetchMySaju = { myChart } + $0.fortuneClient.fetchPartners = { [partner] } + $0.fortuneClient.fetchPartnerSaju = { _ in partnerChart } + } + + await store.send(.task) + await store.receive( + .initialResponse(.success(.init(mySaju: myChart, partners: [partner]))) + ) { + $0.mySaju = myChart + $0.partners = [partner] + $0.selectedPartnerID = partnerID + $0.viewState = .loaded + } + await store.receive(.partnerSajuResponse(partnerID, .success(partnerChart))) { + $0.selectedPartnerSaju = partnerChart + } + } + + private func makeSajuChart(id chartID: UUID, name: String) -> SajuChartDetail { + SajuChartDetail( + id: chartID, + name: name, + gender: .female, + birthDate: Date(timeIntervalSince1970: 0), + calendarType: .solar, + birthTime: .jaTime, + isBirthTimeUnknown: false, + pillars: [] + ) + } +} + +private extension UUID { + init(_ value: UInt8) { + self.init(uuid: (value, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + } +} diff --git a/Projects/Feature/MyPageFeature/Sources/MyPageSajuDetailView.swift b/Projects/Feature/MyPageFeature/Sources/MyPageSajuDetailView.swift index c8fcd70..ca8d881 100644 --- a/Projects/Feature/MyPageFeature/Sources/MyPageSajuDetailView.swift +++ b/Projects/Feature/MyPageFeature/Sources/MyPageSajuDetailView.swift @@ -158,10 +158,10 @@ struct MyPageSajuOriginalCard: View { rowTitle(title) HStack(spacing: 24) { ForEach(pillars) { pillar in - MyPageSajuPillarCell( + DSSajuPillarCell( hanja: isHeavenly ? pillar.heavenlyStem : pillar.earthlyBranch, reading: isHeavenly ? pillar.heavenlyReading : pillar.earthlyReading, - element: isHeavenly ? pillar.heavenlyElement : pillar.earthlyElement + element: (isHeavenly ? pillar.heavenlyElement : pillar.earthlyElement).toDSSajuElement ) } } @@ -194,31 +194,15 @@ struct MyPageSajuOriginalCard: View { } } -private struct MyPageSajuPillarCell: View { - let hanja: String - let reading: String - let element: MyPageElement - - var body: some View { - VStack(spacing: 1) { - Text(hanja) - .font(.system(size: 18, weight: .bold)) - Text("-\(reading)") - .dsCaption3Regular - } - .foregroundStyle(Color.ds.gray975) - .frame(width: 48, height: 48) - .background(backgroundColor, in: RoundedRectangle(cornerRadius: 12)) - } - - private var backgroundColor: Color { - switch element { - case .wood: .ds.teal200 - case .fire: .ds.red200 - case .earth: .ds.orange200 - case .metal: .ds.coolGray300 - case .water: .ds.sky200 - case .unknown: .ds.gray100 +private extension MyPageElement { + var toDSSajuElement: DSSajuElement { + switch self { + case .wood: .wood + case .fire: .fire + case .earth: .earth + case .metal: .metal + case .water: .water + case .unknown: .unknown } } } From 328dd5503cac75ecd583b27c8b0ac5b0de90cdc5 Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Fri, 14 Aug 2026 23:45:59 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat(app):=20=EA=B6=81=ED=95=A9=20?= =?UTF-8?q?=EB=82=B4=20=EC=A0=95=EB=B3=B4=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C?= =?UTF-8?q?=20=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=83=AD=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EC=97=B0=EB=8F=99=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CompatibilityFeature 내 정보 변경 액션을 상위 FortuneFeature 델리게이트로 연결 (#83) - MainTabFeature에서 myPageRequested 수신 시 마이페이지 탭으로 전환 처리 --- Projects/App/Sources/MainTab/MainTabFeature.swift | 14 +++++++++++--- Projects/App/Sources/MainTab/MainTabView.swift | 4 +++- Projects/App/Tests/MainTabFeatureTests.swift | 6 ++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Projects/App/Sources/MainTab/MainTabFeature.swift b/Projects/App/Sources/MainTab/MainTabFeature.swift index f179425..76e2e65 100644 --- a/Projects/App/Sources/MainTab/MainTabFeature.swift +++ b/Projects/App/Sources/MainTab/MainTabFeature.swift @@ -4,7 +4,7 @@ import Foundation import MyPageFeature @Reducer -struct MainTabFeature: Sendable { +struct MainTabFeature { init() {} enum Tab: Equatable, Sendable { @@ -15,7 +15,7 @@ struct MainTabFeature: Sendable { } @ObservableState - struct State: Equatable, Sendable { + struct State: Equatable { var selectedTab: Tab var fortune: FortuneFeature.State var myPage: MyPageFeature.State @@ -31,7 +31,7 @@ struct MainTabFeature: Sendable { } } - enum Action: Equatable, Sendable { + enum Action: Equatable { case selectedTabChanged(Tab) case fortune(FortuneFeature.Action) case myPage(MyPageFeature.Action) @@ -56,6 +56,14 @@ struct MainTabFeature: Sendable { 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, .myPage: return .none } diff --git a/Projects/App/Sources/MainTab/MainTabView.swift b/Projects/App/Sources/MainTab/MainTabView.swift index be356ae..6543cab 100644 --- a/Projects/App/Sources/MainTab/MainTabView.swift +++ b/Projects/App/Sources/MainTab/MainTabView.swift @@ -43,12 +43,14 @@ struct MainTabView: View { } private var shouldShowBottomNavigation: Bool { - store.myPage.edit == nil + let fortuneShowing = store.selectedTab != .fortune || !store.fortune.isShowingDetail + let myPageShowing = 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 } private var bottomNavigationBinding: Binding { diff --git a/Projects/App/Tests/MainTabFeatureTests.swift b/Projects/App/Tests/MainTabFeatureTests.swift index 15b66d7..748f656 100644 --- a/Projects/App/Tests/MainTabFeatureTests.swift +++ b/Projects/App/Tests/MainTabFeatureTests.swift @@ -42,12 +42,14 @@ 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 From 1f51a18501255964fda366b5fdb69734ba372b15 Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Fri, 14 Aug 2026 23:55:33 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix(fortune):=20PR=20Preflight=20Review?= =?UTF-8?q?=20=EC=A7=80=EC=A0=81=EC=82=AC=ED=95=AD=20=EB=B3=B4=EC=99=84=20?= =?UTF-8?q?(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FortuneCategoryDetailFeature todakRequested delegate 상위 라우팅 누락 수정 - CompatibilityFeature 초기화/목록 조회 Effect에 CancelID 및 취소 방어 적용 - DSSajuPillarCell 10천간 12지지 22자 음양(+/-) 동적 계산 로직 추가 - FortuneCategoryDetailView 하드코딩된 RGB 색상을 Color.ds 토큰으로 대체 - DSSajuPillarCell 22자 오행 파싱 및 MainTab/FortuneFeature 델리게이트 단위 테스트 보강 --- Projects/App/Tests/MainTabFeatureTests.swift | 11 ++++ .../Components/Saju/DSSajuPillarCell.swift | 17 +++++- .../DSSajuPillarCellSpecificationTests.swift | 57 ++++++++++++++++++- .../Compatibility/CompatibilityFeature.swift | 4 ++ .../Sources/FortuneFeature.swift | 3 +- .../Report/FortuneCategoryDetailView.swift | 16 +++--- .../Tests/FortuneFeatureTests.swift | 26 +++++++++ .../Tests/FortuneFollowupFeatureTests.swift | 36 ++++++++++++ 8 files changed, 157 insertions(+), 13 deletions(-) diff --git a/Projects/App/Tests/MainTabFeatureTests.swift b/Projects/App/Tests/MainTabFeatureTests.swift index 748f656..e549075 100644 --- a/Projects/App/Tests/MainTabFeatureTests.swift +++ b/Projects/App/Tests/MainTabFeatureTests.swift @@ -52,6 +52,17 @@ struct MainTabFeatureTests { } } + @Test + func fortuneDelegateMyPageRequestedSwitchesTab() async { + let store = TestStore(initialState: MainTabFeature.State()) { + MainTabFeature() + } + + await store.send(.fortune(.delegate(.myPageRequested))) { + $0.selectedTab = .myPage + } + } + @Test func statePersistedAcrossTabChanges() async { var initialState = MainTabFeature.State() diff --git a/Projects/Core/DesignSystem/Sources/Components/Saju/DSSajuPillarCell.swift b/Projects/Core/DesignSystem/Sources/Components/Saju/DSSajuPillarCell.swift index f48bf52..ef89f88 100644 --- a/Projects/Core/DesignSystem/Sources/Components/Saju/DSSajuPillarCell.swift +++ b/Projects/Core/DesignSystem/Sources/Components/Saju/DSSajuPillarCell.swift @@ -84,6 +84,17 @@ public struct DSSajuPillarCell: View { private let sublabel: String private let element: DSSajuElement + public static func yinYangSign(for hanja: String) -> String { + switch hanja { + case "甲", "丙", "戊", "庚", "壬", "子", "寅", "辰", "午", "申", "戌": + return "+" + case "乙", "丁", "己", "辛", "癸", "丑", "卯", "巳", "未", "酉", "亥": + return "-" + default: + return "-" + } + } + public init( hanja: String, reading: String, @@ -93,10 +104,12 @@ public struct DSSajuPillarCell: View { self.hanja = hanja let resolvedElement = element ?? DSSajuElement.from(hanja: hanja) self.element = resolvedElement + let sign = Self.yinYangSign(for: hanja) + let cleanedReading = reading.trimmingCharacters(in: CharacterSet(charactersIn: "+- ")) if showElementHanja && resolvedElement != .unknown { - self.sublabel = "-\(reading), \(resolvedElement.hanja)" + self.sublabel = "\(sign)\(cleanedReading), \(resolvedElement.hanja)" } else { - self.sublabel = "-\(reading)" + self.sublabel = "\(sign)\(cleanedReading)" } } diff --git a/Projects/Core/DesignSystem/Tests/DSSajuPillarCellSpecificationTests.swift b/Projects/Core/DesignSystem/Tests/DSSajuPillarCellSpecificationTests.swift index a139cee..2e171f5 100644 --- a/Projects/Core/DesignSystem/Tests/DSSajuPillarCellSpecificationTests.swift +++ b/Projects/Core/DesignSystem/Tests/DSSajuPillarCellSpecificationTests.swift @@ -22,12 +22,65 @@ struct DSSajuPillarCellSpecificationTests { expectColorEqual(specification.foregroundAsset, DesignSystemAsset.Colors.gray975) } - @Test("Hanja로부터 오행 파싱 검증") - func testHanjaElementParsing() { + @Test("10천간 12지지 전체 22자 오행 파싱 검증") + func testAll22HanjaElementParsing() { + // 목 (Wood): 甲, 乙, 寅, 卯 #expect(DSSajuElement.from(hanja: "甲") == .wood) + #expect(DSSajuElement.from(hanja: "乙") == .wood) + #expect(DSSajuElement.from(hanja: "寅") == .wood) + #expect(DSSajuElement.from(hanja: "卯") == .wood) + + // 화 (Fire): 丙, 丁, 巳, 午 #expect(DSSajuElement.from(hanja: "丙") == .fire) + #expect(DSSajuElement.from(hanja: "丁") == .fire) + #expect(DSSajuElement.from(hanja: "巳") == .fire) + #expect(DSSajuElement.from(hanja: "午") == .fire) + + // 토 (Earth): 戊, 己, 辰, 戌, 丑, 未 #expect(DSSajuElement.from(hanja: "戊") == .earth) + #expect(DSSajuElement.from(hanja: "己") == .earth) + #expect(DSSajuElement.from(hanja: "辰") == .earth) + #expect(DSSajuElement.from(hanja: "戌") == .earth) + #expect(DSSajuElement.from(hanja: "丑") == .earth) + #expect(DSSajuElement.from(hanja: "未") == .earth) + + // 금 (Metal): 庚, 辛, 申, 酉 #expect(DSSajuElement.from(hanja: "庚") == .metal) + #expect(DSSajuElement.from(hanja: "辛") == .metal) + #expect(DSSajuElement.from(hanja: "申") == .metal) + #expect(DSSajuElement.from(hanja: "酉") == .metal) + + // 수 (Water): 壬, 癸, 亥, 子 #expect(DSSajuElement.from(hanja: "壬") == .water) + #expect(DSSajuElement.from(hanja: "癸") == .water) + #expect(DSSajuElement.from(hanja: "亥") == .water) + #expect(DSSajuElement.from(hanja: "子") == .water) + + // 알 수 없는 한자 fallback + #expect(DSSajuElement.from(hanja: "天") == .unknown) + #expect(DSSajuElement.from(hanja: "") == .unknown) + } + + @Test("텍스트 오행 코드 파싱 검증") + func testTextElementParsing() { + #expect(DSSajuElement.from(text: "WOOD") == .wood) + #expect(DSSajuElement.from(text: "목") == .wood) + #expect(DSSajuElement.from(text: "FIRE") == .fire) + #expect(DSSajuElement.from(text: "화") == .fire) + #expect(DSSajuElement.from(text: "EARTH") == .earth) + #expect(DSSajuElement.from(text: "토") == .earth) + #expect(DSSajuElement.from(text: "METAL") == .metal) + #expect(DSSajuElement.from(text: "금") == .metal) + #expect(DSSajuElement.from(text: "WATER") == .water) + #expect(DSSajuElement.from(text: "수") == .water) + #expect(DSSajuElement.from(text: "UNKNOWN") == .unknown) + } + + @Test("음양 기호 계산 검증") + func testYinYangSign() { + #expect(DSSajuPillarCell.yinYangSign(for: "甲") == "+") + #expect(DSSajuPillarCell.yinYangSign(for: "乙") == "-") + #expect(DSSajuPillarCell.yinYangSign(for: "子") == "+") + #expect(DSSajuPillarCell.yinYangSign(for: "丑") == "-") } } diff --git a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift index e4e3e9d..61dfbab 100644 --- a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift +++ b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift @@ -8,6 +8,8 @@ import Model @Reducer public struct CompatibilityFeature { private enum CancelID { + case initialData + case partners case partnerSaju } @@ -317,6 +319,7 @@ public struct CompatibilityFeature { await send(.initialResponse(.failure(.transport))) } } + .cancellable(id: CancelID.initialData, cancelInFlight: true) } private func fetchPartners() -> Effect { @@ -331,6 +334,7 @@ public struct CompatibilityFeature { await send(.partnersResponse(.failure(.transport))) } } + .cancellable(id: CancelID.partners, cancelInFlight: true) } private func fetchPartnerSaju(_ partnerID: UUID) -> Effect { diff --git a/Projects/Feature/FortuneFeature/Sources/FortuneFeature.swift b/Projects/Feature/FortuneFeature/Sources/FortuneFeature.swift index c86e286..9dcd9ac 100644 --- a/Projects/Feature/FortuneFeature/Sources/FortuneFeature.swift +++ b/Projects/Feature/FortuneFeature/Sources/FortuneFeature.swift @@ -145,7 +145,8 @@ public struct FortuneFeature { case .path(.element(id: _, action: .report(.delegate(.todakRequested)))), .path(.element(id: _, action: .compatibility(.delegate(.todakRequested)))), .path(.element(id: _, action: .dayFortune(.delegate(.todakRequested)))), - .path(.element(id: _, action: .yearFortune(.delegate(.todakRequested)))): + .path(.element(id: _, action: .yearFortune(.delegate(.todakRequested)))), + .categoryDetail(.presented(.delegate(.todakRequested))): return .send(.delegate(.todakRequested)) case .path(.element(id: _, action: .report(.delegate(.luckyActionRequested)))), diff --git a/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailView.swift b/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailView.swift index 6911cd5..4445ea5 100644 --- a/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailView.swift +++ b/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailView.swift @@ -134,23 +134,23 @@ private struct HalfCircularGaugeView: View { private extension FortuneMoodLevel { var scoreColor: Color { switch self { - case .level01: return Color(red: 247/255, green: 139/255, blue: 139/255) - case .level02: return Color(red: 23/255, green: 144/255, blue: 130/255) - case .level03: return Color(red: 138/255, green: 165/255, blue: 255/255) - case .level04: return Color(red: 127/255, green: 115/255, blue: 234/255) + case .level01: return Color.ds.red300 + case .level02: return Color.ds.teal400 + case .level03: return Color.ds.sky300 + case .level04: return Color.ds.primary300 } } var gaugeGradient: LinearGradient { switch self { case .level01: - return LinearGradient(colors: [Color(red: 255/255, green: 194/255, blue: 194/255), scoreColor], startPoint: .leading, endPoint: .trailing) + return LinearGradient(colors: [Color.ds.red100, scoreColor], startPoint: .leading, endPoint: .trailing) case .level02: - return LinearGradient(colors: [Color(red: 183/255, green: 232/255, blue: 227/255), Color(red: 77/255, green: 213/255, blue: 197/255)], startPoint: .leading, endPoint: .trailing) + return LinearGradient(colors: [Color.ds.teal100, scoreColor], startPoint: .leading, endPoint: .trailing) case .level03: - return LinearGradient(colors: [Color(red: 192/255, green: 206/255, blue: 255/255), scoreColor], startPoint: .leading, endPoint: .trailing) + return LinearGradient(colors: [Color.ds.sky100, scoreColor], startPoint: .leading, endPoint: .trailing) case .level04: - return LinearGradient(colors: [Color(red: 138/255, green: 165/255, blue: 255/255), scoreColor], startPoint: .leading, endPoint: .trailing) + return LinearGradient(colors: [Color.ds.primary100, scoreColor], startPoint: .leading, endPoint: .trailing) } } } diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift index 70f8459..2dad08e 100644 --- a/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift +++ b/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift @@ -341,6 +341,32 @@ extension FortuneFeatureTests { await store.send(.view(.luckyActionBannerTapped)) await store.receive(.delegate(.luckyActionRequested)) } + + @Test("궁합 화면의 내 정보 변경 요청을 상위 myPageRequested delegate로 전파한다") + func compatibilityMyInfoEditDelegatesToMyPage() async { + var state = FortuneFeature.State() + state.path.append(.compatibility(.init())) + + let store = TestStore(initialState: state) { + FortuneFeature() + } + + await store.send(.path(.element(id: 0, action: .compatibility(.delegate(.myInfoEditRequested))))) + await store.receive(.delegate(.myPageRequested)) + } + + @Test("상세운 바텀시트에서 토닥이 탭 시 상위 todakRequested delegate로 전파한다") + func categoryDetailTodakTapDelegates() async { + var state = FortuneFeature.State() + state.categoryDetail = .init(luckActionID: UUID(), category: .money) + + let store = TestStore(initialState: state) { + FortuneFeature() + } + + await store.send(.categoryDetail(.presented(.delegate(.todakRequested)))) + await store.receive(.delegate(.todakRequested)) + } } private extension FortuneHomeContent { diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift index 8fba222..be7d74a 100644 --- a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift +++ b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift @@ -160,6 +160,42 @@ struct FortuneFollowupFeatureTests { } } + @Test("택일 운세 생성 시 점수 순으로 상위 3개를 선별하여 저장한다") + func dayFortuneCreatesAndSelectsTop3Results() async { + let now = Date(timeIntervalSince1970: 1_788_969_600) + let results: [DayFortuneItem] = [ + .init(date: now, score: 60, title: "보통", content: "내용1"), + .init(date: now.addingTimeInterval(86_400), score: 95, title: "대길", content: "내용2"), + .init(date: now.addingTimeInterval(172_800), score: 80, title: "길", content: "내용3"), + .init(date: now.addingTimeInterval(259_200), score: 40, title: "소흉", content: "내용4") + ] + var state = DayFortuneFeature.State() + state.selectedDates = [now, now.addingTimeInterval(86_400)] + let store = TestStore(initialState: state) { + DayFortuneFeature() + } withDependencies: { + $0.fortuneClient.createDayFortunes = { _, _ in results } + } + + await store.send(.createTapped) { + $0.isSubmitting = true + } + await store.receive(.response(.success(results))) { + $0.isSubmitting = false + $0.results = [results[1], results[2], results[0]] // 95, 80, 60 + } + } + + @Test("궁합 화면에서 내 정보 수정을 탭하면 myInfoEditRequested delegate가 전송된다") + func compatibilityMyInfoEditDelegates() async { + let store = TestStore(initialState: CompatibilityFeature.State()) { + CompatibilityFeature() + } + + await store.send(.myInfoEditTapped) + await store.receive(.delegate(.myInfoEditRequested)) + } + private func makeSajuChart(id chartID: UUID, name: String) -> SajuChartDetail { SajuChartDetail( id: chartID, From e548842c292b20f40cb6e3efdf2b5afa3e3fdfa2 Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Fri, 14 Aug 2026 23:58:10 +0900 Subject: [PATCH 07/16] =?UTF-8?q?style(fortune):=20SwiftLint=20=ED=8F=AC?= =?UTF-8?q?=EB=A7=B7=ED=8C=85=20=EB=B0=8F=20=EC=BB=A8=EB=B2=A4=EC=85=98=20?= =?UTF-8?q?=EC=A4=80=EC=88=98=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Saju/DSSajuPillarCell.swift | 20 ++++++-- .../Example/Sources/ExampleContentView.swift | 46 +++++++++++++++---- .../Sources/FortuneExampleFixtures.swift | 7 ++- .../Compatibility/CompatibilityView.swift | 19 ++++---- .../Sources/DayFortune/DayFortuneView.swift | 12 ++++- .../Report/FortuneCategoryDetailFeature.swift | 2 +- .../Report/FortuneCategoryDetailView.swift | 32 ++++++------- .../Sources/Report/FortuneReportView.swift | 27 +++++++---- 8 files changed, 113 insertions(+), 52 deletions(-) diff --git a/Projects/Core/DesignSystem/Sources/Components/Saju/DSSajuPillarCell.swift b/Projects/Core/DesignSystem/Sources/Components/Saju/DSSajuPillarCell.swift index ef89f88..f6531ea 100644 --- a/Projects/Core/DesignSystem/Sources/Components/Saju/DSSajuPillarCell.swift +++ b/Projects/Core/DesignSystem/Sources/Components/Saju/DSSajuPillarCell.swift @@ -54,11 +54,21 @@ public enum DSSajuElement: String, Sendable, Equatable, Hashable, Codable { public static func from(text: String) -> DSSajuElement { let upper = text.uppercased() - if upper.contains("WOOD") || upper.contains("MOK") || upper.contains("목") || upper.contains("木") { return .wood } - if upper.contains("FIRE") || upper.contains("HWA") || upper.contains("화") || upper.contains("火") { return .fire } - if upper.contains("EARTH") || upper.contains("TO") || upper.contains("토") || upper.contains("土") { return .earth } - if upper.contains("METAL") || upper.contains("GEUM") || upper.contains("금") || upper.contains("金") { return .metal } - if upper.contains("WATER") || upper.contains("SU") || upper.contains("수") || upper.contains("水") { return .water } + if upper.contains("WOOD") || upper.contains("MOK") || upper.contains("목") || upper.contains("木") { + return .wood + } + if upper.contains("FIRE") || upper.contains("HWA") || upper.contains("화") || upper.contains("火") { + return .fire + } + if upper.contains("EARTH") || upper.contains("TO") || upper.contains("토") || upper.contains("土") { + return .earth + } + if upper.contains("METAL") || upper.contains("GEUM") || upper.contains("금") || upper.contains("金") { + return .metal + } + if upper.contains("WATER") || upper.contains("SU") || upper.contains("수") || upper.contains("水") { + return .water + } return .unknown } } diff --git a/Projects/Feature/FortuneFeature/Example/Sources/ExampleContentView.swift b/Projects/Feature/FortuneFeature/Example/Sources/ExampleContentView.swift index 11f95f3..253ed04 100644 --- a/Projects/Feature/FortuneFeature/Example/Sources/ExampleContentView.swift +++ b/Projects/Feature/FortuneFeature/Example/Sources/ExampleContentView.swift @@ -38,9 +38,27 @@ struct ExampleContentView: View { birthTime: nil, isBirthTimeUnknown: true, pillars: [ - SajuPillar(type: .year, heavenlyStem: SajuSymbol(hanja: "甲", reading: "갑", elementLabel: "큰 나무", elementHanja: "목"), earthlyBranch: SajuSymbol(hanja: "辰", reading: "진", elementLabel: "용", elementHanja: "토"), stemTenGod: "비견", branchTenGod: "편재"), - SajuPillar(type: .month, heavenlyStem: SajuSymbol(hanja: "丙", reading: "병", elementLabel: "태양", elementHanja: "화"), earthlyBranch: SajuSymbol(hanja: "寅", reading: "인", elementLabel: "호랑이", elementHanja: "목"), stemTenGod: "식신", branchTenGod: "비견"), - SajuPillar(type: .day, heavenlyStem: SajuSymbol(hanja: "戊", reading: "무", elementLabel: "큰 산", elementHanja: "토"), earthlyBranch: SajuSymbol(hanja: "申", reading: "신", elementLabel: "원숭이", elementHanja: "금"), stemTenGod: nil, branchTenGod: "식신") + SajuPillar( + type: .year, + heavenlyStem: SajuSymbol(hanja: "甲", reading: "갑", elementLabel: "큰 나무", elementHanja: "목"), + earthlyBranch: SajuSymbol(hanja: "辰", reading: "진", elementLabel: "용", elementHanja: "토"), + stemTenGod: "비견", + branchTenGod: "편재" + ), + SajuPillar( + type: .month, + heavenlyStem: SajuSymbol(hanja: "丙", reading: "병", elementLabel: "태양", elementHanja: "화"), + earthlyBranch: SajuSymbol(hanja: "寅", reading: "인", elementLabel: "호랑이", elementHanja: "목"), + stemTenGod: "식신", + branchTenGod: "비견" + ), + SajuPillar( + type: .day, + heavenlyStem: SajuSymbol(hanja: "戊", reading: "무", elementLabel: "큰 산", elementHanja: "토"), + earthlyBranch: SajuSymbol(hanja: "申", reading: "신", elementLabel: "원숭이", elementHanja: "금"), + stemTenGod: nil, + branchTenGod: "식신" + ) ] ) } @@ -61,13 +79,25 @@ struct ExampleContentView: View { birthTime: nil, isBirthTimeUnknown: true, pillars: [ - SajuPillar(type: .year, heavenlyStem: SajuSymbol(hanja: "丁", reading: "정", elementLabel: "등불", elementHanja: "화"), earthlyBranch: SajuSymbol(hanja: "卯", reading: "묘", elementLabel: "토끼", elementHanja: "목"), stemTenGod: "정인", branchTenGod: "정관"), - SajuPillar(type: .month, heavenlyStem: SajuSymbol(hanja: "己", reading: "기", elementLabel: "작은 흙", elementHanja: "토"), earthlyBranch: SajuSymbol(hanja: "酉", reading: "유", elementLabel: "닭", elementHanja: "금"), stemTenGod: "겁재", branchTenGod: "상관") + SajuPillar( + type: .year, + heavenlyStem: SajuSymbol(hanja: "丁", reading: "정", elementLabel: "등불", elementHanja: "화"), + earthlyBranch: SajuSymbol(hanja: "卯", reading: "묘", elementLabel: "토끼", elementHanja: "목"), + stemTenGod: "정인", + branchTenGod: "정관" + ), + SajuPillar( + type: .month, + heavenlyStem: SajuSymbol(hanja: "己", reading: "기", elementLabel: "작은 흙", elementHanja: "토"), + earthlyBranch: SajuSymbol(hanja: "酉", reading: "유", elementLabel: "닭", elementHanja: "금"), + stemTenGod: "겁재", + branchTenGod: "상관" + ) ] ) } $0.fortuneClient.registerPartner = { _ in UUID() } - $0.fortuneClient.createCompatibility = { _, _ in + $0.fortuneClient.createCompatibility = { _, _ in CompatibilityResult( id: UUID(), partnerName: "토닥이", @@ -108,7 +138,7 @@ struct ExampleContentView: View { .prefix(3) ) } - $0.fortuneClient.createYearFortune = { _ in + $0.fortuneClient.createYearFortune = { _ in try await Task.sleep(for: .seconds(1)) return YearFortuneResult( id: UUID(), @@ -121,7 +151,7 @@ struct ExampleContentView: View { FortuneCategoryStar(category: .achievement, star: 4), FortuneCategoryStar(category: .relationship, star: 4) ] - ) + ) } } diff --git a/Projects/Feature/FortuneFeature/Example/Sources/FortuneExampleFixtures.swift b/Projects/Feature/FortuneFeature/Example/Sources/FortuneExampleFixtures.swift index 5dc1845..7e221b3 100644 --- a/Projects/Feature/FortuneFeature/Example/Sources/FortuneExampleFixtures.swift +++ b/Projects/Feature/FortuneFeature/Example/Sources/FortuneExampleFixtures.swift @@ -47,7 +47,12 @@ extension FortuneDetailContent { fortuneDate: Date(timeIntervalSince1970: 1_788_969_600), score: score, title: "오늘은 무난하고 평범한 하루예요", - content: "주변 사람들과의 관계가 한층 부드러워지는 시기예요. 평소에는 무심코 지나쳤던 인연 속에서 뜻밖의 도움이나 따뜻한 위로를 얻게 될 수 있어요. 작은 오해가 있었다면 먼저 손을 내밀어 보세요. 진심 어린 한마디가 관계의 흐름을 좋은 방향으로 이끌어 줄 거예요.", + content: """ + 주변 사람들과의 관계가 한층 부드러워지는 시기예요. \ + 평소에는 무심코 지나쳤던 인연 속에서 뜻밖의 도움이나 따뜻한 위로를 얻게 될 수 있어요. \ + 작은 오해가 있었다면 먼저 손을 내밀어 보세요. \ + 진심 어린 한마디가 관계의 흐름을 좋은 방향으로 이끌어 줄 거예요. + """, luckyItems: ["따뜻한 차", "노란색 아이템", "산책"], cautionaryItems: ["충동구매", "과식"], categoryScores: [ diff --git a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift index 0eea3de..522fc3f 100644 --- a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift +++ b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift @@ -58,14 +58,15 @@ struct CompatibilityView: View { onSave: { store.send(.partnerPickerPresented(false)) store.send(.registrationPresented(true)) + }, + content: { + DSSingleWheelPicker( + items: partnerWheelItems, + selection: $selectedPartnerIndex, + accessibilityLabel: "궁합 상대" + ) } - ) { - DSSingleWheelPicker( - items: partnerWheelItems, - selection: $selectedPartnerIndex, - accessibilityLabel: "궁합 상대" - ) - } + ) .onAppear { syncSelectedPartnerIndex() } @@ -247,8 +248,6 @@ private struct CompatibilityMainContent: View { } } - - // MARK: - 상대방 등록 화면 (Push Screen) private struct PartnerRegistrationView: View { @@ -761,7 +760,7 @@ private struct SajuChartCard: View { let chart: SajuChartDetail let fallbackName: String let relationshipTitle: String? - var onEditTapped: (() -> Void)? = nil + var onEditTapped: (() -> Void)? private var sortedPillars: [SajuPillar] { chart.pillars.sorted { $0.type.sortOrder < $1.type.sortOrder } diff --git a/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift b/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift index cc5b6d1..07bfa2f 100644 --- a/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift +++ b/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift @@ -472,7 +472,8 @@ private struct DayFortuneResultContent: View { .foregroundStyle(Color.ds.whiteOpacity60) HStack(spacing: 4) { - let filledCount = min(3, max(1, catStar.star > 3 ? Int(round(Double(catStar.star) * 3.0 / 5.0)) : catStar.star)) + let normalized = catStar.star > 3 ? Int(round(Double(catStar.star) * 3.0 / 5.0)) : catStar.star + let filledCount = min(3, max(1, normalized)) ForEach(1...3, id: \.self) { starIndex in let isFilled = starIndex <= filledCount if isFilled { @@ -671,7 +672,7 @@ private struct CalendarPickerView: View { Text("\(calendar.component(.day, from: date))") .dsBody2Medium .foregroundStyle( - isSelected ? Color.ds.white : (isPast ? Color.ds.gray300 : (isToday ? Color.ds.primary600 : Color.ds.gray975)) + cellTextColor(isSelected: isSelected, isPast: isPast, isToday: isToday) ) if isToday { @@ -690,6 +691,13 @@ private struct CalendarPickerView: View { .buttonStyle(.plain) } + private func cellTextColor(isSelected: Bool, isPast: Bool, isToday: Bool) -> Color { + if isSelected { return Color.ds.white } + if isPast { return Color.ds.gray300 } + if isToday { return Color.ds.primary600 } + return Color.ds.gray975 + } + private func monthTitle(for date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "ko_KR") diff --git a/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailFeature.swift b/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailFeature.swift index edc701b..dde4247 100644 --- a/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailFeature.swift +++ b/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailFeature.swift @@ -76,7 +76,7 @@ public struct FortuneCategoryDetailFeature { case .luckyActionTapped: return .send(.delegate(.luckyActionRequested)) - + case .todakTapped: return .send(.delegate(.todakRequested)) diff --git a/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailView.swift b/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailView.swift index 4445ea5..712aabc 100644 --- a/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailView.swift +++ b/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailView.swift @@ -9,25 +9,25 @@ struct FortuneCategoryDetailView: View { var body: some View { VStack(spacing: 0) { header - + ScrollView { switch store.viewState { case .loading: ProgressView() .frame(maxWidth: .infinity, minHeight: 240) - + case let .failed(message): VStack(spacing: 16) { Text(message) .dsBody3Regular .foregroundStyle(Color.ds.gray700) - + DSButton("다시 시도", size: .medium) { store.send(.retryTapped) } } .frame(maxWidth: .infinity, minHeight: 240) - + case let .loaded(detail): loadedContent(detail) } @@ -39,15 +39,15 @@ struct FortuneCategoryDetailView: View { await store.send(.task).finish() } } - + private var header: some View { HStack { Text("오늘의 \(store.category.title)") .dsHeading4Bold .foregroundStyle(Color.ds.gray975) - + Spacer() - + Button { dismiss() } label: { @@ -66,12 +66,12 @@ struct FortuneCategoryDetailView: View { HalfCircularGaugeView(score: detail.score) .padding(.top, 40) .padding(.bottom, 48) - + VStack(spacing: 8) { Text("오늘의 행운 액션") .dsBody3Medium .foregroundStyle(Color.ds.primary500) - + Text(detail.title) .dsBody2SemiBold .foregroundStyle(Color.ds.gray975) @@ -84,14 +84,14 @@ struct FortuneCategoryDetailView: View { .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 20) .padding(.bottom, 24) - + Text(detail.content) .dsBody3Regular .foregroundStyle(Color.ds.gray975) .lineSpacing(6) .padding(.horizontal, 20) .padding(.bottom, 40) - + DSPrimaryLargeButton("토닥이에게 더 물어보기") { store.send(.todakTapped) } @@ -103,16 +103,16 @@ struct FortuneCategoryDetailView: View { private struct HalfCircularGaugeView: View { let score: Int - + var body: some View { let level = FortuneMoodLevel(score: Double(score)) - + ZStack(alignment: .bottom) { ZStack { Circle() .trim(from: 0.5, to: 1.0) .stroke(Color.ds.gray100, style: StrokeStyle(lineWidth: 16, lineCap: .round)) - + Circle() .trim(from: 0.5, to: 0.5 + CGFloat(score) / 100.0 * 0.5) .stroke(level.gaugeGradient, style: StrokeStyle(lineWidth: 16, lineCap: .round)) @@ -121,7 +121,7 @@ private struct HalfCircularGaugeView: View { .padding(10) .frame(height: 110, alignment: .top) .clipped() - + Text("\(score)점") .dsHeading1ExtraBold .foregroundStyle(level.scoreColor) @@ -140,7 +140,7 @@ private extension FortuneMoodLevel { case .level04: return Color.ds.primary300 } } - + var gaugeGradient: LinearGradient { switch self { case .level01: diff --git a/Projects/Feature/FortuneFeature/Sources/Report/FortuneReportView.swift b/Projects/Feature/FortuneFeature/Sources/Report/FortuneReportView.swift index e292752..182aa49 100644 --- a/Projects/Feature/FortuneFeature/Sources/Report/FortuneReportView.swift +++ b/Projects/Feature/FortuneFeature/Sources/Report/FortuneReportView.swift @@ -3,7 +3,7 @@ import DesignSystem import SwiftUI // 리포트 섹션은 플로팅 툴팁 노출에 사용하는 스크롤 임계 상태를 공유한다. -// swiftlint:disable type_body_length +// swiftlint:disable file_length type_body_length struct FortuneReportView: View { @Bindable var store: StoreOf @@ -96,9 +96,18 @@ struct FortuneReportView: View { VStack(spacing: 24) { scoreSection(detail) summarySection(detail) - categorySection(detail) - itemSection(title: "오늘의 행운 아이템", items: detail.luckyItems, imageName: "img_clover", badgeColor: Color.ds.primary300) - itemSection(title: "오늘의 주의 아이템", items: detail.cautionaryItems, imageName: "img_warning", badgeColor: Color.ds.pink300) + itemSection( + title: "오늘의 행운 아이템", + items: detail.luckyItems, + imageName: "img_clover", + badgeColor: Color.ds.primary300 + ) + itemSection( + title: "오늘의 주의 아이템", + items: detail.cautionaryItems, + imageName: "img_warning", + badgeColor: Color.ds.pink300 + ) Color.clear.frame(height: 76) } @@ -112,7 +121,7 @@ struct FortuneReportView: View { Text("오늘의 운세") .dsBody2Medium .foregroundStyle(Color.ds.primary300) - + Button { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { showInfoTooltip.toggle() @@ -130,7 +139,7 @@ struct FortuneReportView: View { .fill(Color.ds.white.opacity(0.9)) .frame(width: 12, height: 8) .padding(.leading, 80) - + Text("오늘의 운세 점수는\n사주 데이터 분석을 바탕으로 나온\n상세운 5가지의 평균 점수예요") .dsBody3Medium .foregroundStyle(Color.ds.gray900) @@ -386,7 +395,7 @@ private struct FlowLayout: Layout { var rows: [[LayoutSubview]] = [] var currentRow: [LayoutSubview] = [] var currentRowWidth: CGFloat = 0 - + for subview in subviews { let size = subview.sizeThatFits(.unspecified) if !currentRow.isEmpty, currentRowWidth + spacing + size.width > bounds.width { @@ -401,13 +410,13 @@ private struct FlowLayout: Layout { if !currentRow.isEmpty { rows.append(currentRow) } - + var verticalOffset = bounds.minY for row in rows { let rowWidth = row.reduce(0) { $0 + $1.sizeThatFits(.unspecified).width } + spacing * CGFloat(row.count - 1) var horizontalOffset = bounds.minX + (bounds.width - rowWidth) / 2 var rowHeight: CGFloat = 0 - + for subview in row { let size = subview.sizeThatFits(.unspecified) subview.place( From 956b5834b3c80174120b4cc0cd77dc52b39f2802 Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Sat, 15 Aug 2026 01:25:52 +0900 Subject: [PATCH 08/16] =?UTF-8?q?fix(fortune):=20=EC=9A=B4=EC=84=B8=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=82=B4=20=EC=83=81=EC=84=B8=EC=9A=B4=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FortuneReportView reportBody 본문에 categorySection(detail) 추가 --- .../FortuneFeature/Sources/Report/FortuneReportView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Projects/Feature/FortuneFeature/Sources/Report/FortuneReportView.swift b/Projects/Feature/FortuneFeature/Sources/Report/FortuneReportView.swift index 182aa49..1b6053b 100644 --- a/Projects/Feature/FortuneFeature/Sources/Report/FortuneReportView.swift +++ b/Projects/Feature/FortuneFeature/Sources/Report/FortuneReportView.swift @@ -96,6 +96,7 @@ struct FortuneReportView: View { VStack(spacing: 24) { scoreSection(detail) summarySection(detail) + categorySection(detail) itemSection( title: "오늘의 행운 아이템", items: detail.luckyItems, From 55ecfc0aab6d6fcb404638a9f3fb87c47fb036a7 Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Sat, 15 Aug 2026 02:11:54 +0900 Subject: [PATCH 09/16] =?UTF-8?q?fix(fortune):=20=EC=82=AC=EC=A3=BC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EA=B7=9C=EA=B2=A9=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EA=B6=81?= =?UTF-8?q?=ED=95=A9=20=EC=82=AC=EC=A3=BC=20=EA=B5=AC=EC=A1=B0=EB=8F=84=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상대방 등록 API 전송 시 동료 관계 코드를 COLLEAGUE로 수정 - 시간 모름 시 birthTime 값을 UNKNOWN으로 전송하도록 수정 및 불필요한 isTimeUnknown 필드 제거 - 궁합 결과 화면의 사주 구조도를 Grid 레이아웃으로 변경하여 가변 이름 지원 및 컬럼 밀림 방지 - 상대방 선택 휠피커 바텀시트 추가 버튼 복원 및 닫힘 시 커밋 UX 적용 --- .../Compatibility/CompatibilityView.swift | 110 +++++++++++------- .../Sources/FortuneClient+Live.swift | 8 +- .../Tests/FortuneClientTests.swift | 2 +- .../Tests/FortuneFollowupFeatureTests.swift | 17 +-- 4 files changed, 78 insertions(+), 59 deletions(-) diff --git a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift index 522fc3f..8af3fa0 100644 --- a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift +++ b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift @@ -53,7 +53,7 @@ struct CompatibilityView: View { .dsWheelPickerSheet( isPresented: $store.isPartnerPickerPresented.sending(\.partnerPickerPresented), layout: .single, - title: "궁합 상대 선택", + title: "상대방 선택", actionTitle: "추가", onSave: { store.send(.partnerPickerPresented(false)) @@ -70,14 +70,21 @@ struct CompatibilityView: View { .onAppear { syncSelectedPartnerIndex() } + .onChange(of: store.isPartnerPickerPresented) { wasPresented, isPresented in + if isPresented { + syncSelectedPartnerIndex() + } else if wasPresented { + if store.partners.indices.contains(selectedPartnerIndex) { + let partner = store.partners[selectedPartnerIndex] + if partner.id != store.selectedPartnerID { + store.send(.partnerSelected(partner.id)) + } + } + } + } .onChange(of: store.selectedPartnerID) { _, _ in syncSelectedPartnerIndex() } - .onChange(of: selectedPartnerIndex) { _, newIndex in - if store.partners.indices.contains(newIndex) { - store.send(.partnerSelected(store.partners[newIndex].id)) - } - } } private var partnerWheelItems: [DSWheelPickerItem] { @@ -621,11 +628,12 @@ private struct CompatibilityResultContent: View { .dsBody1Bold .foregroundStyle(Color.ds.white) - HStack(alignment: .center, spacing: 8) { - Color.clear - .frame(width: 38) + Grid(alignment: .center, horizontalSpacing: 8, verticalSpacing: 12) { + GridRow { + Color.clear + .frame(height: 1) + .gridColumnAlignment(.leading) - HStack(spacing: 8) { ForEach(["시", "일", "월", "년"], id: \.self) { title in Text(title) .dsCaption1Regular @@ -633,16 +641,19 @@ private struct CompatibilityResultContent: View { .frame(maxWidth: .infinity) } } - } - CompactSajuRow( - title: "나", - symbols: mySaju.pillars.sorted { $0.type.sortOrder < $1.type.sortOrder }.map(\.heavenlyStem) - ) - CompactSajuRow( - title: store.result?.partnerName ?? "토닥", - symbols: partnerSaju.pillars.sorted { $0.type.sortOrder < $1.type.sortOrder }.map(\.earthlyBranch) - ) + sajuGridRow( + title: "나", + pillars: mySaju.pillars, + symbolKeyPath: \.heavenlyStem + ) + + sajuGridRow( + title: store.result?.partnerName ?? (store.selectedPartner?.name ?? "상대방"), + pillars: partnerSaju.pillars, + symbolKeyPath: \.earthlyBranch + ) + } } .frame(maxWidth: .infinity, alignment: .leading) .padding(20) @@ -651,6 +662,41 @@ private struct CompatibilityResultContent: View { } } + @ViewBuilder + private func sajuGridRow( + title: String, + pillars: [SajuPillar], + symbolKeyPath: KeyPath + ) -> some View { + GridRow { + Text(title) + .dsBody2SemiBold + .foregroundStyle(Color.ds.white) + .lineLimit(1) + .minimumScaleFactor(0.7) + .gridColumnAlignment(.leading) + + ForEach([SajuPillarType.hour, .day, .month, .year], id: \.self) { type in + if let pillar = pillars.first(where: { $0.type == type }) { + let symbol = pillar[keyPath: symbolKeyPath] + DSSajuPillarCell( + hanja: symbol.hanja, + reading: symbol.reading, + element: DSSajuElement.from(hanja: symbol.hanja) + ) + .frame(maxWidth: .infinity) + } else { + DSSajuPillarCell( + hanja: "-", + reading: "-", + element: .unknown + ) + .frame(maxWidth: .infinity) + } + } + } + } + private func elementsCard(elements: [FortuneElementScore]) -> some View { VStack(alignment: .leading, spacing: 16) { Text("오행 시각화") @@ -858,32 +904,6 @@ private struct SajuChartCard: View { }() } -private struct CompactSajuRow: View { - let title: String - let symbols: [SajuSymbol] - - var body: some View { - HStack(alignment: .center, spacing: 8) { - Text(title) - .dsBody2SemiBold - .foregroundStyle(Color.ds.white) - .lineLimit(1) - .frame(width: 38, alignment: .leading) - - HStack(spacing: 8) { - ForEach(Array(symbols.enumerated()), id: \.offset) { _, symbol in - DSSajuPillarCell( - hanja: symbol.hanja, - reading: symbol.reading, - element: DSSajuElement.from(hanja: symbol.hanja) - ) - .frame(maxWidth: .infinity) - } - } - } - } -} - private struct SajuPillarColumn: View { let pillar: SajuPillar let isDark: Bool diff --git a/Projects/Feature/FortuneFeature/Sources/FortuneClient+Live.swift b/Projects/Feature/FortuneFeature/Sources/FortuneClient+Live.swift index 66d312b..02dc875 100644 --- a/Projects/Feature/FortuneFeature/Sources/FortuneClient+Live.swift +++ b/Projects/Feature/FortuneFeature/Sources/FortuneClient+Live.swift @@ -461,7 +461,6 @@ private struct RegisterPartnerSajuRequestDTO: Encodable { let calendarType: String let birthDate: String let birthTime: String - let isTimeUnknown: Bool let relationshipType: String init(input: PartnerRegistrationInput) { @@ -469,8 +468,7 @@ private struct RegisterPartnerSajuRequestDTO: Encodable { gender = input.gender.serverValue calendarType = input.calendarType.serverValue birthDate = formatFortuneDate(input.birthDate) - isTimeUnknown = input.isBirthTimeUnknown - birthTime = input.isBirthTimeUnknown ? "12:00:00" : (input.birthTime?.serverValue ?? "12:00:00") + birthTime = input.isBirthTimeUnknown ? "UNKNOWN" : (input.birthTime?.serverValue ?? "UNKNOWN") relationshipType = input.relationship.serverValue } } @@ -635,7 +633,7 @@ private func mapRelationship(_ value: String) throws -> FortuneRelationship { switch value { case "LOVER": .partner case "FRIEND": .friend - case "COWORKER": .colleague + case "COLLEAGUE", "COWORKER": .colleague case "FAMILY": .family case "ETC": .other default: throw FortuneClientError.invalidResponse @@ -710,7 +708,7 @@ private extension Relationship { switch self { case .partner: "LOVER" case .friend: "FRIEND" - case .colleague: "COWORKER" + case .colleague: "COLLEAGUE" } } } diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneClientTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneClientTests.swift index e1f454f..fa14dee 100644 --- a/Projects/Feature/FortuneFeature/Tests/FortuneClientTests.swift +++ b/Projects/Feature/FortuneFeature/Tests/FortuneClientTests.swift @@ -364,7 +364,7 @@ struct FortuneClientTests { #expect(json["gender"] as? String == "MALE") #expect(json["calendarType"] as? String == "LUNAR") #expect(json["birthTime"] as? String == "JASI") - #expect(json["relationshipType"] as? String == "COWORKER") + #expect(json["relationshipType"] as? String == "COLLEAGUE") #expect(json["birthDate"] as? String == "2000-01-01") let response = """ diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift index be7d74a..e95e392 100644 --- a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift +++ b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift @@ -163,12 +163,12 @@ struct FortuneFollowupFeatureTests { @Test("택일 운세 생성 시 점수 순으로 상위 3개를 선별하여 저장한다") func dayFortuneCreatesAndSelectsTop3Results() async { let now = Date(timeIntervalSince1970: 1_788_969_600) - let results: [DayFortuneItem] = [ - .init(date: now, score: 60, title: "보통", content: "내용1"), - .init(date: now.addingTimeInterval(86_400), score: 95, title: "대길", content: "내용2"), - .init(date: now.addingTimeInterval(172_800), score: 80, title: "길", content: "내용3"), - .init(date: now.addingTimeInterval(259_200), score: 40, title: "소흉", content: "내용4") - ] + let r1 = DayFortuneResult(id: UUID(), purpose: .travel, targetDate: now, score: 60, title: "보통", content: "내용1", categories: []) + let r2 = DayFortuneResult(id: UUID(), purpose: .travel, targetDate: now.addingTimeInterval(86_400), score: 95, title: "대길", content: "내용2", categories: []) + let r3 = DayFortuneResult(id: UUID(), purpose: .travel, targetDate: now.addingTimeInterval(172_800), score: 80, title: "길", content: "내용3", categories: []) + let r4 = DayFortuneResult(id: UUID(), purpose: .travel, targetDate: now.addingTimeInterval(259_200), score: 40, title: "소흉", content: "내용4", categories: []) + let results = [r1, r2, r3, r4] + var state = DayFortuneFeature.State() state.selectedDates = [now, now.addingTimeInterval(86_400)] let store = TestStore(initialState: state) { @@ -180,9 +180,10 @@ struct FortuneFollowupFeatureTests { await store.send(.createTapped) { $0.isSubmitting = true } - await store.receive(.response(.success(results))) { + await store.receive(\.response.success) { $0.isSubmitting = false - $0.results = [results[1], results[2], results[0]] // 95, 80, 60 + $0.results = [r2, r3, r1] // 95, 80, 60 + $0.selectedResultID = r2.id } } From 036e8710b3da637b15cb35a9dd1604c8926dbde8 Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Sat, 15 Aug 2026 04:10:03 +0900 Subject: [PATCH 10/16] =?UTF-8?q?refactor(fortune):=20=EC=82=AC=EC=A3=BC?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=EC=A0=95=EB=B3=B4=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=ED=8F=BC=20=EA=B3=B5=ED=86=B5=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=ED=86=B5=ED=95=A9=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DSSajuBasicFieldsView 컴포넌트 신규 구현을 통해 성별, 음/양력, 생년월일, 태어난 시간 입력 폼 공통화 - BirthDate 및 BirthDatePolicy를 Core/Model에 구축하여 윤년, 말일 정규화, 미래 날짜 및 만 14세 유효성 검증 단일화 - FortuneFeature(궁합), MyPageFeature(상대방 관리/수정), OnboardingFeature에 공통 폼 마이그레이션 및 레거시 중복 코드 정리 - UnicodeScalar+Korean을 Core/Utils로 공통화하여 한글 이름 검증 단일화 - 폼 상태 전이, 미래 날짜/만 14세 정책, 궁합/상대방 사주 관리 단위 및 통합 테스트 보강 --- .../Form/DSSajuBasicFieldsView.swift | 181 +++++++++++++ .../Components/Form/DSSelectGender.swift | 11 +- .../Form/DSSelectLunarOrSolarCalendar.swift | 11 +- .../Tests/DSSajuBasicFieldsViewTests.swift | 32 +++ .../Sources/FortuneProfile/BirthDate.swift | 41 ++- .../FortuneProfile/BirthDateCalendar.swift | 14 + .../FortuneProfile/BirthDatePolicy.swift | 114 +++++++++ .../Model/Sources/FortuneProfile/Gender.swift | 14 + .../FortuneProfile/BirthDatePolicyTests.swift | 140 ++++++++++ .../Utils/Sources/UnicodeScalar+Korean.swift | 16 ++ .../Compatibility/CompatibilityFeature.swift | 27 +- .../Compatibility/CompatibilityView.swift | 239 +++--------------- .../Sources/Domain/FortuneSajuModels.swift | 4 +- .../Sources/FortuneClient+Live.swift | 2 +- .../Tests/FortuneClientTests.swift | 2 +- .../Tests/FortuneFollowupFeatureTests.swift | 137 ++++++++++ .../Sources/MyPageEditView.swift | 141 ++--------- .../MyPageFeature/Sources/MyPageFeature.swift | 34 +-- .../Sources/MyPageSajuManagementView.swift | 125 +-------- .../Sources/PartnerBirthDatePolicy.swift | 22 -- .../Tests/MyPageFeatureTests.swift | 65 ++++- .../Sources/FortuneInformationView.swift | 170 +------------ .../Sources/OnboardingMinimumAgePolicy.swift | 28 +- 23 files changed, 855 insertions(+), 715 deletions(-) create mode 100644 Projects/Core/DesignSystem/Sources/Components/Form/DSSajuBasicFieldsView.swift create mode 100644 Projects/Core/DesignSystem/Tests/DSSajuBasicFieldsViewTests.swift create mode 100644 Projects/Core/Model/Sources/FortuneProfile/BirthDatePolicy.swift create mode 100644 Projects/Core/Model/Tests/FortuneProfile/BirthDatePolicyTests.swift create mode 100644 Projects/Core/Utils/Sources/UnicodeScalar+Korean.swift delete mode 100644 Projects/Feature/MyPageFeature/Sources/PartnerBirthDatePolicy.swift diff --git a/Projects/Core/DesignSystem/Sources/Components/Form/DSSajuBasicFieldsView.swift b/Projects/Core/DesignSystem/Sources/Components/Form/DSSajuBasicFieldsView.swift new file mode 100644 index 0000000..84fdc27 --- /dev/null +++ b/Projects/Core/DesignSystem/Sources/Components/Form/DSSajuBasicFieldsView.swift @@ -0,0 +1,181 @@ +import SwiftUI +import Model + +public struct DSSajuBasicFieldsView: View { + @Binding private var gender: Gender? + @Binding private var calendarType: BirthDateCalendar? + @Binding private var birthDate: BirthDate? + @Binding private var birthTime: BirthTimePeriod? + @Binding private var isBirthTimeUnknown: Bool + + private let birthDateValidationMessage: String? + private let unknownTimeInfoText: String? + private let spacing: CGFloat + + @State private var isDatePickerPresented = false + @State private var isTimePickerPresented = false + @State private var pickerYear: Int = 1999 + @State private var pickerMonth: Int = 1 + @State private var pickerDay: Int = 1 + @State private var pickerBirthTimeRawValue: Int = BirthTimePeriod.inTime.rawValue + + private let calendar = Calendar(identifier: .gregorian) + + public init( + gender: Binding, + calendarType: Binding, + birthDate: Binding, + birthTime: Binding, + isBirthTimeUnknown: Binding, + birthDateValidationMessage: String? = nil, + unknownTimeInfoText: String? = nil, + spacing: CGFloat = 32 + ) { + self._gender = gender + self._calendarType = calendarType + self._birthDate = birthDate + self._birthTime = birthTime + self._isBirthTimeUnknown = isBirthTimeUnknown + self.birthDateValidationMessage = birthDateValidationMessage + self.unknownTimeInfoText = unknownTimeInfoText + self.spacing = spacing + } + + public var body: some View { + VStack(alignment: .leading, spacing: spacing) { + DSSelectGender(selection: $gender) + + DSSelectLunarOrSolarCalendar(selection: $calendarType) + + VStack(alignment: .leading, spacing: 8) { + DSEnterDateOfBirth( + selection: $birthDate, + isFocused: isDatePickerPresented + ) { + prepareBirthDatePicker() + isDatePickerPresented = true + } + + if let message = birthDateValidationMessage { + Text(message) + .dsCaption1Regular + .foregroundStyle(Color.ds.red500) + .padding(.horizontal, 4) + } + } + + VStack(alignment: .leading, spacing: 8) { + DSEnterTimeOfBirth( + selection: $birthTime, + isFocused: isTimePickerPresented, + isTimeUnknown: $isBirthTimeUnknown + ) { + prepareBirthTimePicker() + isTimePickerPresented = true + } + + if isBirthTimeUnknown, let info = unknownTimeInfoText { + Text(info) + .dsCaption1Regular + .foregroundStyle(Color.ds.sky600) + .padding(.horizontal, 4) + } + } + } + .dsWheelPickerSheet( + isPresented: $isDatePickerPresented, + layout: .date, + title: "생년월일 입력", + onSave: saveBirthDate + ) { + DSMultiWheelPicker( + layout: .date, + columns: [ + DSWheelPickerColumn( + items: birthYearItems, + selection: $pickerYear, + accessibilityLabel: "연도" + ), + DSWheelPickerColumn( + items: birthMonthItems, + selection: $pickerMonth, + accessibilityLabel: "월", + isCircular: true + ), + DSWheelPickerColumn( + items: birthDayItems, + selection: $pickerDay, + accessibilityLabel: "일" + ) + ] + ) + } + .dsWheelPickerSheet( + isPresented: $isTimePickerPresented, + layout: .single, + title: "태어난 시간 선택", + onSave: saveBirthTime + ) { + DSSingleWheelPicker( + items: BirthTimePeriod.allCases.map { + DSWheelPickerItem( + value: $0.rawValue, + title: DSEnterTimeOfBirth.pickerTitle(for: $0) + ) + }, + selection: $pickerBirthTimeRawValue, + accessibilityLabel: "태어난 시간" + ) + } + .onChange(of: pickerYear) { _, _ in normalizeBirthDatePicker() } + .onChange(of: pickerMonth) { _, _ in normalizeBirthDatePicker() } + } + + private var birthYearItems: [DSWheelPickerItem] { + let range = BirthDatePolicy.yearRange(asOf: Date(), calendar: calendar) + return range.map { DSWheelPickerItem(value: $0, title: "\($0)년") } + } + + private var birthMonthItems: [DSWheelPickerItem] { + let maxMonth = BirthDatePolicy.maximumMonth(forYear: pickerYear, asOf: Date(), calendar: calendar) + return (1...maxMonth).map { DSWheelPickerItem(value: $0, title: "\($0)월") } + } + + private var birthDayItems: [DSWheelPickerItem] { + let maxDay = BirthDatePolicy.maximumDay(forYear: pickerYear, month: pickerMonth, asOf: Date(), calendar: calendar) + return (1...maxDay).map { DSWheelPickerItem(value: $0, title: "\($0)일") } + } + + private func prepareBirthDatePicker() { + pickerYear = birthDate?.year ?? 1999 + pickerMonth = birthDate?.month ?? 1 + pickerDay = birthDate?.day ?? 1 + normalizeBirthDatePicker() + } + + private func prepareBirthTimePicker() { + pickerBirthTimeRawValue = (birthTime ?? .inTime).rawValue + } + + private func saveBirthDate() { + birthDate = BirthDate(year: pickerYear, month: pickerMonth, day: pickerDay) + isDatePickerPresented = false + } + + private func saveBirthTime() { + birthTime = BirthTimePeriod(rawValue: pickerBirthTimeRawValue) + isTimePickerPresented = false + } + + private func normalizeBirthDatePicker() { + let (normalizedMonth, normalizedDay) = BirthDatePolicy.normalize( + year: pickerYear, + month: pickerMonth, + day: pickerDay, + asOf: Date(), + calendar: calendar + ) + pickerMonth = normalizedMonth + pickerDay = normalizedDay + } +} diff --git a/Projects/Core/DesignSystem/Sources/Components/Form/DSSelectGender.swift b/Projects/Core/DesignSystem/Sources/Components/Form/DSSelectGender.swift index f90a496..53e6e61 100644 --- a/Projects/Core/DesignSystem/Sources/Components/Form/DSSelectGender.swift +++ b/Projects/Core/DesignSystem/Sources/Components/Form/DSSelectGender.swift @@ -43,7 +43,7 @@ public struct DSSelectGender: View { HStack(spacing: specification.optionSpacing) { ForEach(Gender.allCases, id: \.self) { option in DSSelectBox( - option.dsGenderTitle, + option.title, isSelected: selectionBinding(for: option) ) .frame(maxWidth: .infinity) @@ -68,12 +68,3 @@ public struct DSSelectGender: View { ) } } - -extension Gender { - var dsGenderTitle: String { - switch self { - case .male: "남성" - case .female: "여성" - } - } -} diff --git a/Projects/Core/DesignSystem/Sources/Components/Form/DSSelectLunarOrSolarCalendar.swift b/Projects/Core/DesignSystem/Sources/Components/Form/DSSelectLunarOrSolarCalendar.swift index 0ddb1f0..0f324f4 100644 --- a/Projects/Core/DesignSystem/Sources/Components/Form/DSSelectLunarOrSolarCalendar.swift +++ b/Projects/Core/DesignSystem/Sources/Components/Form/DSSelectLunarOrSolarCalendar.swift @@ -43,7 +43,7 @@ public struct DSSelectLunarOrSolarCalendar: View { HStack(spacing: specification.optionSpacing) { ForEach(BirthDateCalendar.allCases, id: \.self) { option in DSSelectBox( - option.dsCalendarTitle, + option.title, isSelected: selectionBinding(for: option) ) .frame(maxWidth: .infinity) @@ -68,12 +68,3 @@ public struct DSSelectLunarOrSolarCalendar: View { ) } } - -extension BirthDateCalendar { - var dsCalendarTitle: String { - switch self { - case .solar: "양력" - case .lunar: "음력" - } - } -} diff --git a/Projects/Core/DesignSystem/Tests/DSSajuBasicFieldsViewTests.swift b/Projects/Core/DesignSystem/Tests/DSSajuBasicFieldsViewTests.swift new file mode 100644 index 0000000..f0c43e9 --- /dev/null +++ b/Projects/Core/DesignSystem/Tests/DSSajuBasicFieldsViewTests.swift @@ -0,0 +1,32 @@ +import SwiftUI +import Testing +import Model +@testable import DesignSystem + +struct DSSajuBasicFieldsViewTests { + @Test("DSSajuBasicFieldsView 초기화 및 뷰 계층 구성 검증") + func testDSSajuBasicFieldsViewInitialization() { + var gender: Gender? = .female + var calendarType: BirthDateCalendar? = .solar + var birthDate: BirthDate? = BirthDate(year: 1995, month: 5, day: 20) + var birthTime: BirthTimePeriod? = .saTime + var isBirthTimeUnknown = false + + let view = DSSajuBasicFieldsView( + gender: Binding(get: { gender }, set: { gender = $0 }), + calendarType: Binding(get: { calendarType }, set: { calendarType = $0 }), + birthDate: Binding(get: { birthDate }, set: { birthDate = $0 }), + birthTime: Binding(get: { birthTime }, set: { birthTime = $0 }), + isBirthTimeUnknown: Binding(get: { isBirthTimeUnknown }, set: { isBirthTimeUnknown = $0 }), + birthDateValidationMessage: "생년월일 에러", + unknownTimeInfoText: "시간 안내", + spacing: 24 + ) + + #expect(gender == .female) + #expect(calendarType == .solar) + #expect(birthDate?.year == 1995) + #expect(birthTime == .saTime) + #expect(!isBirthTimeUnknown) + } +} diff --git a/Projects/Core/Model/Sources/FortuneProfile/BirthDate.swift b/Projects/Core/Model/Sources/FortuneProfile/BirthDate.swift index a855c0e..1d22194 100644 --- a/Projects/Core/Model/Sources/FortuneProfile/BirthDate.swift +++ b/Projects/Core/Model/Sources/FortuneProfile/BirthDate.swift @@ -1,4 +1,6 @@ -public struct BirthDate: Hashable, Sendable { +import Foundation + +public struct BirthDate: Hashable, Sendable, Comparable, Codable, CustomStringConvertible { public let year: Int public let month: Int public let day: Int @@ -12,4 +14,41 @@ public struct BirthDate: Hashable, Sendable { self.month = month self.day = day } + + public init?(yyyyMMdd: String) { + let components = yyyyMMdd.split(separator: "-").compactMap { Int($0) } + guard components.count == 3 else { return nil } + self.init(year: components[0], month: components[1], day: components[2]) + } + + public init(date: Date, calendar: Calendar = .current) { + let components = calendar.dateComponents([.year, .month, .day], from: date) + self.year = components.year ?? 2000 + self.month = components.month ?? 1 + self.day = components.day ?? 1 + } + + public func toDate(calendar: Calendar = .current) -> Date? { + let components = DateComponents(year: year, month: month, day: day) + guard let date = calendar.date(from: components) else { return nil } + let resolved = calendar.dateComponents([.year, .month, .day], from: date) + guard resolved.year == year, resolved.month == month, resolved.day == day else { + return nil + } + return date + } + + public var description: String { + String(format: "%04d-%02d-%02d", year, month, day) + } + + public var formattedDate: String { + description + } + + public static func < (lhs: BirthDate, rhs: BirthDate) -> Bool { + if lhs.year != rhs.year { return lhs.year < rhs.year } + if lhs.month != rhs.month { return lhs.month < rhs.month } + return lhs.day < rhs.day + } } diff --git a/Projects/Core/Model/Sources/FortuneProfile/BirthDateCalendar.swift b/Projects/Core/Model/Sources/FortuneProfile/BirthDateCalendar.swift index 6132383..f3b9eec 100644 --- a/Projects/Core/Model/Sources/FortuneProfile/BirthDateCalendar.swift +++ b/Projects/Core/Model/Sources/FortuneProfile/BirthDateCalendar.swift @@ -1,4 +1,18 @@ public enum BirthDateCalendar: CaseIterable, Hashable, Sendable { case solar case lunar + + public var title: String { + switch self { + case .solar: "양력" + case .lunar: "음력" + } + } + + public var apiValue: String { + switch self { + case .solar: "SOLAR" + case .lunar: "LUNAR" + } + } } diff --git a/Projects/Core/Model/Sources/FortuneProfile/BirthDatePolicy.swift b/Projects/Core/Model/Sources/FortuneProfile/BirthDatePolicy.swift new file mode 100644 index 0000000..2035bf0 --- /dev/null +++ b/Projects/Core/Model/Sources/FortuneProfile/BirthDatePolicy.swift @@ -0,0 +1,114 @@ +import Foundation + +public enum BirthDatePolicy: Sendable { + public static let minimumYear = 1900 + public static let futureDateMessage = "생년월일은 오늘 이전으로 선택해 주세요." + public static let invalidDateMessage = "생년월일을 다시 확인해 주세요." + public static let minimumAgeMessage = "토닥운은 만 14세부터 가입할 수 있어요" + + public static func yearRange( + from minYear: Int = minimumYear, + asOf referenceDate: Date = Date(), + calendar: Calendar = .current + ) -> ClosedRange { + let currentYear = calendar.component(.year, from: referenceDate) + return minYear...max(minYear, currentYear) + } + + public static func maximumMonth( + forYear year: Int, + asOf referenceDate: Date = Date(), + calendar: Calendar = .current + ) -> Int { + let currentYear = calendar.component(.year, from: referenceDate) + if year >= currentYear { + return calendar.component(.month, from: referenceDate) + } + return 12 + } + + public static func maximumDay( + forYear year: Int, + month: Int, + asOf referenceDate: Date = Date(), + calendar: Calendar = .current + ) -> Int { + let currentYear = calendar.component(.year, from: referenceDate) + let currentMonth = calendar.component(.month, from: referenceDate) + let currentDay = calendar.component(.day, from: referenceDate) + + let daysInCurrentMonth = daysInMonth(year: year, month: month, calendar: calendar) + + if year == currentYear && month == currentMonth { + return min(daysInCurrentMonth, currentDay) + } + return daysInCurrentMonth + } + + public static func daysInMonth( + year: Int, + month: Int, + calendar: Calendar = .current + ) -> Int { + let dateComponents = DateComponents(year: year, month: month, day: 1) + guard let date = calendar.date(from: dateComponents), + let range = calendar.range(of: .day, in: .month, for: date) else { + return 31 + } + return range.count + } + + public static func isLeapYear(_ year: Int) -> Bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) + } + + public static func normalize( + year: Int, + month: Int, + day: Int, + asOf referenceDate: Date = Date(), + calendar: Calendar = .current + ) -> (month: Int, day: Int) { + let maxMonth = maximumMonth(forYear: year, asOf: referenceDate, calendar: calendar) + let normalizedMonth = min(max(month, 1), maxMonth) + let maxDay = maximumDay(forYear: year, month: normalizedMonth, asOf: referenceDate, calendar: calendar) + let normalizedDay = min(max(day, 1), maxDay) + return (normalizedMonth, normalizedDay) + } + + public static func validateNotInFuture( + for birthDate: BirthDate?, + asOf referenceDate: Date = Date(), + calendar: Calendar = .current + ) -> String? { + guard let birthDate else { return nil } + guard let date = birthDate.toDate(calendar: calendar) else { + return invalidDateMessage + } + return calendar.startOfDay(for: date) <= calendar.startOfDay(for: referenceDate) + ? nil + : futureDateMessage + } + + public static func validateMinimumAge( + for birthDate: BirthDate?, + minimumAge: Int = 14, + asOf referenceDate: Date = Date(), + calendar: Calendar = .current + ) -> String? { + guard let birthDate else { return nil } + guard let dateOfBirth = birthDate.toDate(calendar: calendar) else { + return invalidDateMessage + } + if calendar.startOfDay(for: dateOfBirth) > calendar.startOfDay(for: referenceDate) { + return futureDateMessage + } + guard let eligibleDate = calendar.date(byAdding: .year, value: minimumAge, to: dateOfBirth) else { + return invalidDateMessage + } + + return calendar.startOfDay(for: referenceDate) < calendar.startOfDay(for: eligibleDate) + ? minimumAgeMessage + : nil + } +} diff --git a/Projects/Core/Model/Sources/FortuneProfile/Gender.swift b/Projects/Core/Model/Sources/FortuneProfile/Gender.swift index 110a22e..75925ec 100644 --- a/Projects/Core/Model/Sources/FortuneProfile/Gender.swift +++ b/Projects/Core/Model/Sources/FortuneProfile/Gender.swift @@ -1,4 +1,18 @@ public enum Gender: CaseIterable, Hashable, Sendable { case male case female + + public var title: String { + switch self { + case .male: "남성" + case .female: "여성" + } + } + + public var apiValue: String { + switch self { + case .male: "MALE" + case .female: "FEMALE" + } + } } diff --git a/Projects/Core/Model/Tests/FortuneProfile/BirthDatePolicyTests.swift b/Projects/Core/Model/Tests/FortuneProfile/BirthDatePolicyTests.swift new file mode 100644 index 0000000..5bb1447 --- /dev/null +++ b/Projects/Core/Model/Tests/FortuneProfile/BirthDatePolicyTests.swift @@ -0,0 +1,140 @@ +import Foundation +import Testing +@testable import Model + +struct BirthDatePolicyTests { + private var testCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private var referenceDate: Date { + let components = DateComponents(year: 2026, month: 8, day: 15) + return testCalendar.date(from: components)! + } + + @Test("윤년 2월은 29일까지이고 평년 2월은 28일까지이다") + func testDaysInMonthForFebruary() { + #expect(BirthDatePolicy.daysInMonth(year: 2024, month: 2, calendar: testCalendar) == 29) + #expect(BirthDatePolicy.daysInMonth(year: 2023, month: 2, calendar: testCalendar) == 28) + #expect(BirthDatePolicy.daysInMonth(year: 2000, month: 2, calendar: testCalendar) == 29) + #expect(BirthDatePolicy.daysInMonth(year: 1900, month: 2, calendar: testCalendar) == 28) + } + + @Test("현재 연도 선택 시 현재 월 및 일자를 초과할 수 없다") + func testMaximumMonthAndDayForCurrentYear() { + #expect(BirthDatePolicy.maximumMonth(forYear: 2026, asOf: referenceDate, calendar: testCalendar) == 8) + #expect(BirthDatePolicy.maximumMonth(forYear: 2025, asOf: referenceDate, calendar: testCalendar) == 12) + + #expect(BirthDatePolicy.maximumDay(forYear: 2026, month: 8, asOf: referenceDate, calendar: testCalendar) == 15) + #expect(BirthDatePolicy.maximumDay(forYear: 2026, month: 7, asOf: referenceDate, calendar: testCalendar) == 31) + } + + @Test("정규화 로직은 31일이 없는 달의 일수를 올바르게 보정한다") + func testNormalizeAdjustsInvalidDay() { + let (month1, day1) = BirthDatePolicy.normalize( + year: 2024, + month: 2, + day: 31, + asOf: referenceDate, + calendar: testCalendar + ) + #expect(month1 == 2) + #expect(day1 == 29) + + let (month2, day2) = BirthDatePolicy.normalize( + year: 2023, + month: 2, + day: 31, + asOf: referenceDate, + calendar: testCalendar + ) + #expect(month2 == 2) + #expect(day2 == 28) + + let (month3, day3) = BirthDatePolicy.normalize( + year: 2026, + month: 8, + day: 20, + asOf: referenceDate, + calendar: testCalendar + ) + #expect(month3 == 8) + #expect(day3 == 15) + } + + @Test("연도 선택 범위 검증") + func testYearRange() { + let range = BirthDatePolicy.yearRange(from: 1900, asOf: referenceDate, calendar: testCalendar) + #expect(range.lowerBound == 1900) + #expect(range.upperBound == 2026) + } + + @Test("미래 날짜 유효성 검증") + func testValidateNotInFuture() { + #expect(BirthDatePolicy.validateNotInFuture(for: nil, asOf: referenceDate, calendar: testCalendar) == nil) + + let validDate = BirthDate(year: 2026, month: 8, day: 14) + #expect(BirthDatePolicy.validateNotInFuture(for: validDate, asOf: referenceDate, calendar: testCalendar) == nil) + + let todayDate = BirthDate(year: 2026, month: 8, day: 15) + #expect(BirthDatePolicy.validateNotInFuture(for: todayDate, asOf: referenceDate, calendar: testCalendar) == nil) + + let futureDate = BirthDate(year: 2026, month: 8, day: 16) + #expect(BirthDatePolicy.validateNotInFuture(for: futureDate, asOf: referenceDate, calendar: testCalendar) == BirthDatePolicy.futureDateMessage) + + let invalidDate = BirthDate(year: 2023, month: 2, day: 29) + #expect(BirthDatePolicy.validateNotInFuture(for: invalidDate, asOf: referenceDate, calendar: testCalendar) == BirthDatePolicy.invalidDateMessage) + } + + @Test("만 14세 미만 유효성 검증") + func testValidateMinimumAge() { + #expect(BirthDatePolicy.validateMinimumAge(for: nil, minimumAge: 14, asOf: referenceDate, calendar: testCalendar) == nil) + + let eligibleDate = BirthDate(year: 2012, month: 8, day: 15) + #expect(BirthDatePolicy.validateMinimumAge(for: eligibleDate, minimumAge: 14, asOf: referenceDate, calendar: testCalendar) == nil) + + let underAgeDate = BirthDate(year: 2012, month: 8, day: 16) + #expect(BirthDatePolicy.validateMinimumAge(for: underAgeDate, minimumAge: 14, asOf: referenceDate, calendar: testCalendar) == BirthDatePolicy.minimumAgeMessage) + + let futureDate = BirthDate(year: 2027, month: 1, day: 1) + #expect(BirthDatePolicy.validateMinimumAge(for: futureDate, minimumAge: 14, asOf: referenceDate, calendar: testCalendar) == BirthDatePolicy.futureDateMessage) + } + + @Test("BirthDate Date 변환 및 Comparable 검증") + func testBirthDateConversionAndComparison() { + let birthDate = BirthDate(year: 1995, month: 5, day: 20) + let convertedDate = birthDate.toDate(calendar: testCalendar) + #expect(convertedDate != nil) + + let backToBirthDate = BirthDate(date: convertedDate!, calendar: testCalendar) + #expect(backToBirthDate == birthDate) + + let older = BirthDate(year: 1990, month: 1, day: 1) + let younger = BirthDate(year: 2000, month: 1, day: 1) + #expect(older < younger) + #expect(birthDate.description == "1995-05-20") + #expect(birthDate.formattedDate == "1995-05-20") + + let invalidDate = BirthDate(year: 2024, month: 2, day: 31) + #expect(invalidDate.toDate(calendar: testCalendar) == nil) + + let parsed = BirthDate(yyyyMMdd: "1999-12-31") + #expect(parsed == BirthDate(year: 1999, month: 12, day: 31)) + #expect(BirthDate(yyyyMMdd: "invalid") == nil) + } + + @Test("Gender 및 BirthDateCalendar 메타데이터 검증") + func testGenderAndCalendarMetadata() { + #expect(Gender.male.title == "남성") + #expect(Gender.female.title == "여성") + #expect(Gender.male.apiValue == "MALE") + #expect(Gender.female.apiValue == "FEMALE") + + #expect(BirthDateCalendar.solar.title == "양력") + #expect(BirthDateCalendar.lunar.title == "음력") + #expect(BirthDateCalendar.solar.apiValue == "SOLAR") + #expect(BirthDateCalendar.lunar.apiValue == "LUNAR") + } +} diff --git a/Projects/Core/Utils/Sources/UnicodeScalar+Korean.swift b/Projects/Core/Utils/Sources/UnicodeScalar+Korean.swift new file mode 100644 index 0000000..a2c3583 --- /dev/null +++ b/Projects/Core/Utils/Sources/UnicodeScalar+Korean.swift @@ -0,0 +1,16 @@ +import Foundation + +public extension UnicodeScalar { + var isKoreanNameScalar: Bool { + switch value { + case 0xAC00 ... 0xD7A3, + 0x1100 ... 0x11FF, + 0x3130 ... 0x318F, + 0xA960 ... 0xA97F, + 0xD7B0 ... 0xD7FF: + true + default: + false + } + } +} diff --git a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift index 61dfbab..b58b6a1 100644 --- a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift +++ b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import Foundation import Model +import Utils // 등록, 선택, 결과 상태는 하나의 화면 단위 상태 머신으로 구성한다. // swiftlint:disable type_body_length @@ -44,8 +45,8 @@ public struct CompatibilityFeature { public var name = "" public var gender: Gender? - public var calendarType: BirthDateCalendar = .solar - public var birthDate = Date(timeIntervalSince1970: 946_684_800) + public var calendarType: BirthDateCalendar? = .solar + public var birthDate: BirthDate? public var birthTime: BirthTimePeriod? public var isBirthTimeUnknown = false public var relationship: Relationship? @@ -58,9 +59,9 @@ public struct CompatibilityFeature { public var nameValidationMessage: String? { guard !name.isEmpty else { return nil } - guard name.count <= 10 else { return "최대 10글자까지 입력 가능해요." } - guard name.allSatisfy({ $0.isLetter || $0.isNumber }) else { - return "한글, 영문, 숫자만 입력할 수 있어요." + guard name.count <= 10 else { return "이름은 최대 10글자까지 가능해요." } + guard name.unicodeScalars.allSatisfy(\.isKoreanNameScalar) else { + return "이름은 한글만 가능해요." } return nil } @@ -69,6 +70,9 @@ public struct CompatibilityFeature { !name.isEmpty && nameValidationMessage == nil && gender != nil + && calendarType != nil + && birthDate != nil + && BirthDatePolicy.validateNotInFuture(for: birthDate) == nil && (birthTime != nil || isBirthTimeUnknown) && relationship != nil && !isSubmitting @@ -86,8 +90,8 @@ public struct CompatibilityFeature { case registrationPresented(Bool) case nameChanged(String) case genderChanged(Gender?) - case calendarTypeChanged(BirthDateCalendar) - case birthDateChanged(Date) + case calendarTypeChanged(BirthDateCalendar?) + case birthDateChanged(BirthDate?) case birthTimeChanged(BirthTimePeriod?) case birthTimeUnknownChanged(Bool) case relationshipChanged(Relationship?) @@ -212,6 +216,8 @@ public struct CompatibilityFeature { guard state.canRegister, let gender = state.gender, + let calendarType = state.calendarType, + let birthDate = state.birthDate, let relationship = state.relationship else { return .none } @@ -220,8 +226,8 @@ public struct CompatibilityFeature { let input = PartnerRegistrationInput( name: state.name, gender: gender, - calendarType: state.calendarType, - birthDate: state.birthDate, + calendarType: calendarType, + birthDate: birthDate, birthTime: state.birthTime, isBirthTimeUnknown: state.isBirthTimeUnknown, relationship: relationship @@ -240,6 +246,7 @@ public struct CompatibilityFeature { state.isSubmitting = false state.isRegistrationPresented = false state.selectedPartnerID = partnerID + resetForm(state: &state) return fetchPartners() case let .registrationResponse(.failure(error)): @@ -361,7 +368,7 @@ public struct CompatibilityFeature { state.name = "" state.gender = nil state.calendarType = .solar - state.birthDate = Date(timeIntervalSince1970: 946_684_800) + state.birthDate = nil state.birthTime = nil state.isBirthTimeUnknown = false state.relationship = nil diff --git a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift index 8af3fa0..22d8db7 100644 --- a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift +++ b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift @@ -259,60 +259,38 @@ private struct CompatibilityMainContent: View { private struct PartnerRegistrationView: View { @Bindable var store: StoreOf - @State private var isDatePickerPresented = false - @State private var isTimePickerPresented = false - - @State private var pickerYear = 1999 - @State private var pickerMonth = 1 - @State private var pickerDay = 1 - @State private var pickerBirthTimeRawValue = BirthTimePeriod.inTime.rawValue - - private let calendar = Calendar.current var body: some View { ZStack { Color.ds.white.ignoresSafeArea() VStack(spacing: 0) { - FortuneHeaderView( + DSHeaderSub( title: "상대방 정보 입력", - isDark: false - ) { - store.send(.registrationPresented(false)) - } + leftItem: DSHeaderActionItem( + identifier: "back", + icon: .chevronLeftNarrow, + action: { store.send(.registrationPresented(false)) } + ) + ) ScrollView { - VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 32) { DSEnterName( text: $store.name.sending(\.nameChanged), validationState: nameValidationState ) - DSSelectGender( - selection: $store.gender.sending(\.genderChanged) - ) - - DSSelectLunarOrSolarCalendar( - selection: calendarTypeBinding + DSSajuBasicFieldsView( + gender: $store.gender.sending(\.genderChanged), + calendarType: $store.calendarType.sending(\.calendarTypeChanged), + birthDate: $store.birthDate.sending(\.birthDateChanged), + birthTime: $store.birthTime.sending(\.birthTimeChanged), + isBirthTimeUnknown: $store.isBirthTimeUnknown.sending(\.birthTimeUnknownChanged), + birthDateValidationMessage: BirthDatePolicy.validateNotInFuture(for: store.birthDate, asOf: Date()), + spacing: 32 ) - DSEnterDateOfBirth( - selection: birthDateBinding, - isFocused: isDatePickerPresented - ) { - prepareBirthDatePicker() - isDatePickerPresented = true - } - - DSEnterTimeOfBirth( - selection: $store.birthTime.sending(\.birthTimeChanged), - isFocused: isTimePickerPresented, - isTimeUnknown: $store.isBirthTimeUnknown.sending(\.birthTimeUnknownChanged) - ) { - prepareBirthTimePicker() - isTimePickerPresented = true - } - DSSelectRelationship( selection: $store.relationship.sending(\.relationshipChanged) ) @@ -358,48 +336,6 @@ private struct PartnerRegistrationView: View { ) ) } - .dsWheelPickerSheet( - isPresented: $isDatePickerPresented, - layout: .date, - title: "생년월일 입력", - onSave: saveBirthDate - ) { - DSMultiWheelPicker( - layout: .date, - columns: [ - DSWheelPickerColumn( - items: birthYearItems, - selection: $pickerYear, - accessibilityLabel: "연도" - ), - DSWheelPickerColumn( - items: birthMonthItems, - selection: $pickerMonth, - accessibilityLabel: "월", - isCircular: true - ), - DSWheelPickerColumn( - items: birthDayItems, - selection: $pickerDay, - accessibilityLabel: "일" - ) - ] - ) - } - .dsWheelPickerSheet( - isPresented: $isTimePickerPresented, - layout: .single, - title: "태어난 시각 선택", - onSave: saveBirthTime - ) { - DSSingleWheelPicker( - items: birthTimeItems, - selection: $pickerBirthTimeRawValue, - accessibilityLabel: "태어난 시각" - ) - } - .onChange(of: pickerYear) { _, _ in normalizeBirthDatePicker() } - .onChange(of: pickerMonth) { _, _ in normalizeBirthDatePicker() } } private var nameValidationState: DSTextFieldValidationState { @@ -408,92 +344,6 @@ private struct PartnerRegistrationView: View { } return .none } - - private var calendarTypeBinding: Binding { - Binding( - get: { store.calendarType }, - set: { if let type = $0 { store.send(.calendarTypeChanged(type)) } } - ) - } - - private var birthDateBinding: Binding { - Binding( - get: { store.birthDate.toBirthDate }, - set: { if let birthDate = $0 { store.send(.birthDateChanged(birthDate.toDate)) } } - ) - } - - private var currentDateComponents: DateComponents { - calendar.dateComponents([.year, .month, .day], from: Date()) - } - - private var birthYearItems: [DSWheelPickerItem] { - (1900...(currentDateComponents.year ?? 1999)).map { - DSWheelPickerItem(value: $0, title: "\($0)년") - } - } - - private var birthMonthItems: [DSWheelPickerItem] { - let maximumMonth = pickerYear == currentDateComponents.year - ? currentDateComponents.month ?? 12 - : 12 - return (1...maximumMonth).map { - DSWheelPickerItem(value: $0, title: "\($0)월") - } - } - - private var birthDayItems: [DSWheelPickerItem] { - let date = calendar.date(from: DateComponents(year: pickerYear, month: pickerMonth)) ?? Date() - let daysInMonth = calendar.range(of: .day, in: .month, for: date)?.count ?? 28 - let maximumDay = pickerYear == currentDateComponents.year - && pickerMonth == currentDateComponents.month - ? min(daysInMonth, currentDateComponents.day ?? daysInMonth) - : daysInMonth - return (1...maximumDay).map { - DSWheelPickerItem(value: $0, title: "\($0)일") - } - } - - private var birthTimeItems: [DSWheelPickerItem] { - BirthTimePeriod.allCases.map { period in - DSWheelPickerItem( - value: period.rawValue, - title: DSEnterTimeOfBirth.pickerTitle(for: period) - ) - } - } - - private func prepareBirthDatePicker() { - let selected = store.birthDate.toBirthDate - pickerYear = selected.year - pickerMonth = selected.month - pickerDay = selected.day - normalizeBirthDatePicker() - } - - private func prepareBirthTimePicker() { - pickerBirthTimeRawValue = (store.birthTime ?? .inTime).rawValue - } - - private func saveBirthDate() { - let newBirthDate = BirthDate(year: pickerYear, month: pickerMonth, day: pickerDay) - store.send(.birthDateChanged(newBirthDate.toDate)) - isDatePickerPresented = false - } - - private func saveBirthTime() { - store.send(.birthTimeChanged(BirthTimePeriod(rawValue: pickerBirthTimeRawValue))) - isTimePickerPresented = false - } - - private func normalizeBirthDatePicker() { - if let maximumMonth = birthMonthItems.last?.value { - pickerMonth = min(pickerMonth, maximumMonth) - } - if let maximumDay = birthDayItems.last?.value { - pickerDay = min(pickerDay, maximumDay) - } - } } // MARK: - 결과 화면 @@ -815,10 +665,23 @@ private struct SajuChartCard: View { private var titleText: String { let name = chart.name?.nonEmpty ?? fallbackName if let gender = chart.gender { - return "\(name) · \(gender.displayTitle)" + return "\(name) · \(gender.title)" + } + return name + } + + private func sajuInfoSubtitle(for chart: SajuChartDetail) -> String { + let calendar = chart.calendarType.map { $0.title } ?? "양력" + let date = Self.dateFormatter.string(from: chart.birthDate) + let time: String + if chart.isBirthTimeUnknown { + time = "태어난 시각 모름" + } else if let birthTime = chart.birthTime { + time = birthTime.displayText } else { - return name + time = "" } + return [date + " " + calendar, time].filter { !$0.isEmpty }.joined(separator: " · ") } var body: some View { @@ -883,7 +746,7 @@ private struct SajuChartCard: View { private var birthInformation: String { let date = Self.dateFormatter.string(from: chart.birthDate) - let calendar = chart.calendarType.map { $0.displayTitle } ?? "양력" + let calendar = chart.calendarType.map { $0.title } ?? "양력" let time: String if chart.isBirthTimeUnknown { time = "태어난 시각 모름" @@ -1002,44 +865,6 @@ private struct FortuneErrorBanner: View { } } -private extension Date { - var toBirthDate: BirthDate { - let calendar = Calendar.current - let year = calendar.component(.year, from: self) - let month = calendar.component(.month, from: self) - let day = calendar.component(.day, from: self) - return BirthDate(year: year, month: month, day: day) - } -} - -private extension BirthDate { - var toDate: Date { - var components = DateComponents() - components.year = year - components.month = month - components.day = day - return Calendar.current.date(from: components) ?? Date() - } -} - -private extension Gender { - var displayTitle: String { - switch self { - case .male: "남성" - case .female: "여성" - } - } -} - -private extension BirthDateCalendar { - var displayTitle: String { - switch self { - case .solar: "양력" - case .lunar: "음력" - } - } -} - private extension String { var nonEmpty: String? { isEmpty ? nil : self diff --git a/Projects/Feature/FortuneFeature/Sources/Domain/FortuneSajuModels.swift b/Projects/Feature/FortuneFeature/Sources/Domain/FortuneSajuModels.swift index ac1efbf..b3d5556 100644 --- a/Projects/Feature/FortuneFeature/Sources/Domain/FortuneSajuModels.swift +++ b/Projects/Feature/FortuneFeature/Sources/Domain/FortuneSajuModels.swift @@ -82,7 +82,7 @@ public struct PartnerRegistrationInput: Equatable, Sendable { public let name: String public let gender: Gender public let calendarType: BirthDateCalendar - public let birthDate: Date + public let birthDate: BirthDate public let birthTime: BirthTimePeriod? public let isBirthTimeUnknown: Bool public let relationship: Relationship @@ -91,7 +91,7 @@ public struct PartnerRegistrationInput: Equatable, Sendable { name: String, gender: Gender, calendarType: BirthDateCalendar, - birthDate: Date, + birthDate: BirthDate, birthTime: BirthTimePeriod?, isBirthTimeUnknown: Bool, relationship: Relationship diff --git a/Projects/Feature/FortuneFeature/Sources/FortuneClient+Live.swift b/Projects/Feature/FortuneFeature/Sources/FortuneClient+Live.swift index 02dc875..9cce54b 100644 --- a/Projects/Feature/FortuneFeature/Sources/FortuneClient+Live.swift +++ b/Projects/Feature/FortuneFeature/Sources/FortuneClient+Live.swift @@ -467,7 +467,7 @@ private struct RegisterPartnerSajuRequestDTO: Encodable { name = input.name gender = input.gender.serverValue calendarType = input.calendarType.serverValue - birthDate = formatFortuneDate(input.birthDate) + birthDate = input.birthDate.description birthTime = input.isBirthTimeUnknown ? "UNKNOWN" : (input.birthTime?.serverValue ?? "UNKNOWN") relationshipType = input.relationship.serverValue } diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneClientTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneClientTests.swift index fa14dee..4b678b8 100644 --- a/Projects/Feature/FortuneFeature/Tests/FortuneClientTests.swift +++ b/Projects/Feature/FortuneFeature/Tests/FortuneClientTests.swift @@ -377,7 +377,7 @@ struct FortuneClientTests { name: "동료1", gender: .male, calendarType: .lunar, - birthDate: try #require(makeDate("2000-01-01")), + birthDate: BirthDate(year: 2000, month: 1, day: 1), birthTime: .jaTime, isBirthTimeUnknown: false, relationship: .colleague diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift index e95e392..d656c76 100644 --- a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift +++ b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift @@ -197,6 +197,143 @@ struct FortuneFollowupFeatureTests { await store.receive(.delegate(.myInfoEditRequested)) } + @Test("궁합 화면에서 상대방 사주를 성공적으로 등록하면 폼을 닫고 목록을 새로고침한다") + func compatibilityRegistersPartnerSuccessfully() async { + let partnerID = UUID(30) + let partner = FortunePartner(id: partnerID, name: "영희", relationship: .lover) + let chart = makeSajuChart(id: partnerID, name: "영희") + + var state = CompatibilityFeature.State() + state.isRegistrationPresented = true + state.name = "영희" + state.gender = .female + state.calendarType = .solar + state.birthDate = BirthDate(year: 2000, month: 1, day: 1) + state.birthTime = .inTime + state.isBirthTimeUnknown = false + state.relationship = .lover + + #expect(state.canRegister) + + let store = TestStore(initialState: state) { + CompatibilityFeature() + } withDependencies: { + $0.fortuneClient.registerPartner = { _ in partnerID } + $0.fortuneClient.fetchPartners = { [partner] } + $0.fortuneClient.fetchPartnerSaju = { _ in chart } + } + + await store.send(.registerTapped) { + $0.isSubmitting = true + $0.errorMessage = nil + } + await store.receive(.registrationResponse(.success(partnerID))) { + $0.isSubmitting = false + $0.isRegistrationPresented = false + $0.name = "" + $0.gender = nil + $0.calendarType = .solar + $0.birthDate = nil + $0.birthTime = nil + $0.isBirthTimeUnknown = false + $0.relationship = nil + $0.errorMessage = nil + } + await store.receive(.partnersResponse(.success([partner]))) { + $0.partners = [partner] + $0.selectedPartnerID = partnerID + } + await store.receive(.partnerSajuResponse(partnerID, .success(chart))) { + $0.selectedPartnerSaju = chart + } + } + + @Test("궁합 화면에서 궁합 보기 탭 시 궁합 결과를 생성하고 상태를 갱신한다") + func compatibilityCreatesResultSuccessfully() async { + let partnerID = UUID(31) + let partner = FortunePartner(id: partnerID, name: "영희", relationship: .lover) + let result = CompatibilityResult( + id: UUID(32), + score: 90, + title: "천생연분", + content: "궁합 총평", + categories: [.init(category: .love, score: 95)] + ) + + var state = CompatibilityFeature.State() + state.partners = [partner] + state.selectedPartnerID = partnerID + + let store = TestStore(initialState: state) { + CompatibilityFeature() + } withDependencies: { + $0.fortuneClient.createCompatibility = { _, _ in result } + } + + await store.send(.compatibilityTapped) { + $0.isSubmitting = true + $0.errorMessage = nil + } + await store.receive(.compatibilityResponse(.success(result))) { + $0.isSubmitting = false + $0.result = result + } + } + + @Test("궁합 생성 실패 시 에러 메시지를 표시하고 제출 상태를 해제한다") + func compatibilityHandlesCreationFailure() async { + let partnerID = UUID(33) + let partner = FortunePartner(id: partnerID, name: "영희", relationship: .lover) + + var state = CompatibilityFeature.State() + state.partners = [partner] + state.selectedPartnerID = partnerID + + let store = TestStore(initialState: state) { + CompatibilityFeature() + } withDependencies: { + $0.fortuneClient.createCompatibility = { _, _ in throw FortuneClientError.transport } + } + + await store.send(.compatibilityTapped) { + $0.isSubmitting = true + $0.errorMessage = nil + } + await store.receive(.compatibilityResponse(.failure(.transport))) { + $0.isSubmitting = false + $0.errorMessage = FortuneClientError.transport.userMessage + } + } + + @Test("상대방 변경 시 이전 사주를 초기화하고 새로운 상대방 사주를 조회한다") + func compatibilityPartnerSelectionUpdatesSaju() async { + let p1 = FortunePartner(id: UUID(34), name: "영희", relationship: .lover) + let p2 = FortunePartner(id: UUID(35), name: "철수", relationship: .friend) + let chart2 = makeSajuChart(id: p2.id, name: "철수") + + var state = CompatibilityFeature.State() + state.partners = [p1, p2] + state.selectedPartnerID = p1.id + state.selectedPartnerSaju = makeSajuChart(id: p1.id, name: "영희") + state.isPartnerPickerPresented = true + + let store = TestStore(initialState: state) { + CompatibilityFeature() + } withDependencies: { + $0.fortuneClient.fetchPartnerSaju = { _ in chart2 } + } + + await store.send(.partnerSelected(p2.id)) { + $0.selectedPartnerID = p2.id + $0.selectedPartnerSaju = nil + $0.isPartnerPickerPresented = false + } + await store.receive(.partnerSajuResponse(p2.id, .success(chart2))) { + $0.selectedPartnerSaju = chart2 + $0.errorMessage = nil + } + } + private func makeSajuChart(id chartID: UUID, name: String) -> SajuChartDetail { SajuChartDetail( id: chartID, diff --git a/Projects/Feature/MyPageFeature/Sources/MyPageEditView.swift b/Projects/Feature/MyPageFeature/Sources/MyPageEditView.swift index 7731e02..97f81ba 100644 --- a/Projects/Feature/MyPageFeature/Sources/MyPageEditView.swift +++ b/Projects/Feature/MyPageFeature/Sources/MyPageEditView.swift @@ -5,14 +5,6 @@ import SwiftUI struct MyPageEditView: View { @Bindable private var store: StoreOf - @State private var isBirthDatePickerPresented = false - @State private var isBirthTimePickerPresented = false - @State private var pickerYear = 1999 - @State private var pickerMonth = 1 - @State private var pickerDay = 1 - @State private var pickerBirthTimeRawValue = BirthTimePeriod.inTime.rawValue - - private let calendar = Calendar(identifier: .gregorian) init(store: StoreOf) { self.store = store @@ -24,30 +16,23 @@ struct MyPageEditView: View { title: "내 정보 수정", leftItem: DSHeaderActionItem( identifier: "back", - icon: .chevronLeftPlain, + icon: .chevronLeftNarrow, action: { store.send(.editDismissButtonTapped) } ) ) ScrollView { VStack(alignment: .leading, spacing: 40) { - DSSelectGender(selection: genderBinding) - DSSelectLunarOrSolarCalendar(selection: calendarBinding) - DSEnterDateOfBirth( - selection: birthDateBinding, - isFocused: isBirthDatePickerPresented - ) { - prepareBirthDatePicker() - isBirthDatePickerPresented = true - } - DSEnterTimeOfBirth( - selection: birthTimeBinding, - isFocused: isBirthTimePickerPresented, - isTimeUnknown: birthTimeUnknownBinding - ) { - prepareBirthTimePicker() - isBirthTimePickerPresented = true - } + DSSajuBasicFieldsView( + gender: genderBinding, + calendarType: calendarBinding, + birthDate: birthDateBinding, + birthTime: birthTimeBinding, + isBirthTimeUnknown: birthTimeUnknownBinding, + birthDateValidationMessage: BirthDatePolicy.validateMinimumAge(for: store.edit?.birthDate, asOf: Date()), + spacing: 40 + ) + currentStatusField } .padding(.horizontal, 20) @@ -71,66 +56,29 @@ struct MyPageEditView: View { .padding(.bottom, 14) } .background(Color.ds.white) - .dsWheelPickerSheet( - isPresented: $isBirthDatePickerPresented, - layout: .date, - title: "생년월일 입력", - onSave: saveBirthDate - ) { - DSMultiWheelPicker( - layout: .date, - columns: [ - DSWheelPickerColumn(items: birthYearItems, selection: $pickerYear, accessibilityLabel: "연도"), - DSWheelPickerColumn( - items: birthMonthItems, - selection: $pickerMonth, - accessibilityLabel: "월", - isCircular: true - ), - DSWheelPickerColumn(items: birthDayItems, selection: $pickerDay, accessibilityLabel: "일") - ] - ) - } - .dsWheelPickerSheet( - isPresented: $isBirthTimePickerPresented, - layout: .single, - title: "태어난 시각 선택", - onSave: saveBirthTime - ) { - DSSingleWheelPicker( - items: BirthTimePeriod.allCases.map { - DSWheelPickerItem(value: $0.rawValue, title: DSEnterTimeOfBirth.pickerTitle(for: $0)) - }, - selection: $pickerBirthTimeRawValue, - accessibilityLabel: "태어난 시각" - ) - } .sheet(isPresented: isStatusSheetPresented) { MyPageCurrentStatusSheet(store: store) .presentationDetents([.large]) .presentationDragIndicator(.visible) .presentationCornerRadius(24) } - .onChange(of: pickerYear) { _, _ in normalizeBirthDatePicker() } - .onChange(of: pickerMonth) { _, _ in normalizeBirthDatePicker() } } private var currentStatusField: some View { - DSSelectField( - selection: Binding( - get: { currentStatusText }, - set: { _ in } - ), - placeholder: "현재 상황 선택", - action: { store.send(.editStatusSheetPresented(true)) } - ) - .overlay(alignment: .topLeading) { + VStack(alignment: .leading, spacing: 16) { Text("현재 상황") .dsBody1Bold .foregroundStyle(Color.ds.black) - .offset(y: -42) + + DSSelectField( + selection: Binding( + get: { currentStatusText }, + set: { _ in } + ), + placeholder: "현재 상황 선택", + action: { store.send(.editStatusSheetPresented(true)) } + ) } - .padding(.top, 42) } private var currentStatusText: String? { @@ -164,53 +112,6 @@ struct MyPageEditView: View { set: { store.send(.editStatusSheetPresented($0)) } ) } - - private var currentDateComponents: DateComponents { - calendar.dateComponents([.year, .month, .day], from: Date()) - } - - private var birthYearItems: [DSWheelPickerItem] { - (1900...(currentDateComponents.year ?? 1900)).map { DSWheelPickerItem(value: $0, title: "\($0)년") } - } - - private var birthMonthItems: [DSWheelPickerItem] { - let maximum = pickerYear == currentDateComponents.year ? currentDateComponents.month ?? 12 : 12 - return (1...maximum).map { DSWheelPickerItem(value: $0, title: "\($0)월") } - } - - private var birthDayItems: [DSWheelPickerItem] { - let date = calendar.date(from: DateComponents(year: pickerYear, month: pickerMonth)) ?? Date() - let monthDays = calendar.range(of: .day, in: .month, for: date)?.count ?? 28 - let maximum = pickerYear == currentDateComponents.year && pickerMonth == currentDateComponents.month - ? min(monthDays, currentDateComponents.day ?? monthDays) : monthDays - return (1...maximum).map { DSWheelPickerItem(value: $0, title: "\($0)일") } - } - - private func prepareBirthDatePicker() { - pickerYear = store.edit?.birthDate?.year ?? 1999 - pickerMonth = store.edit?.birthDate?.month ?? 1 - pickerDay = store.edit?.birthDate?.day ?? 1 - normalizeBirthDatePicker() - } - - private func prepareBirthTimePicker() { - pickerBirthTimeRawValue = (store.edit?.birthTime ?? .inTime).rawValue - } - - private func saveBirthDate() { - store.send(.editBirthDateChanged(BirthDate(year: pickerYear, month: pickerMonth, day: pickerDay))) - isBirthDatePickerPresented = false - } - - private func saveBirthTime() { - store.send(.editBirthTimeChanged(BirthTimePeriod(rawValue: pickerBirthTimeRawValue))) - isBirthTimePickerPresented = false - } - - private func normalizeBirthDatePicker() { - pickerMonth = min(max(pickerMonth, 1), birthMonthItems.last?.value ?? 12) - pickerDay = min(max(pickerDay, 1), birthDayItems.last?.value ?? 28) - } } private struct MyPageCurrentStatusSheet: View { diff --git a/Projects/Feature/MyPageFeature/Sources/MyPageFeature.swift b/Projects/Feature/MyPageFeature/Sources/MyPageFeature.swift index 972bb79..8c22f5a 100644 --- a/Projects/Feature/MyPageFeature/Sources/MyPageFeature.swift +++ b/Projects/Feature/MyPageFeature/Sources/MyPageFeature.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import Foundation import Model +import Utils // swiftlint:disable file_length @Reducer @@ -100,7 +101,7 @@ public struct MyPageFeature { var isValid: Bool { !name.isEmpty && nameValidationMessage == nil && gender != nil && calendar != nil && birthDate != nil && (birthTime != nil || isBirthTimeUnknown) - && PartnerBirthDatePolicy.validationMessage(for: birthDate, asOf: Date()) == nil + && BirthDatePolicy.validateNotInFuture(for: birthDate, asOf: Date()) == nil } func input() -> MyPagePartnerSajuInput? { @@ -155,14 +156,14 @@ public struct MyPageFeature { var isValid: Bool { gender != nil && calendar != nil && birthDate != nil + && BirthDatePolicy.validateNotInFuture(for: birthDate, asOf: Date()) == nil + && BirthDatePolicy.validateMinimumAge(for: birthDate, asOf: Date()) == nil && (birthTime != nil || isBirthTimeUnknown) && job != nil && relationshipStatus != nil } private static func birthDate(_ value: String) -> BirthDate? { - let components = value.split(separator: "-").compactMap { Int($0) } - guard components.count == 3 else { return nil } - return BirthDate(year: components[0], month: components[1], day: components[2]) + BirthDate(yyyyMMdd: value) } } @@ -341,6 +342,7 @@ public struct MyPageFeature { case .editSaveButtonTapped: guard let edit = state.edit, edit.isValid, + !edit.isSaving, let gender = edit.gender, let calendar = edit.calendar, let birthDate = edit.birthDate, @@ -493,6 +495,7 @@ public struct MyPageFeature { case .partnerSaveButtonTapped: guard let form = state.sajuManagement?.form, form.isValid, + !form.isSaving, let input = form.input() else { return .none } @@ -594,26 +597,3 @@ public enum MyPageRelationshipStatus: String, CaseIterable, Equatable, Sendable init?(apiValue: String) { self.init(rawValue: apiValue) } } - -private extension Gender { - var apiValue: String { self == .female ? "FEMALE" : "MALE" } -} - -private extension BirthDateCalendar { - var apiValue: String { self == .lunar ? "LUNAR" : "SOLAR" } -} - -private extension UnicodeScalar { - var isKoreanNameScalar: Bool { - switch value { - case 0xAC00 ... 0xD7A3, - 0x1100 ... 0x11FF, - 0x3130 ... 0x318F, - 0xA960 ... 0xA97F, - 0xD7B0 ... 0xD7FF: - true - default: - false - } - } -} diff --git a/Projects/Feature/MyPageFeature/Sources/MyPageSajuManagementView.swift b/Projects/Feature/MyPageFeature/Sources/MyPageSajuManagementView.swift index 621dd96..af8079c 100644 --- a/Projects/Feature/MyPageFeature/Sources/MyPageSajuManagementView.swift +++ b/Projects/Feature/MyPageFeature/Sources/MyPageSajuManagementView.swift @@ -206,14 +206,6 @@ private struct PartnerMenuButtonFrameKey: PreferenceKey { private struct MyPagePartnerFormView: View { @Bindable private var store: StoreOf @FocusState private var isNameFocused: Bool - @State private var isBirthDatePickerPresented = false - @State private var isBirthTimePickerPresented = false - @State private var pickerYear = 1999 - @State private var pickerMonth = 1 - @State private var pickerDay = 1 - @State private var pickerBirthTimeRawValue = BirthTimePeriod.inTime.rawValue - - private let calendar = Calendar(identifier: .gregorian) init(store: StoreOf) { self.store = store } @@ -235,17 +227,15 @@ private struct MyPagePartnerFormView: View { validationState: nameValidationState, isFocused: $isNameFocused ) - DSSelectGender(selection: genderBinding) - DSSelectLunarOrSolarCalendar(selection: calendarBinding) - birthDateField - DSEnterTimeOfBirth( - selection: birthTimeBinding, - isFocused: isBirthTimePickerPresented, - isTimeUnknown: birthTimeUnknownBinding - ) { - prepareBirthTimePicker() - isBirthTimePickerPresented = true - } + DSSajuBasicFieldsView( + gender: genderBinding, + calendarType: calendarBinding, + birthDate: birthDateBinding, + birthTime: birthTimeBinding, + isBirthTimeUnknown: birthTimeUnknownBinding, + birthDateValidationMessage: BirthDatePolicy.validateNotInFuture(for: form?.birthDate, asOf: Date()), + spacing: 40 + ) relationshipField DSPrimaryLargeButton(form?.isSaving == true ? "저장 중…" : "저장하기") { store.send(.partnerSaveButtonTapped) @@ -259,63 +249,6 @@ private struct MyPagePartnerFormView: View { } } .background(Color.ds.white) - .dsWheelPickerSheet( - isPresented: $isBirthDatePickerPresented, - layout: .date, - title: "생년월일 입력", - onSave: saveBirthDate - ) { - DSMultiWheelPicker( - layout: .date, - columns: [ - DSWheelPickerColumn(items: birthYearItems, selection: $pickerYear, accessibilityLabel: "연도"), - DSWheelPickerColumn( - items: birthMonthItems, - selection: $pickerMonth, - accessibilityLabel: "월", - isCircular: true - ), - DSWheelPickerColumn(items: birthDayItems, selection: $pickerDay, accessibilityLabel: "일") - ] - ) - } - .dsWheelPickerSheet( - isPresented: $isBirthTimePickerPresented, - layout: .single, - title: "태어난 시각 선택", - onSave: saveBirthTime - ) { - DSSingleWheelPicker( - items: BirthTimePeriod.allCases.map { - DSWheelPickerItem( - value: $0.rawValue, - title: DSEnterTimeOfBirth.pickerTitle(for: $0) - ) - }, - selection: $pickerBirthTimeRawValue, - accessibilityLabel: "태어난 시각" - ) - } - .onChange(of: pickerYear) { _, _ in normalizeBirthDatePicker() } - .onChange(of: pickerMonth) { _, _ in normalizeBirthDatePicker() } - } - - private var birthDateField: some View { - VStack(alignment: .leading, spacing: 8) { - DSEnterDateOfBirth(selection: birthDateBinding, isFocused: isBirthDatePickerPresented) { - prepareBirthDatePicker() - isBirthDatePickerPresented = true - } - if let message = PartnerBirthDatePolicy.validationMessage( - for: form?.birthDate, - asOf: Date() - ) { - Text(message) - .dsCaption1Regular - .foregroundStyle(Color.ds.red500) - .padding(.horizontal, 4) - } - } } private var relationshipField: some View { @@ -362,46 +295,6 @@ private struct MyPagePartnerFormView: View { } ) } - - private var currentDateComponents: DateComponents { - calendar.dateComponents([.year, .month, .day], from: Date()) - } - - private var birthYearItems: [DSWheelPickerItem] { - (1900...(currentDateComponents.year ?? 1900)).map { - DSWheelPickerItem(value: $0, title: "\($0)년") - } - } - private var birthMonthItems: [DSWheelPickerItem] { - let maximum = pickerYear == currentDateComponents.year ? currentDateComponents.month ?? 12 : 12 - return (1...maximum).map { DSWheelPickerItem(value: $0, title: "\($0)월") } - } - private var birthDayItems: [DSWheelPickerItem] { - let date = calendar.date(from: DateComponents(year: pickerYear, month: pickerMonth)) ?? Date() - let maximum = calendar.range(of: .day, in: .month, for: date)?.count ?? 28 - let todayMaximum = pickerYear == currentDateComponents.year && pickerMonth == currentDateComponents.month - ? min(maximum, currentDateComponents.day ?? maximum) : maximum - return (1...todayMaximum).map { DSWheelPickerItem(value: $0, title: "\($0)일") } - } - private func prepareBirthDatePicker() { - pickerYear = form?.birthDate?.year ?? 1999 - pickerMonth = form?.birthDate?.month ?? 1 - pickerDay = form?.birthDate?.day ?? 1 - normalizeBirthDatePicker() - } - private func prepareBirthTimePicker() { pickerBirthTimeRawValue = (form?.birthTime ?? .inTime).rawValue } - private func saveBirthDate() { - store.send(.partnerBirthDateChanged(BirthDate(year: pickerYear, month: pickerMonth, day: pickerDay))) - isBirthDatePickerPresented = false - } - private func saveBirthTime() { - store.send(.partnerBirthTimeChanged(BirthTimePeriod(rawValue: pickerBirthTimeRawValue))) - isBirthTimePickerPresented = false - } - private func normalizeBirthDatePicker() { - pickerMonth = min(max(pickerMonth, 1), birthMonthItems.last?.value ?? 12) - pickerDay = min(max(pickerDay, 1), birthDayItems.last?.value ?? 28) - } } #Preview("사주 정보 관리 - 최대 인원") { diff --git a/Projects/Feature/MyPageFeature/Sources/PartnerBirthDatePolicy.swift b/Projects/Feature/MyPageFeature/Sources/PartnerBirthDatePolicy.swift deleted file mode 100644 index 912881a..0000000 --- a/Projects/Feature/MyPageFeature/Sources/PartnerBirthDatePolicy.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation -import Model - -enum PartnerBirthDatePolicy { - static let futureDateMessage = "생년월일은 오늘 이전으로 선택해 주세요." - - static func validationMessage(for birthDate: BirthDate?, asOf referenceDate: Date) -> String? { - guard let birthDate else { return nil } - - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = .autoupdatingCurrent - guard let date = calendar.date( - from: DateComponents(year: birthDate.year, month: birthDate.month, day: birthDate.day) - ) else { - return "생년월일을 다시 확인해 주세요." - } - - return calendar.startOfDay(for: date) < calendar.startOfDay(for: referenceDate) - ? nil - : futureDateMessage - } -} diff --git a/Projects/Feature/MyPageFeature/Tests/MyPageFeatureTests.swift b/Projects/Feature/MyPageFeature/Tests/MyPageFeatureTests.swift index 814371e..05243b8 100644 --- a/Projects/Feature/MyPageFeature/Tests/MyPageFeatureTests.swift +++ b/Projects/Feature/MyPageFeature/Tests/MyPageFeatureTests.swift @@ -286,9 +286,10 @@ final class MyPageFeatureTests: XCTestCase { let referenceDate = Date(timeIntervalSince1970: 1_730_000_000) let today = Calendar(identifier: .gregorian).dateComponents([.year, .month, .day], from: referenceDate) let birthDate = BirthDate(year: today.year ?? 2024, month: today.month ?? 1, day: today.day ?? 1) + let futureReferenceDate = Calendar(identifier: .gregorian).date(byAdding: .day, value: -1, to: referenceDate)! XCTAssertEqual( - PartnerBirthDatePolicy.validationMessage(for: birthDate, asOf: referenceDate), - PartnerBirthDatePolicy.futureDateMessage + BirthDatePolicy.validateNotInFuture(for: birthDate, asOf: futureReferenceDate), + BirthDatePolicy.futureDateMessage ) } @@ -301,6 +302,66 @@ final class MyPageFeatureTests: XCTestCase { XCTAssertEqual(form.relationship, .colleague) } + func testEditStateValidationPolicies() { + let profile = MyPageProfile( + name: "토닥이", + gender: "FEMALE", + birthDate: "1999-05-15", + calendarType: "SOLAR", + birthTime: "09:30", + isTimeUnknown: false, + job: "STUDENT", + relationshipStatus: "SINGLE" + ) + var edit = MyPageFeature.EditState(profile: profile) + XCTAssertTrue(edit.isValid) + + // Under 14 years old + edit.birthDate = BirthDate(year: 2020, month: 1, day: 1) + XCTAssertFalse(edit.isValid) + + // Future date + edit.birthDate = BirthDate(year: 2030, month: 1, day: 1) + XCTAssertFalse(edit.isValid) + + // Valid date + edit.birthDate = BirthDate(year: 1999, month: 5, day: 15) + XCTAssertTrue(edit.isValid) + + // Missing job + edit.job = nil + XCTAssertFalse(edit.isValid) + edit.job = .student + + // Unknown birth time + edit.birthTime = nil + edit.isBirthTimeUnknown = true + XCTAssertTrue(edit.isValid) + + edit.isBirthTimeUnknown = false + XCTAssertFalse(edit.isValid) + } + + func testPartnerFormLifecycleAndValidation() { + var form = MyPageFeature.PartnerFormState() + form.name = "홍길동" + form.gender = .male + form.calendar = .solar + form.birthDate = BirthDate(year: 1995, month: 3, day: 20) + form.birthTime = .inTime + form.relationship = .friend + XCTAssertTrue(form.isValid) + + // Unknown time resets birthTime + form.isBirthTimeUnknown = true + form.birthTime = nil + XCTAssertTrue(form.isValid) + + // Future date is invalid + form.birthDate = BirthDate(year: 2030, month: 1, day: 1) + XCTAssertFalse(form.isValid) + } + func testLivePartnerClientUsesSwaggerCRUDContract() async throws { let recorder = PartnerRequestRecorder() let httpClient = HTTPClient( diff --git a/Projects/Feature/OnboardingFeature/Sources/FortuneInformationView.swift b/Projects/Feature/OnboardingFeature/Sources/FortuneInformationView.swift index a045727..041d309 100644 --- a/Projects/Feature/OnboardingFeature/Sources/FortuneInformationView.swift +++ b/Projects/Feature/OnboardingFeature/Sources/FortuneInformationView.swift @@ -5,14 +5,6 @@ import SwiftUI struct FortuneInformationView: View { @Bindable private var store: StoreOf - @State private var isBirthDatePickerPresented = false - @State private var isBirthTimePickerPresented = false - @State private var pickerYear = 1999 - @State private var pickerMonth = 1 - @State private var pickerDay = 1 - @State private var pickerBirthTimeRawValue = BirthTimePeriod.inTime.rawValue - - private let calendar = Calendar(identifier: .gregorian) init(store: StoreOf) { self.store = store @@ -29,38 +21,16 @@ struct FortuneInformationView: View { Text(fortuneInformationTitle) .dsHeading2SemiBold - DSSelectGender(selection: genderBinding) - DSSelectLunarOrSolarCalendar(selection: birthDateCalendarBinding) - VStack(alignment: .leading, spacing: 8) { - DSEnterDateOfBirth( - selection: birthDateBinding, - isFocused: isBirthDatePickerPresented - ) { - prepareBirthDatePicker() - isBirthDatePickerPresented = true - } - - if let message = store.birthDateAgeValidationMessage { - Text(message) - .dsCaption1Regular - .foregroundStyle(Color.ds.red500) - .padding(.horizontal, 4) - } - } - DSEnterTimeOfBirth( - selection: birthTimePeriodBinding, - isFocused: isBirthTimePickerPresented, - isTimeUnknown: birthTimeUnknownBinding - ) { - prepareBirthTimePicker() - isBirthTimePickerPresented = true - } - - if store.isBirthTimeUnknown { - Text("정확한 시간을 모르면 정오 기준으로 운세가 계산돼요.") - .dsCaption1Regular - .foregroundStyle(Color.ds.sky600) - } + DSSajuBasicFieldsView( + gender: genderBinding, + calendarType: birthDateCalendarBinding, + birthDate: birthDateBinding, + birthTime: birthTimePeriodBinding, + isBirthTimeUnknown: birthTimeUnknownBinding, + birthDateValidationMessage: store.birthDateAgeValidationMessage, + unknownTimeInfoText: "정확한 시간을 모르면 정오 기준으로 운세가 계산돼요.", + spacing: 32 + ) } .padding(.horizontal, 20) .padding(.top, 64) @@ -74,52 +44,6 @@ struct FortuneInformationView: View { .padding(.horizontal, 20) .padding(.bottom, 14) } - .dsWheelPickerSheet( - isPresented: $isBirthDatePickerPresented, - layout: .date, - title: "생년월일 입력", - onSave: saveBirthDate - ) { - DSMultiWheelPicker( - layout: .date, - columns: [ - DSWheelPickerColumn( - items: birthYearItems, - selection: $pickerYear, - accessibilityLabel: "연도" - ), - DSWheelPickerColumn( - items: birthMonthItems, - selection: $pickerMonth, - accessibilityLabel: "월", - isCircular: true - ), - DSWheelPickerColumn( - items: birthDayItems, - selection: $pickerDay, - accessibilityLabel: "일" - ) - ] - ) - } - .dsWheelPickerSheet( - isPresented: $isBirthTimePickerPresented, - layout: .single, - title: "태어난 시각 선택", - onSave: saveBirthTime - ) { - DSSingleWheelPicker( - items: birthTimeItems, - selection: $pickerBirthTimeRawValue, - accessibilityLabel: "태어난 시각" - ) - } - .onChange(of: pickerYear) { _, _ in - normalizeBirthDatePicker() - } - .onChange(of: pickerMonth) { _, _ in - normalizeBirthDatePicker() - } } } @@ -171,78 +95,4 @@ private extension FortuneInformationView { set: { store.send(.birthTimeUnknownChanged($0)) } ) } - - var currentDateComponents: DateComponents { - calendar.dateComponents([.year, .month, .day], from: Date()) - } - - var birthYearItems: [DSWheelPickerItem] { - (1900...(currentDateComponents.year ?? 1900)).map { - DSWheelPickerItem(value: $0, title: "\($0)년") - } - } - - var birthMonthItems: [DSWheelPickerItem] { - let maximumMonth = pickerYear == currentDateComponents.year - ? currentDateComponents.month ?? 12 - : 12 - - return (1...maximumMonth).map { - DSWheelPickerItem(value: $0, title: "\($0)월") - } - } - - var birthDayItems: [DSWheelPickerItem] { - let date = calendar.date(from: DateComponents(year: pickerYear, month: pickerMonth)) ?? Date() - let daysInMonth = calendar.range(of: .day, in: .month, for: date)?.count ?? 28 - let maximumDay = pickerYear == currentDateComponents.year - && pickerMonth == currentDateComponents.month - ? min(daysInMonth, currentDateComponents.day ?? daysInMonth) - : daysInMonth - - return (1...maximumDay).map { - DSWheelPickerItem(value: $0, title: "\($0)일") - } - } - - var birthTimeItems: [DSWheelPickerItem] { - BirthTimePeriod.allCases.map { period in - DSWheelPickerItem( - value: period.rawValue, - title: DSEnterTimeOfBirth.pickerTitle(for: period) - ) - } - } - - func prepareBirthDatePicker() { - let selected = store.birthDate - pickerYear = selected?.year ?? 1999 - pickerMonth = selected?.month ?? 1 - pickerDay = selected?.day ?? 1 - normalizeBirthDatePicker() - } - - func prepareBirthTimePicker() { - pickerBirthTimeRawValue = (store.birthTimePeriod ?? .inTime).rawValue - } - - func saveBirthDate() { - store.send(.birthDateChanged(BirthDate(year: pickerYear, month: pickerMonth, day: pickerDay))) - isBirthDatePickerPresented = false - } - - func saveBirthTime() { - store.send(.birthTimePeriodChanged(BirthTimePeriod(rawValue: pickerBirthTimeRawValue))) - isBirthTimePickerPresented = false - } - - func normalizeBirthDatePicker() { - if let maximumMonth = birthMonthItems.last?.value { - pickerMonth = min(max(pickerMonth, 1), maximumMonth) - } - - if let maximumDay = birthDayItems.last?.value { - pickerDay = min(max(pickerDay, 1), maximumDay) - } - } } diff --git a/Projects/Feature/OnboardingFeature/Sources/OnboardingMinimumAgePolicy.swift b/Projects/Feature/OnboardingFeature/Sources/OnboardingMinimumAgePolicy.swift index 2adfbb2..31d03f8 100644 --- a/Projects/Feature/OnboardingFeature/Sources/OnboardingMinimumAgePolicy.swift +++ b/Projects/Feature/OnboardingFeature/Sources/OnboardingMinimumAgePolicy.swift @@ -2,36 +2,12 @@ import Foundation import Model enum OnboardingMinimumAgePolicy { - static let minimumAgeMessage = "토닥운은 만 14세부터 가입할 수 있어요" + static var minimumAgeMessage: String { BirthDatePolicy.minimumAgeMessage } static func validationMessage( for birthDate: BirthDate?, asOf referenceDate: Date ) -> String? { - guard let birthDate else { return nil } - - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = .autoupdatingCurrent - - guard - let dateOfBirth = calendar.date( - from: DateComponents( - year: birthDate.year, - month: birthDate.month, - day: birthDate.day - ) - ), - let eligibleDate = calendar.date( - byAdding: .year, - value: 14, - to: dateOfBirth - ) - else { - return "생년월일을 다시 확인해 주세요." - } - - return calendar.startOfDay(for: referenceDate) < calendar.startOfDay(for: eligibleDate) - ? minimumAgeMessage - : nil + BirthDatePolicy.validateMinimumAge(for: birthDate, minimumAge: 14, asOf: referenceDate) } } From 6de9fb996f5ed6d4c17a89516e24c417d67598fe Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Sat, 15 Aug 2026 17:58:03 +0900 Subject: [PATCH 11/16] =?UTF-8?q?fix(fortune):=20=ED=95=98=EB=8B=A8=20CTA?= =?UTF-8?q?=20=EC=98=81=EC=97=AD=20=EC=9D=B8=EC=9C=84=EC=A0=81=20=EA=B7=B8?= =?UTF-8?q?=EB=9D=BC=EB=8D=B0=EC=9D=B4=EC=85=98=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20=EB=B7=B0=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Compatibility/CompatibilityView.swift | 75 +---------------- .../Sources/DayFortune/DayFortuneView.swift | 84 +------------------ .../Sources/FortuneClient.swift | 11 +++ .../Report/FortuneCategoryDetailFeature.swift | 13 --- .../Sources/Views/FortuneCommonViews.swift | 57 +++++++++++++ .../Views/FortuneResultComponents.swift | 12 +-- .../Sources/YearFortune/YearFortuneView.swift | 55 +----------- .../Tests/FortuneFeatureTests.swift | 8 +- .../Tests/FortuneFollowupFeatureTests.swift | 40 +++++---- 9 files changed, 108 insertions(+), 247 deletions(-) create mode 100644 Projects/Feature/FortuneFeature/Sources/Views/FortuneCommonViews.swift diff --git a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift index 22d8db7..d84fb33 100644 --- a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift +++ b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift @@ -324,17 +324,7 @@ private struct PartnerRegistrationView: View { .padding(.top, 12) .padding(.bottom, 12) } - .background( - LinearGradient( - colors: [ - Color.ds.white.opacity(0), - Color.ds.white.opacity(0.95), - Color.ds.white - ], - startPoint: .top, - endPoint: .bottom - ) - ) + .background(Color.ds.white) } } @@ -670,20 +660,6 @@ private struct SajuChartCard: View { return name } - private func sajuInfoSubtitle(for chart: SajuChartDetail) -> String { - let calendar = chart.calendarType.map { $0.title } ?? "양력" - let date = Self.dateFormatter.string(from: chart.birthDate) - let time: String - if chart.isBirthTimeUnknown { - time = "태어난 시각 모름" - } else if let birthTime = chart.birthTime { - time = birthTime.displayText - } else { - time = "" - } - return [date + " " + calendar, time].filter { !$0.isEmpty }.joined(separator: " · ") - } - var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center) { @@ -816,55 +792,6 @@ private struct SajuPillarColumn: View { } } -private struct FortuneHeaderView: View { - let title: String - let isDark: Bool - let action: () -> Void - - var body: some View { - HStack { - Button(action: action) { - DSIcon(.chevronLeftNarrow, width: 24, height: 24) - .foregroundStyle(isDark ? Color.ds.white : Color.ds.gray975) - .frame(width: 44, height: 44) - } - .dsIconButtonStyle(.chevronLeftNarrow, width: 24, height: 24) - - Spacer() - - Text(title) - .dsBody2SemiBold - .foregroundStyle(isDark ? Color.ds.white : Color.ds.gray975) - - Spacer() - - Color.clear.frame(width: 44, height: 44) - } - .padding(.horizontal, 10) - .frame(height: 52) - } -} - -private struct FortuneErrorBanner: View { - let message: String - - var body: some View { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(Color.ds.red500) - - Text(message) - .dsBody3Medium - .foregroundStyle(Color.ds.red500) - - Spacer() - } - .padding(12) - .background(Color.ds.red500.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } -} - private extension String { var nonEmpty: String? { isEmpty ? nil : self diff --git a/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift b/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift index 07bfa2f..52d04a6 100644 --- a/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift +++ b/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift @@ -13,7 +13,7 @@ struct DayFortuneView: View { } var body: some View { - ZStack { + Group { if store.results.isEmpty { DayFortuneFormContent(store: store, backAction: backAction) } else { @@ -82,17 +82,7 @@ private struct DayFortuneFormContent: View { .padding(.top, 12) .padding(.bottom, 12) } - .background( - LinearGradient( - colors: [ - Color.ds.white.opacity(0), - Color.ds.white.opacity(0.95), - Color.ds.white - ], - startPoint: .top, - endPoint: .bottom - ) - ) + .background(Color.ds.white) } } @@ -275,17 +265,7 @@ private struct DayFortuneCalendarSheet: View { .padding(.top, 12) .padding(.bottom, 16) } - .background( - LinearGradient( - colors: [ - Color.ds.white.opacity(0), - Color.ds.white.opacity(0.95), - Color.ds.white - ], - startPoint: .top, - endPoint: .bottom - ) - ) + .background(Color.ds.white) if store.showLimitToast { DSToast(compact: "후보 날짜는 5개까지만 선택 가능합니다.") @@ -533,57 +513,6 @@ private struct DayFortuneResultContent: View { } } -// MARK: - 공용 뷰와 도우미 - -private struct FortuneHeaderView: View { - let title: String - let isDark: Bool - let action: () -> Void - - var body: some View { - HStack { - Button(action: action) { - DSIcon(.chevronLeftNarrow, width: 24, height: 24) - .foregroundStyle(isDark ? Color.ds.white : Color.ds.gray975) - .frame(width: 44, height: 44) - } - .dsIconButtonStyle(.chevronLeftNarrow, width: 24, height: 24) - - Spacer() - - Text(title) - .dsBody2SemiBold - .foregroundStyle(isDark ? Color.ds.white : Color.ds.gray975) - - Spacer() - - Color.clear.frame(width: 44, height: 44) - } - .padding(.horizontal, 10) - .frame(height: 52) - } -} - -private struct FortuneErrorBanner: View { - let message: String - - var body: some View { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(Color.ds.red500) - - Text(message) - .dsBody3Medium - .foregroundStyle(Color.ds.red500) - - Spacer() - } - .padding(12) - .background(Color.ds.red500.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } -} - private enum DayFortuneDateFormatter { static func shortDate(_ date: Date) -> String { let formatter = DateFormatter() @@ -598,13 +527,6 @@ private enum DayFortuneDateFormatter { formatter.dateFormat = "M.d(E)" return formatter.string(from: date) } - - static func fullDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "ko_KR") - formatter.dateFormat = "yyyy년 M월 d일 (E)" - return formatter.string(from: date) - } } private struct CalendarPickerView: View { diff --git a/Projects/Feature/FortuneFeature/Sources/FortuneClient.swift b/Projects/Feature/FortuneFeature/Sources/FortuneClient.swift index c3ca4d2..e1d7682 100644 --- a/Projects/Feature/FortuneFeature/Sources/FortuneClient.swift +++ b/Projects/Feature/FortuneFeature/Sources/FortuneClient.swift @@ -45,6 +45,17 @@ public enum FortuneClientError: Error, Equatable, Sendable { case invalidResponse case unsupportedCategory(String) case transport + + public var userMessage: String { + switch self { + case .notConfigured: + "운세 서비스를 사용할 수 없어요." + case .server, .httpStatus, .invalidResponse, .unsupportedCategory: + "운세 정보를 불러오지 못했어요." + case .transport: + "네트워크 연결 상태를 확인해주세요." + } + } } extension FortuneClient: DependencyKey { diff --git a/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailFeature.swift b/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailFeature.swift index dde4247..b69b826 100644 --- a/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailFeature.swift +++ b/Projects/Feature/FortuneFeature/Sources/Report/FortuneCategoryDetailFeature.swift @@ -86,16 +86,3 @@ public struct FortuneCategoryDetailFeature { } } } - -extension FortuneClientError { - var userMessage: String { - switch self { - case .notConfigured: - "서비스를 사용할 수 없어요." - case .server, .httpStatus, .invalidResponse, .unsupportedCategory: - "정보를 불러오지 못했어요." - case .transport: - "네트워크 연결 상태를 확인해주세요." - } - } -} diff --git a/Projects/Feature/FortuneFeature/Sources/Views/FortuneCommonViews.swift b/Projects/Feature/FortuneFeature/Sources/Views/FortuneCommonViews.swift new file mode 100644 index 0000000..450bb0c --- /dev/null +++ b/Projects/Feature/FortuneFeature/Sources/Views/FortuneCommonViews.swift @@ -0,0 +1,57 @@ +import DesignSystem +import SwiftUI + +struct FortuneHeaderView: View { + let title: String + let isDark: Bool + let action: () -> Void + + init(title: String, isDark: Bool = false, action: @escaping () -> Void) { + self.title = title + self.isDark = isDark + self.action = action + } + + var body: some View { + HStack { + Button(action: action) { + DSIcon(.chevronLeftNarrow, width: 24, height: 24) + .foregroundStyle(isDark ? Color.ds.white : Color.ds.gray975) + .frame(width: 44, height: 44) + } + .dsIconButtonStyle(.chevronLeftNarrow, width: 24, height: 24) + + Spacer() + + Text(title) + .dsBody2SemiBold + .foregroundStyle(isDark ? Color.ds.white : Color.ds.gray975) + + Spacer() + + Color.clear.frame(width: 44, height: 44) + } + .padding(.horizontal, 10) + .frame(height: 52) + } +} + +struct FortuneErrorBanner: View { + let message: String + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.ds.red500) + + Text(message) + .dsBody3Medium + .foregroundStyle(Color.ds.red500) + + Spacer() + } + .padding(12) + .background(Color.ds.red500.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} diff --git a/Projects/Feature/FortuneFeature/Sources/Views/FortuneResultComponents.swift b/Projects/Feature/FortuneFeature/Sources/Views/FortuneResultComponents.swift index 20202fd..9a2e5b2 100644 --- a/Projects/Feature/FortuneFeature/Sources/Views/FortuneResultComponents.swift +++ b/Projects/Feature/FortuneFeature/Sources/Views/FortuneResultComponents.swift @@ -52,16 +52,6 @@ struct FortuneTodakInquiryBar: View { .padding(.horizontal, 20) .padding(.top, 12) .padding(.bottom, 8) - .background( - LinearGradient( - colors: [ - Color.fortuneSpaceBase.opacity(0), - Color.fortuneSpaceBase.opacity(0.85), - Color.fortuneSpaceBase - ], - startPoint: .top, - endPoint: .bottom - ) - ) + .background(Color.fortuneSpaceBase) } } diff --git a/Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneView.swift b/Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneView.swift index cdbb587..8fd0b29 100644 --- a/Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneView.swift +++ b/Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneView.swift @@ -34,7 +34,7 @@ private struct YearFortuneFormContent: View { Color.ds.white.ignoresSafeArea() VStack(spacing: 0) { - YearFortuneHeaderView(title: "연도별 운세", isDark: false, action: backAction) + FortuneHeaderView(title: "연도별 운세", isDark: false, action: backAction) ScrollView { VStack(alignment: .leading, spacing: 28) { @@ -43,7 +43,7 @@ private struct YearFortuneFormContent: View { yearSelectionSection if let errorMessage = store.errorMessage { - YearFortuneErrorBanner(message: errorMessage) + FortuneErrorBanner(message: errorMessage) } submitButton @@ -290,54 +290,3 @@ private struct YearFortuneResultContent: View { FortuneFeatureAsset.fortuneShare.swiftUIImage } } - -// MARK: - 공용 뷰 - -private struct YearFortuneHeaderView: View { - let title: String - let isDark: Bool - let action: () -> Void - - var body: some View { - HStack { - Button(action: action) { - DSIcon(.chevronLeftNarrow, width: 24, height: 24) - .foregroundStyle(isDark ? Color.ds.white : Color.ds.gray975) - .frame(width: 44, height: 44) - } - .dsIconButtonStyle(.chevronLeftNarrow, width: 24, height: 24) - - Spacer() - - Text(title) - .dsBody2SemiBold - .foregroundStyle(isDark ? Color.ds.white : Color.ds.gray975) - - Spacer() - - Color.clear.frame(width: 44, height: 44) - } - .padding(.horizontal, 10) - .frame(height: 52) - } -} - -private struct YearFortuneErrorBanner: View { - let message: String - - var body: some View { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(Color.ds.red500) - - Text(message) - .dsBody3Medium - .foregroundStyle(Color.ds.red500) - - Spacer() - } - .padding(12) - .background(Color.ds.red500.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } -} diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift index 2dad08e..1b80aae 100644 --- a/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift +++ b/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift @@ -376,7 +376,13 @@ private extension FortuneHomeContent { fortuneDate: Date(timeIntervalSince1970: 0), score: 72, title: "오늘의 운세", - categoryScores: [] + categoryScores: FortuneCategory.allCases.enumerated().map { index, category in + FortuneCategoryScore( + luckActionID: UUID(UInt8(10 + index)), + category: category, + score: 70 + index * 5 + ) + } ) } } diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift index d656c76..4fec625 100644 --- a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift +++ b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift @@ -1,5 +1,6 @@ import ComposableArchitecture import Foundation +import Model import Testing @testable import FortuneFeature @@ -197,10 +198,10 @@ struct FortuneFollowupFeatureTests { await store.receive(.delegate(.myInfoEditRequested)) } - @Test("궁합 화면에서 상대방 사주를 성공적으로 등록하면 폼을 닫고 목록을 새로고침한다") + @Test("궁합 화면에서 상대방 사주를 성공적으로 등록하면 폼을닫고 목록을 새로고침한다") func compatibilityRegistersPartnerSuccessfully() async { let partnerID = UUID(30) - let partner = FortunePartner(id: partnerID, name: "영희", relationship: .lover) + let partner = FortunePartner(id: partnerID, name: "영희", relationship: .partner) let chart = makeSajuChart(id: partnerID, name: "영희") var state = CompatibilityFeature.State() @@ -211,7 +212,7 @@ struct FortuneFollowupFeatureTests { state.birthDate = BirthDate(year: 2000, month: 1, day: 1) state.birthTime = .inTime state.isBirthTimeUnknown = false - state.relationship = .lover + state.relationship = .partner #expect(state.canRegister) @@ -227,9 +228,10 @@ struct FortuneFollowupFeatureTests { $0.isSubmitting = true $0.errorMessage = nil } - await store.receive(.registrationResponse(.success(partnerID))) { + await store.receive(\.registrationResponse.success) { $0.isSubmitting = false $0.isRegistrationPresented = false + $0.selectedPartnerID = partnerID $0.name = "" $0.gender = nil $0.calendarType = .solar @@ -239,9 +241,8 @@ struct FortuneFollowupFeatureTests { $0.relationship = nil $0.errorMessage = nil } - await store.receive(.partnersResponse(.success([partner]))) { + await store.receive(\.partnersResponse.success) { $0.partners = [partner] - $0.selectedPartnerID = partnerID } await store.receive(.partnerSajuResponse(partnerID, .success(chart))) { $0.selectedPartnerSaju = chart @@ -251,13 +252,24 @@ struct FortuneFollowupFeatureTests { @Test("궁합 화면에서 궁합 보기 탭 시 궁합 결과를 생성하고 상태를 갱신한다") func compatibilityCreatesResultSuccessfully() async { let partnerID = UUID(31) - let partner = FortunePartner(id: partnerID, name: "영희", relationship: .lover) + let partner = FortunePartner(id: partnerID, name: "영희", relationship: .partner) let result = CompatibilityResult( id: UUID(32), + partnerName: "영희", + relationship: .partner, score: 90, - title: "천생연분", - content: "궁합 총평", - categories: [.init(category: .love, score: 95)] + headline: "천생연분", + subheadline: "완벽한 조화", + summary: "궁합 총평", + totalAnalysis: "궁합 전체 분석 내용입니다.", + analysisBasis: "오행 및 십신 분석 근거입니다.", + elements: [ + FortuneElementScore(element: .wood, percentage: 30), + FortuneElementScore(element: .fire, percentage: 25), + FortuneElementScore(element: .earth, percentage: 20), + FortuneElementScore(element: .metal, percentage: 15), + FortuneElementScore(element: .water, percentage: 10) + ] ) var state = CompatibilityFeature.State() @@ -274,7 +286,7 @@ struct FortuneFollowupFeatureTests { $0.isSubmitting = true $0.errorMessage = nil } - await store.receive(.compatibilityResponse(.success(result))) { + await store.receive(\.compatibilityResponse.success) { $0.isSubmitting = false $0.result = result } @@ -283,7 +295,7 @@ struct FortuneFollowupFeatureTests { @Test("궁합 생성 실패 시 에러 메시지를 표시하고 제출 상태를 해제한다") func compatibilityHandlesCreationFailure() async { let partnerID = UUID(33) - let partner = FortunePartner(id: partnerID, name: "영희", relationship: .lover) + let partner = FortunePartner(id: partnerID, name: "영희", relationship: .partner) var state = CompatibilityFeature.State() state.partners = [partner] @@ -299,7 +311,7 @@ struct FortuneFollowupFeatureTests { $0.isSubmitting = true $0.errorMessage = nil } - await store.receive(.compatibilityResponse(.failure(.transport))) { + await store.receive(\.compatibilityResponse.failure) { $0.isSubmitting = false $0.errorMessage = FortuneClientError.transport.userMessage } @@ -307,7 +319,7 @@ struct FortuneFollowupFeatureTests { @Test("상대방 변경 시 이전 사주를 초기화하고 새로운 상대방 사주를 조회한다") func compatibilityPartnerSelectionUpdatesSaju() async { - let p1 = FortunePartner(id: UUID(34), name: "영희", relationship: .lover) + let p1 = FortunePartner(id: UUID(34), name: "영희", relationship: .partner) let p2 = FortunePartner(id: UUID(35), name: "철수", relationship: .friend) let chart2 = makeSajuChart(id: p2.id, name: "철수") From 7583dfc8dbac023f1dc420f2f8a0390fce5c9b46 Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Sat, 15 Aug 2026 19:47:18 +0900 Subject: [PATCH 12/16] =?UTF-8?q?fix(fortune):=20=EA=B6=81=ED=95=A9=20?= =?UTF-8?q?=EB=82=B4=20=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=EA=B3=BC=20CTA=20=EC=83=81=ED=83=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 궁합 화면에서 내 정보 수정 후 저장 결과를 즉시 반영합니다.\n- CTA 컨테이너의 투명 배경은 유지하고 disabled 버튼은 DesignSystem 기본 표면을 사용합니다.\n- MainTab·Fortune·MyPage 상태 전이 테스트를 보강합니다. --- .../App/Sources/MainTab/MainTabFeature.swift | 20 +++++++++++ .../App/Sources/MainTab/MainTabView.swift | 15 +++++++- Projects/App/Tests/MainTabFeatureTests.swift | 35 +++++++++++++++++++ .../Compatibility/CompatibilityFeature.swift | 32 +++++++++++++++-- .../Compatibility/CompatibilityView.swift | 3 +- .../Sources/DayFortune/DayFortuneView.swift | 4 +-- .../Sources/FortuneFeature.swift | 3 +- .../Views/FortuneResultComponents.swift | 2 +- .../Sources/YearFortune/YearFortuneView.swift | 1 - .../Tests/FortuneFeatureTests.swift | 6 ++-- .../Tests/FortuneFollowupFeatureTests.swift | 21 +++++++++++ .../MyPageAccountSettingsReducer.swift | 1 + .../Sources/MyPageEditView.swift | 6 ++-- .../MyPageFeature/Sources/MyPageFeature.swift | 30 +++++++++++++++- .../Tests/MyPageFeatureTests.swift | 1 + 15 files changed, 162 insertions(+), 18 deletions(-) diff --git a/Projects/App/Sources/MainTab/MainTabFeature.swift b/Projects/App/Sources/MainTab/MainTabFeature.swift index 76e2e65..29d18e3 100644 --- a/Projects/App/Sources/MainTab/MainTabFeature.swift +++ b/Projects/App/Sources/MainTab/MainTabFeature.swift @@ -64,6 +64,26 @@ struct MainTabFeature { state.selectedTab = .myPage return .none + case .fortune(.delegate(.myInfoEditRequested)): + return .send(.myPage(.presentEdit)) + + case .myPage(.delegate(.profileUpdated)): + guard let destinationID = state.fortune.path.ids.last, + case .compatibility = state.fortune.path[id: destinationID] + else { + return .none + } + return .send( + .fortune( + .path( + .element( + id: destinationID, + action: .compatibility(.mySajuRefreshRequested) + ) + ) + ) + ) + case .fortune, .myPage: return .none } diff --git a/Projects/App/Sources/MainTab/MainTabView.swift b/Projects/App/Sources/MainTab/MainTabView.swift index 6543cab..a0b07ab 100644 --- a/Projects/App/Sources/MainTab/MainTabView.swift +++ b/Projects/App/Sources/MainTab/MainTabView.swift @@ -31,7 +31,16 @@ 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) .frame(maxWidth: .infinity, maxHeight: .infinity) .safeAreaInset(edge: .bottom, spacing: 0) { if shouldShowBottomNavigation { @@ -50,7 +59,11 @@ struct MainTabView: View { && store.myPage.appSettings == nil && store.myPage.withdrawal == nil && store.myPage.sajuManagement == nil - return fortuneShowing && myPageShowing + return fortuneShowing && myPageShowing && !isMyInfoEditPresented + } + + private var isMyInfoEditPresented: Bool { + store.selectedTab == .fortune && store.myPage.edit != nil } private var bottomNavigationBinding: Binding { diff --git a/Projects/App/Tests/MainTabFeatureTests.swift b/Projects/App/Tests/MainTabFeatureTests.swift index e549075..660b310 100644 --- a/Projects/App/Tests/MainTabFeatureTests.swift +++ b/Projects/App/Tests/MainTabFeatureTests.swift @@ -63,6 +63,41 @@ struct MainTabFeatureTests { } } + @Test + func fortuneDelegateMyInfoEditRequestedPresentsEditWithoutSwitchingTab() async { + let store = TestStore(initialState: MainTabFeature.State()) { + MainTabFeature() + } + store.exhaustivity = .off(showSkippedAssertions: false) + + await store.send(.fortune(.delegate(.myInfoEditRequested))) + await store.receive(.myPage(.presentEdit)) + + #expect(store.state.selectedTab == .fortune) + } + + @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 func statePersistedAcrossTabChanges() async { var initialState = MainTabFeature.State() diff --git a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift index b58b6a1..a00016b 100644 --- a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift +++ b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift @@ -4,12 +4,13 @@ import Model import Utils // 등록, 선택, 결과 상태는 하나의 화면 단위 상태 머신으로 구성한다. -// swiftlint:disable type_body_length +// swiftlint:disable file_length type_body_length @Reducer public struct CompatibilityFeature { private enum CancelID { case initialData + case mySaju case partners case partnerSaju } @@ -82,6 +83,8 @@ public struct CompatibilityFeature { public enum Action: Equatable, Sendable { case task case initialResponse(Result) + case mySajuRefreshRequested + case mySajuRefreshResponse(Result) case partnersResponse(Result<[FortunePartner], FortuneClientError>) case partnerSajuResponse(UUID, Result) case retryTapped @@ -138,6 +141,16 @@ public struct CompatibilityFeature { state.viewState = .failed(error.userMessage) return .none + case .mySajuRefreshRequested: + return fetchMySaju() + + case let .mySajuRefreshResponse(.success(mySaju)): + state.mySaju = mySaju + return .none + + case .mySajuRefreshResponse(.failure): + return .none + case let .partnersResponse(.success(partners)): state.partners = partners if state.selectedPartnerID == nil || state.selectedPartner == nil { @@ -344,6 +357,21 @@ public struct CompatibilityFeature { .cancellable(id: CancelID.partners, cancelInFlight: true) } + private func fetchMySaju() -> Effect { + .run { send in + do { + await send(.mySajuRefreshResponse(.success(try await fortuneClient.fetchMySaju()))) + } catch is CancellationError { + return + } catch let error as FortuneClientError { + await send(.mySajuRefreshResponse(.failure(error))) + } catch { + await send(.mySajuRefreshResponse(.failure(.transport))) + } + } + .cancellable(id: CancelID.mySaju, cancelInFlight: true) + } + private func fetchPartnerSaju(_ partnerID: UUID) -> Effect { .run { send in do { @@ -376,4 +404,4 @@ public struct CompatibilityFeature { } } -// swiftlint:enable type_body_length +// swiftlint:enable file_length type_body_length diff --git a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift index d84fb33..a7cb886 100644 --- a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift +++ b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift @@ -319,12 +319,11 @@ private struct PartnerRegistrationView: View { store.send(.registerTapped) } .disabled(!store.canRegister) - .opacity(store.canRegister ? 1.0 : 0.4) .padding(.horizontal, 20) .padding(.top, 12) .padding(.bottom, 12) } - .background(Color.ds.white) + .background(Color.clear) } } diff --git a/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift b/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift index 52d04a6..6c76a39 100644 --- a/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift +++ b/Projects/Feature/FortuneFeature/Sources/DayFortune/DayFortuneView.swift @@ -77,12 +77,11 @@ private struct DayFortuneFormContent: View { store.send(.createTapped) } .disabled(!store.canSubmit) - .opacity(store.canSubmit ? 1.0 : 0.4) .padding(.horizontal, 20) .padding(.top, 12) .padding(.bottom, 12) } - .background(Color.ds.white) + .background(Color.clear) } } @@ -259,7 +258,6 @@ private struct DayFortuneCalendarSheet: View { store.send(.datesConfirmed) } .disabled(store.selectedDates.isEmpty) - .opacity(store.selectedDates.isEmpty ? 0.4 : 1.0) } .padding(.horizontal, 20) .padding(.top, 12) diff --git a/Projects/Feature/FortuneFeature/Sources/FortuneFeature.swift b/Projects/Feature/FortuneFeature/Sources/FortuneFeature.swift index 9dcd9ac..a9f2f8a 100644 --- a/Projects/Feature/FortuneFeature/Sources/FortuneFeature.swift +++ b/Projects/Feature/FortuneFeature/Sources/FortuneFeature.swift @@ -62,6 +62,7 @@ public struct FortuneFeature { case todakRequested case luckyActionRequested case myPageRequested + case myInfoEditRequested } } @@ -154,7 +155,7 @@ public struct FortuneFeature { return .send(.delegate(.luckyActionRequested)) case .path(.element(id: _, action: .compatibility(.delegate(.myInfoEditRequested)))): - return .send(.delegate(.myPageRequested)) + return .send(.delegate(.myInfoEditRequested)) case .path, .delegate, .categoryDetail: return .none diff --git a/Projects/Feature/FortuneFeature/Sources/Views/FortuneResultComponents.swift b/Projects/Feature/FortuneFeature/Sources/Views/FortuneResultComponents.swift index 9a2e5b2..a44b2eb 100644 --- a/Projects/Feature/FortuneFeature/Sources/Views/FortuneResultComponents.swift +++ b/Projects/Feature/FortuneFeature/Sources/Views/FortuneResultComponents.swift @@ -52,6 +52,6 @@ struct FortuneTodakInquiryBar: View { .padding(.horizontal, 20) .padding(.top, 12) .padding(.bottom, 8) - .background(Color.fortuneSpaceBase) + .background(Color.clear) } } diff --git a/Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneView.swift b/Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneView.swift index 8fd0b29..3a74589 100644 --- a/Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneView.swift +++ b/Projects/Feature/FortuneFeature/Sources/YearFortune/YearFortuneView.swift @@ -122,7 +122,6 @@ private struct YearFortuneFormContent: View { store.send(.createTapped) } .disabled(store.isSubmitting) - .opacity(store.isSubmitting ? 0.4 : 1.0) .padding(.top, 12) } } diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift index 1b80aae..6c45607 100644 --- a/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift +++ b/Projects/Feature/FortuneFeature/Tests/FortuneFeatureTests.swift @@ -342,8 +342,8 @@ extension FortuneFeatureTests { await store.receive(.delegate(.luckyActionRequested)) } - @Test("궁합 화면의 내 정보 변경 요청을 상위 myPageRequested delegate로 전파한다") - func compatibilityMyInfoEditDelegatesToMyPage() async { + @Test("궁합 화면의 내 정보 변경 요청을 상위 myInfoEditRequested delegate로 전파한다") + func compatibilityMyInfoEditDelegatesToMyInfoEdit() async { var state = FortuneFeature.State() state.path.append(.compatibility(.init())) @@ -352,7 +352,7 @@ extension FortuneFeatureTests { } await store.send(.path(.element(id: 0, action: .compatibility(.delegate(.myInfoEditRequested))))) - await store.receive(.delegate(.myPageRequested)) + await store.receive(.delegate(.myInfoEditRequested)) } @Test("상세운 바텀시트에서 토닥이 탭 시 상위 todakRequested delegate로 전파한다") diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift index 4fec625..002a8e3 100644 --- a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift +++ b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift @@ -161,6 +161,26 @@ struct FortuneFollowupFeatureTests { } } + @Test("내 정보 수정 후에는 궁합 화면의 내 사주를 다시 불러온다") + func compatibilityRefreshesMySajuAfterProfileUpdate() async { + let previousChart = makeSajuChart(id: UUID(22), name: "이전 정보") + let refreshedChart = makeSajuChart(id: UUID(23), name: "수정된 정보") + var state = CompatibilityFeature.State() + state.viewState = .loaded + state.mySaju = previousChart + + let store = TestStore(initialState: state) { + CompatibilityFeature() + } withDependencies: { + $0.fortuneClient.fetchMySaju = { refreshedChart } + } + + await store.send(.mySajuRefreshRequested) + await store.receive(.mySajuRefreshResponse(.success(refreshedChart))) { + $0.mySaju = refreshedChart + } + } + @Test("택일 운세 생성 시 점수 순으로 상위 3개를 선별하여 저장한다") func dayFortuneCreatesAndSelectsTop3Results() async { let now = Date(timeIntervalSince1970: 1_788_969_600) @@ -243,6 +263,7 @@ struct FortuneFollowupFeatureTests { } await store.receive(\.partnersResponse.success) { $0.partners = [partner] + $0.viewState = .loaded } await store.receive(.partnerSajuResponse(partnerID, .success(chart))) { $0.selectedPartnerSaju = chart diff --git a/Projects/Feature/MyPageFeature/Sources/MyPageAccountSettingsReducer.swift b/Projects/Feature/MyPageFeature/Sources/MyPageAccountSettingsReducer.swift index e83e373..3b0a7d2 100644 --- a/Projects/Feature/MyPageFeature/Sources/MyPageAccountSettingsReducer.swift +++ b/Projects/Feature/MyPageFeature/Sources/MyPageAccountSettingsReducer.swift @@ -27,6 +27,7 @@ extension MyPageFeature { } public enum Delegate: Equatable { + case profileUpdated case sessionEnded } diff --git a/Projects/Feature/MyPageFeature/Sources/MyPageEditView.swift b/Projects/Feature/MyPageFeature/Sources/MyPageEditView.swift index 97f81ba..69d21d8 100644 --- a/Projects/Feature/MyPageFeature/Sources/MyPageEditView.swift +++ b/Projects/Feature/MyPageFeature/Sources/MyPageEditView.swift @@ -3,14 +3,14 @@ import DesignSystem import Model import SwiftUI -struct MyPageEditView: View { +public struct MyPageEditView: View { @Bindable private var store: StoreOf - init(store: StoreOf) { + public init(store: StoreOf) { self.store = store } - var body: some View { + public var body: some View { VStack(spacing: 0) { DSHeaderSub( title: "내 정보 수정", diff --git a/Projects/Feature/MyPageFeature/Sources/MyPageFeature.swift b/Projects/Feature/MyPageFeature/Sources/MyPageFeature.swift index 8c22f5a..b9cce24 100644 --- a/Projects/Feature/MyPageFeature/Sources/MyPageFeature.swift +++ b/Projects/Feature/MyPageFeature/Sources/MyPageFeature.swift @@ -21,6 +21,7 @@ public struct MyPageFeature { public var isLogoutConfirmationPresented = false public var logoutError: MyPageClientError? public var sajuManagement: SajuManagementState? + public var isPendingEditPresentation: Bool = false public init() {} } @@ -178,6 +179,7 @@ public struct MyPageFeature { case task case retryButtonTapped case dashboardResponse(Result) + case presentEdit case editButtonTapped case editDismissButtonTapped case editGenderChanged(Gender?) @@ -290,12 +292,37 @@ public struct MyPageFeature { case let .dashboardResponse(.success(dashboard)): state.dashboard = dashboard state.phase = .loaded + if state.isPendingEditPresentation { + state.isPendingEditPresentation = false + state.edit = EditState(profile: dashboard.profile) + } return .none case let .dashboardResponse(.failure(error)): state.phase = .failed(error) + state.isPendingEditPresentation = false return .none + case .presentEdit: + if let profile = state.dashboard?.profile { + state.edit = EditState(profile: profile) + state.isPendingEditPresentation = false + return .none + } else { + state.isPendingEditPresentation = true + guard state.phase != .loading else { return .none } + state.phase = .loading + return .run { send in + await send( + .dashboardResponse( + Result { try await myPageClient.loadDashboard() } + .mapError(MyPageClientError.init) + ) + ) + } + .cancellable(id: CancelID.loadDashboard, cancelInFlight: true) + } + case .editButtonTapped: guard let profile = state.dashboard?.profile else { return .none } state.edit = EditState(profile: profile) @@ -303,6 +330,7 @@ public struct MyPageFeature { case .editDismissButtonTapped: state.edit = nil + state.isPendingEditPresentation = false return .none case let .editGenderChanged(value): @@ -372,7 +400,7 @@ public struct MyPageFeature { state.dashboard = dashboard state.phase = .loaded state.edit = nil - return .none + return .send(.delegate(.profileUpdated)) case let .editResponse(.failure(error)): state.edit?.isSaving = false diff --git a/Projects/Feature/MyPageFeature/Tests/MyPageFeatureTests.swift b/Projects/Feature/MyPageFeature/Tests/MyPageFeatureTests.swift index 05243b8..2dde684 100644 --- a/Projects/Feature/MyPageFeature/Tests/MyPageFeatureTests.swift +++ b/Projects/Feature/MyPageFeature/Tests/MyPageFeatureTests.swift @@ -103,6 +103,7 @@ final class MyPageFeatureTests: XCTestCase { $0.phase = .loaded $0.edit = nil } + await store.receive(.delegate(.profileUpdated)) } func testCalendarButtonPresentsSajuDetail() async { From b1aaf4cc0fe8c91493b04fc74e5ad3def36bfc1f Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Sat, 15 Aug 2026 19:49:14 +0900 Subject: [PATCH 13/16] =?UTF-8?q?chore:=20Fortune=20=ED=9B=84=EC=86=8D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=9D=98=20SwiftLint=20=EC=9C=84=EB=B0=98=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Form/DSSajuBasicFieldsView.swift | 7 ++- .../FortuneProfile/BirthDatePolicyTests.swift | 36 ++++++++++--- .../Compatibility/CompatibilityView.swift | 5 +- .../Tests/FortuneFollowupFeatureTests.swift | 51 +++++++++++-------- .../Sources/MyPageEditView.swift | 5 +- .../Sources/MyPageSajuManagementView.swift | 6 ++- 6 files changed, 79 insertions(+), 31 deletions(-) diff --git a/Projects/Core/DesignSystem/Sources/Components/Form/DSSajuBasicFieldsView.swift b/Projects/Core/DesignSystem/Sources/Components/Form/DSSajuBasicFieldsView.swift index 84fdc27..42060e4 100644 --- a/Projects/Core/DesignSystem/Sources/Components/Form/DSSajuBasicFieldsView.swift +++ b/Projects/Core/DesignSystem/Sources/Components/Form/DSSajuBasicFieldsView.swift @@ -142,7 +142,12 @@ public struct DSSajuBasicFieldsView: View { } private var birthDayItems: [DSWheelPickerItem] { - let maxDay = BirthDatePolicy.maximumDay(forYear: pickerYear, month: pickerMonth, asOf: Date(), calendar: calendar) + let maxDay = BirthDatePolicy.maximumDay( + forYear: pickerYear, + month: pickerMonth, + asOf: Date(), + calendar: calendar + ) return (1...maxDay).map { DSWheelPickerItem(value: $0, title: "\($0)일") } } diff --git a/Projects/Core/Model/Tests/FortuneProfile/BirthDatePolicyTests.swift b/Projects/Core/Model/Tests/FortuneProfile/BirthDatePolicyTests.swift index 5bb1447..d88467d 100644 --- a/Projects/Core/Model/Tests/FortuneProfile/BirthDatePolicyTests.swift +++ b/Projects/Core/Model/Tests/FortuneProfile/BirthDatePolicyTests.swift @@ -82,24 +82,48 @@ struct BirthDatePolicyTests { #expect(BirthDatePolicy.validateNotInFuture(for: todayDate, asOf: referenceDate, calendar: testCalendar) == nil) let futureDate = BirthDate(year: 2026, month: 8, day: 16) - #expect(BirthDatePolicy.validateNotInFuture(for: futureDate, asOf: referenceDate, calendar: testCalendar) == BirthDatePolicy.futureDateMessage) + #expect( + BirthDatePolicy.validateNotInFuture(for: futureDate, asOf: referenceDate, calendar: testCalendar) + == BirthDatePolicy.futureDateMessage + ) let invalidDate = BirthDate(year: 2023, month: 2, day: 29) - #expect(BirthDatePolicy.validateNotInFuture(for: invalidDate, asOf: referenceDate, calendar: testCalendar) == BirthDatePolicy.invalidDateMessage) + #expect( + BirthDatePolicy.validateNotInFuture(for: invalidDate, asOf: referenceDate, calendar: testCalendar) + == BirthDatePolicy.invalidDateMessage + ) } @Test("만 14세 미만 유효성 검증") func testValidateMinimumAge() { - #expect(BirthDatePolicy.validateMinimumAge(for: nil, minimumAge: 14, asOf: referenceDate, calendar: testCalendar) == nil) + #expect( + BirthDatePolicy.validateMinimumAge(for: nil, minimumAge: 14, asOf: referenceDate, calendar: testCalendar) + == nil + ) let eligibleDate = BirthDate(year: 2012, month: 8, day: 15) - #expect(BirthDatePolicy.validateMinimumAge(for: eligibleDate, minimumAge: 14, asOf: referenceDate, calendar: testCalendar) == nil) + #expect( + BirthDatePolicy.validateMinimumAge( + for: eligibleDate, minimumAge: 14, asOf: referenceDate, calendar: testCalendar + ) + == nil + ) let underAgeDate = BirthDate(year: 2012, month: 8, day: 16) - #expect(BirthDatePolicy.validateMinimumAge(for: underAgeDate, minimumAge: 14, asOf: referenceDate, calendar: testCalendar) == BirthDatePolicy.minimumAgeMessage) + #expect( + BirthDatePolicy.validateMinimumAge( + for: underAgeDate, minimumAge: 14, asOf: referenceDate, calendar: testCalendar + ) + == BirthDatePolicy.minimumAgeMessage + ) let futureDate = BirthDate(year: 2027, month: 1, day: 1) - #expect(BirthDatePolicy.validateMinimumAge(for: futureDate, minimumAge: 14, asOf: referenceDate, calendar: testCalendar) == BirthDatePolicy.futureDateMessage) + #expect( + BirthDatePolicy.validateMinimumAge( + for: futureDate, minimumAge: 14, asOf: referenceDate, calendar: testCalendar + ) + == BirthDatePolicy.futureDateMessage + ) } @Test("BirthDate Date 변환 및 Comparable 검증") diff --git a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift index a7cb886..6f83d40 100644 --- a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift +++ b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityView.swift @@ -287,7 +287,10 @@ private struct PartnerRegistrationView: View { birthDate: $store.birthDate.sending(\.birthDateChanged), birthTime: $store.birthTime.sending(\.birthTimeChanged), isBirthTimeUnknown: $store.isBirthTimeUnknown.sending(\.birthTimeUnknownChanged), - birthDateValidationMessage: BirthDatePolicy.validateNotInFuture(for: store.birthDate, asOf: Date()), + birthDateValidationMessage: BirthDatePolicy.validateNotInFuture( + for: store.birthDate, + asOf: Date() + ), spacing: 32 ) diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift index 002a8e3..1c33092 100644 --- a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift +++ b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift @@ -3,9 +3,8 @@ import Foundation import Model import Testing @testable import FortuneFeature - @MainActor -struct FortuneFollowupFeatureTests { +struct FortuneFollowupFeatureTests { // swiftlint:disable:this type_body_length @Test("선택 카테고리로 리포트에 진입하면 상세 조회 후 바텀시트를 연다") func reportLoadsAndPresentsRequestedCategory() async { let dailyID = UUID(10) @@ -184,11 +183,23 @@ struct FortuneFollowupFeatureTests { @Test("택일 운세 생성 시 점수 순으로 상위 3개를 선별하여 저장한다") func dayFortuneCreatesAndSelectsTop3Results() async { let now = Date(timeIntervalSince1970: 1_788_969_600) - let r1 = DayFortuneResult(id: UUID(), purpose: .travel, targetDate: now, score: 60, title: "보통", content: "내용1", categories: []) - let r2 = DayFortuneResult(id: UUID(), purpose: .travel, targetDate: now.addingTimeInterval(86_400), score: 95, title: "대길", content: "내용2", categories: []) - let r3 = DayFortuneResult(id: UUID(), purpose: .travel, targetDate: now.addingTimeInterval(172_800), score: 80, title: "길", content: "내용3", categories: []) - let r4 = DayFortuneResult(id: UUID(), purpose: .travel, targetDate: now.addingTimeInterval(259_200), score: 40, title: "소흉", content: "내용4", categories: []) - let results = [r1, r2, r3, r4] + let firstResult = DayFortuneResult( + id: UUID(), purpose: .travel, targetDate: now, score: 60, + title: "보통", content: "내용1", categories: [] + ) + let secondResult = DayFortuneResult( + id: UUID(), purpose: .travel, targetDate: now.addingTimeInterval(86_400), score: 95, + title: "대길", content: "내용2", categories: [] + ) + let thirdResult = DayFortuneResult( + id: UUID(), purpose: .travel, targetDate: now.addingTimeInterval(172_800), score: 80, + title: "길", content: "내용3", categories: [] + ) + let fourthResult = DayFortuneResult( + id: UUID(), purpose: .travel, targetDate: now.addingTimeInterval(259_200), score: 40, + title: "소흉", content: "내용4", categories: [] + ) + let results = [firstResult, secondResult, thirdResult, fourthResult] var state = DayFortuneFeature.State() state.selectedDates = [now, now.addingTimeInterval(86_400)] @@ -203,8 +214,8 @@ struct FortuneFollowupFeatureTests { } await store.receive(\.response.success) { $0.isSubmitting = false - $0.results = [r2, r3, r1] // 95, 80, 60 - $0.selectedResultID = r2.id + $0.results = [secondResult, thirdResult, firstResult] // 95, 80, 60 + $0.selectedResultID = secondResult.id } } @@ -340,29 +351,29 @@ struct FortuneFollowupFeatureTests { @Test("상대방 변경 시 이전 사주를 초기화하고 새로운 상대방 사주를 조회한다") func compatibilityPartnerSelectionUpdatesSaju() async { - let p1 = FortunePartner(id: UUID(34), name: "영희", relationship: .partner) - let p2 = FortunePartner(id: UUID(35), name: "철수", relationship: .friend) - let chart2 = makeSajuChart(id: p2.id, name: "철수") + let firstPartner = FortunePartner(id: UUID(34), name: "영희", relationship: .partner) + let secondPartner = FortunePartner(id: UUID(35), name: "철수", relationship: .friend) + let secondChart = makeSajuChart(id: secondPartner.id, name: "철수") var state = CompatibilityFeature.State() - state.partners = [p1, p2] - state.selectedPartnerID = p1.id - state.selectedPartnerSaju = makeSajuChart(id: p1.id, name: "영희") + state.partners = [firstPartner, secondPartner] + state.selectedPartnerID = firstPartner.id + state.selectedPartnerSaju = makeSajuChart(id: firstPartner.id, name: "영희") state.isPartnerPickerPresented = true let store = TestStore(initialState: state) { CompatibilityFeature() } withDependencies: { - $0.fortuneClient.fetchPartnerSaju = { _ in chart2 } + $0.fortuneClient.fetchPartnerSaju = { _ in secondChart } } - await store.send(.partnerSelected(p2.id)) { - $0.selectedPartnerID = p2.id + await store.send(.partnerSelected(secondPartner.id)) { + $0.selectedPartnerID = secondPartner.id $0.selectedPartnerSaju = nil $0.isPartnerPickerPresented = false } - await store.receive(.partnerSajuResponse(p2.id, .success(chart2))) { - $0.selectedPartnerSaju = chart2 + await store.receive(.partnerSajuResponse(secondPartner.id, .success(secondChart))) { + $0.selectedPartnerSaju = secondChart $0.errorMessage = nil } } diff --git a/Projects/Feature/MyPageFeature/Sources/MyPageEditView.swift b/Projects/Feature/MyPageFeature/Sources/MyPageEditView.swift index 69d21d8..06f2397 100644 --- a/Projects/Feature/MyPageFeature/Sources/MyPageEditView.swift +++ b/Projects/Feature/MyPageFeature/Sources/MyPageEditView.swift @@ -29,7 +29,10 @@ public struct MyPageEditView: View { birthDate: birthDateBinding, birthTime: birthTimeBinding, isBirthTimeUnknown: birthTimeUnknownBinding, - birthDateValidationMessage: BirthDatePolicy.validateMinimumAge(for: store.edit?.birthDate, asOf: Date()), + birthDateValidationMessage: BirthDatePolicy.validateMinimumAge( + for: store.edit?.birthDate, + asOf: Date() + ), spacing: 40 ) diff --git a/Projects/Feature/MyPageFeature/Sources/MyPageSajuManagementView.swift b/Projects/Feature/MyPageFeature/Sources/MyPageSajuManagementView.swift index af8079c..c06c761 100644 --- a/Projects/Feature/MyPageFeature/Sources/MyPageSajuManagementView.swift +++ b/Projects/Feature/MyPageFeature/Sources/MyPageSajuManagementView.swift @@ -3,7 +3,6 @@ import DesignSystem import Model import SwiftUI -// swiftlint:disable file_length struct MyPageSajuManagementView: View { @Bindable private var store: StoreOf @State private var menuButtonFrames: [String: CGRect] = [:] @@ -233,7 +232,10 @@ private struct MyPagePartnerFormView: View { birthDate: birthDateBinding, birthTime: birthTimeBinding, isBirthTimeUnknown: birthTimeUnknownBinding, - birthDateValidationMessage: BirthDatePolicy.validateNotInFuture(for: form?.birthDate, asOf: Date()), + birthDateValidationMessage: BirthDatePolicy.validateNotInFuture( + for: form?.birthDate, + asOf: Date() + ), spacing: 40 ) relationshipField From ef6cfd761e64d5acfaef352166f17d1b3769fa1c Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Sat, 15 Aug 2026 20:10:09 +0900 Subject: [PATCH 14/16] =?UTF-8?q?fix(fortune):=20=EB=82=B4=20=EC=82=AC?= =?UTF-8?q?=EC=A3=BC=20=EA=B0=B1=EC=8B=A0=20=EC=8B=A4=ED=8C=A8=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=A5=BC=20=ED=91=9C=EC=8B=9C=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로필 수정 후 궁합 내 사주 재조회 실패를 오류 배너로 전달합니다.\n- 기존 사주 정보는 유지하는 회귀 테스트를 추가합니다. --- .../Compatibility/CompatibilityFeature.swift | 3 ++- .../Tests/FortuneFollowupFeatureTests.swift | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift index a00016b..cafeb01 100644 --- a/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift +++ b/Projects/Feature/FortuneFeature/Sources/Compatibility/CompatibilityFeature.swift @@ -148,7 +148,8 @@ public struct CompatibilityFeature { state.mySaju = mySaju return .none - case .mySajuRefreshResponse(.failure): + case let .mySajuRefreshResponse(.failure(error)): + state.errorMessage = error.userMessage return .none case let .partnersResponse(.success(partners)): diff --git a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift index 1c33092..d34e585 100644 --- a/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift +++ b/Projects/Feature/FortuneFeature/Tests/FortuneFollowupFeatureTests.swift @@ -4,6 +4,7 @@ import Model import Testing @testable import FortuneFeature @MainActor +// swiftlint:disable file_length struct FortuneFollowupFeatureTests { // swiftlint:disable:this type_body_length @Test("선택 카테고리로 리포트에 진입하면 상세 조회 후 바텀시트를 연다") func reportLoadsAndPresentsRequestedCategory() async { @@ -180,6 +181,26 @@ struct FortuneFollowupFeatureTests { // swiftlint:disable:this type_body_length } } + @Test("내 사주 갱신 실패 시 기존 정보는 유지하고 오류를 표시한다") + func compatibilityShowsErrorWhenMySajuRefreshFails() async { + let previousChart = makeSajuChart(id: UUID(24), name: "이전 정보") + var state = CompatibilityFeature.State() + state.viewState = .loaded + state.mySaju = previousChart + + let store = TestStore(initialState: state) { + CompatibilityFeature() + } withDependencies: { + $0.fortuneClient.fetchMySaju = { throw FortuneClientError.transport } + } + + await store.send(.mySajuRefreshRequested) + await store.receive(.mySajuRefreshResponse(.failure(.transport))) { + $0.errorMessage = FortuneClientError.transport.userMessage + } + #expect(store.state.mySaju == previousChart) + } + @Test("택일 운세 생성 시 점수 순으로 상위 3개를 선별하여 저장한다") func dayFortuneCreatesAndSelectsTop3Results() async { let now = Date(timeIntervalSince1970: 1_788_969_600) From cb1780c7ac7e88efdd585433571fc7e708cbbc47 Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Sat, 15 Aug 2026 20:13:23 +0900 Subject: [PATCH 15/16] =?UTF-8?q?test(mypage):=20=EC=82=AC=EC=A3=BC=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EA=B2=80=EC=A6=9D=20fixture=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyPageFeature/Tests/MyPageFeatureTests.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Projects/Feature/MyPageFeature/Tests/MyPageFeatureTests.swift b/Projects/Feature/MyPageFeature/Tests/MyPageFeatureTests.swift index 2dde684..20edb86 100644 --- a/Projects/Feature/MyPageFeature/Tests/MyPageFeatureTests.swift +++ b/Projects/Feature/MyPageFeature/Tests/MyPageFeatureTests.swift @@ -305,6 +305,7 @@ final class MyPageFeatureTests: XCTestCase { func testEditStateValidationPolicies() { let profile = MyPageProfile( + memberID: "member-id", name: "토닥이", gender: "FEMALE", birthDate: "1999-05-15", @@ -317,24 +318,24 @@ final class MyPageFeatureTests: XCTestCase { var edit = MyPageFeature.EditState(profile: profile) XCTAssertTrue(edit.isValid) - // Under 14 years old + // 만 14세 미만 edit.birthDate = BirthDate(year: 2020, month: 1, day: 1) XCTAssertFalse(edit.isValid) - // Future date + // 미래 날짜 edit.birthDate = BirthDate(year: 2030, month: 1, day: 1) XCTAssertFalse(edit.isValid) - // Valid date + // 유효한 날짜 edit.birthDate = BirthDate(year: 1999, month: 5, day: 15) XCTAssertTrue(edit.isValid) - // Missing job + // 직업 미선택 edit.job = nil XCTAssertFalse(edit.isValid) edit.job = .student - // Unknown birth time + // 출생 시각 모름 edit.birthTime = nil edit.isBirthTimeUnknown = true XCTAssertTrue(edit.isValid) @@ -353,12 +354,12 @@ final class MyPageFeatureTests: XCTestCase { form.relationship = .friend XCTAssertTrue(form.isValid) - // Unknown time resets birthTime + // 출생 시각 모름 form.isBirthTimeUnknown = true form.birthTime = nil XCTAssertTrue(form.isValid) - // Future date is invalid + // 미래 날짜는 유효하지 않음 form.birthDate = BirthDate(year: 2030, month: 1, day: 1) XCTAssertFalse(form.isValid) } From 9cfc00dbc77da242a9dab888b51449c7a162611f Mon Sep 17 00:00:00 2001 From: Seungwon Lee Date: Sat, 15 Aug 2026 20:46:15 +0900 Subject: [PATCH 16/16] =?UTF-8?q?fix(fortune):=20=EA=B6=81=ED=95=A9=20?= =?UTF-8?q?=EB=82=B4=20=EC=A0=95=EB=B3=B4=20=ED=8E=B8=EC=A7=91=20=EA=B2=BD?= =?UTF-8?q?=ED=95=A9=20=EB=B0=A9=EC=A7=80=20(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App이 궁합 경로 식별자로 편집 요청의 수명을 관리하도록 변경\n- 뒤로가기와 탭 전환 시 대기 중인 편집 표시와 전용 대시보드 로드를 폐기\n- 늦은 응답이 편집 화면을 다시 표시하지 않도록 회귀 테스트 추가 --- .../App/Sources/MainTab/MainTabFeature.swift | 41 +++++++++++++++-- .../App/Sources/MainTab/MainTabView.swift | 21 ++++++++- Projects/App/Tests/MainTabFeatureTests.swift | 28 ++++++++++- .../MyPageFeature/Sources/MyPageFeature.swift | 20 +++++++- .../Tests/MyPageEditPresentationTests.swift | 46 +++++++++++++++++++ 5 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 Projects/Feature/MyPageFeature/Tests/MyPageEditPresentationTests.swift diff --git a/Projects/App/Sources/MainTab/MainTabFeature.swift b/Projects/App/Sources/MainTab/MainTabFeature.swift index 29d18e3..cf3f002 100644 --- a/Projects/App/Sources/MainTab/MainTabFeature.swift +++ b/Projects/App/Sources/MainTab/MainTabFeature.swift @@ -19,6 +19,9 @@ struct MainTabFeature { var selectedTab: Tab var fortune: FortuneFeature.State var myPage: MyPageFeature.State + /// 궁합 화면에서 시작된 내 정보 편집 요청의 출처를 보관한다. + /// 경로가 사라진 뒤 도착한 응답이 편집 화면을 표시하지 않도록 App에서 수명을 관리한다. + var compatibilityEditSourceID: StackElementID? init( selectedTab: Tab = .fortune, @@ -33,6 +36,7 @@ struct MainTabFeature { enum Action: Equatable { case selectedTabChanged(Tab) + case fortuneNavigationChanged case fortune(FortuneFeature.Action) case myPage(MyPageFeature.Action) } @@ -49,9 +53,24 @@ struct MainTabFeature { 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 @@ -65,12 +84,19 @@ struct MainTabFeature { 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)): - guard let destinationID = state.fortune.path.ids.last, - case .compatibility = state.fortune.path[id: destinationID] - else { + state.compatibilityEditSourceID = nil + guard let destinationID = currentCompatibilityDestinationID(in: state) else { return .none } return .send( @@ -89,4 +115,13 @@ struct MainTabFeature { } } } + + 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 + } } diff --git a/Projects/App/Sources/MainTab/MainTabView.swift b/Projects/App/Sources/MainTab/MainTabView.swift index a0b07ab..d67d592 100644 --- a/Projects/App/Sources/MainTab/MainTabView.swift +++ b/Projects/App/Sources/MainTab/MainTabView.swift @@ -41,6 +41,9 @@ struct MainTabView: View { } } .animation(.easeInOut(duration: 0.25), value: isMyInfoEditPresented) + .onChange(of: currentCompatibilityDestinationID) { _, _ in + store.send(.fortuneNavigationChanged) + } .frame(maxWidth: .infinity, maxHeight: .infinity) .safeAreaInset(edge: .bottom, spacing: 0) { if shouldShowBottomNavigation { @@ -53,17 +56,31 @@ struct MainTabView: View { private var shouldShowBottomNavigation: Bool { let fortuneShowing = store.selectedTab != .fortune || !store.fortune.isShowingDetail - let myPageShowing = store.myPage.edit == nil + 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 { - store.selectedTab == .fortune && store.myPage.edit != nil + 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 { diff --git a/Projects/App/Tests/MainTabFeatureTests.swift b/Projects/App/Tests/MainTabFeatureTests.swift index 660b310..dad1dd0 100644 --- a/Projects/App/Tests/MainTabFeatureTests.swift +++ b/Projects/App/Tests/MainTabFeatureTests.swift @@ -65,17 +65,41 @@ struct MainTabFeatureTests { @Test func fortuneDelegateMyInfoEditRequestedPresentsEditWithoutSwitchingTab() async { - let store = TestStore(initialState: MainTabFeature.State()) { + 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))) + 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() diff --git a/Projects/Feature/MyPageFeature/Sources/MyPageFeature.swift b/Projects/Feature/MyPageFeature/Sources/MyPageFeature.swift index b9cce24..42d5f32 100644 --- a/Projects/Feature/MyPageFeature/Sources/MyPageFeature.swift +++ b/Projects/Feature/MyPageFeature/Sources/MyPageFeature.swift @@ -22,6 +22,7 @@ public struct MyPageFeature { public var logoutError: MyPageClientError? public var sajuManagement: SajuManagementState? public var isPendingEditPresentation: Bool = false + public var isEditDashboardLoading = false public init() {} } @@ -180,6 +181,7 @@ public struct MyPageFeature { case retryButtonTapped case dashboardResponse(Result) case presentEdit + case discardPendingEditPresentation case editButtonTapped case editDismissButtonTapped case editGenderChanged(Gender?) @@ -266,6 +268,7 @@ public struct MyPageFeature { case .task: guard state.dashboard == nil, state.phase != .loading else { return .none } state.phase = .loading + state.isEditDashboardLoading = false return .run { send in await send( .dashboardResponse( @@ -279,6 +282,7 @@ public struct MyPageFeature { case .retryButtonTapped: guard state.phase != .loading else { return .none } state.phase = .loading + state.isEditDashboardLoading = false return .run { send in await send( .dashboardResponse( @@ -292,6 +296,7 @@ public struct MyPageFeature { case let .dashboardResponse(.success(dashboard)): state.dashboard = dashboard state.phase = .loaded + state.isEditDashboardLoading = false if state.isPendingEditPresentation { state.isPendingEditPresentation = false state.edit = EditState(profile: dashboard.profile) @@ -301,6 +306,7 @@ public struct MyPageFeature { case let .dashboardResponse(.failure(error)): state.phase = .failed(error) state.isPendingEditPresentation = false + state.isEditDashboardLoading = false return .none case .presentEdit: @@ -312,6 +318,7 @@ public struct MyPageFeature { state.isPendingEditPresentation = true guard state.phase != .loading else { return .none } state.phase = .loading + state.isEditDashboardLoading = true return .run { send in await send( .dashboardResponse( @@ -320,9 +327,19 @@ public struct MyPageFeature { ) ) } - .cancellable(id: CancelID.loadDashboard, cancelInFlight: true) + .cancellable(id: CancelID.loadDashboardForEdit, cancelInFlight: true) } + case .discardPendingEditPresentation: + let wasEditDashboardLoading = state.isEditDashboardLoading + state.edit = nil + state.isPendingEditPresentation = false + state.isEditDashboardLoading = false + if wasEditDashboardLoading { + state.phase = state.dashboard == nil ? .idle : .loaded + } + return .cancel(id: CancelID.loadDashboardForEdit) + case .editButtonTapped: guard let profile = state.dashboard?.profile else { return .none } state.edit = EditState(profile: profile) @@ -579,6 +596,7 @@ extension MyPageFeature { private enum CancelID { case loadDashboard + case loadDashboardForEdit case updateProfile } diff --git a/Projects/Feature/MyPageFeature/Tests/MyPageEditPresentationTests.swift b/Projects/Feature/MyPageFeature/Tests/MyPageEditPresentationTests.swift new file mode 100644 index 0000000..e1e15a0 --- /dev/null +++ b/Projects/Feature/MyPageFeature/Tests/MyPageEditPresentationTests.swift @@ -0,0 +1,46 @@ +import ComposableArchitecture +import Model +import Testing +@testable import MyPageFeature + +@Suite +@MainActor +struct MyPageEditPresentationTests { + @Test("편집 대기 취소 뒤 늦은 대시보드 응답은 편집 화면을 다시 표시하지 않는다") + func discardedPendingEditPresentationDoesNotReopenEdit() async { + let dashboard = MyPageDashboard( + profile: MyPageProfile( + memberID: "member-id", + name: "토닥이", + gender: "FEMALE", + birthDate: "1999-02-13", + calendarType: "SOLAR", + birthTime: "JASI", + isTimeUnknown: false, + job: "WORKER", + relationshipStatus: "SOLO" + ), + chart: MyPageSajuChart(pillars: []) + ) + var initialState = MyPageFeature.State() + initialState.phase = .loading + initialState.isPendingEditPresentation = true + initialState.isEditDashboardLoading = true + + let store = TestStore(initialState: initialState) { + MyPageFeature() + } + + await store.send(.discardPendingEditPresentation) { + $0.phase = .idle + $0.isPendingEditPresentation = false + $0.isEditDashboardLoading = false + } + + await store.send(.dashboardResponse(.success(dashboard))) { + $0.dashboard = dashboard + $0.phase = .loaded + } + #expect(store.state.edit == nil) + } +}