diff --git a/Projects/App/Sources/AppFeature.swift b/Projects/App/Sources/AppFeature.swift index 24c95cc..3a93901 100644 --- a/Projects/App/Sources/AppFeature.swift +++ b/Projects/App/Sources/AppFeature.swift @@ -12,7 +12,7 @@ import FeatureOnboarding @Reducer struct AppFeature { @ObservableState - struct State: Equatable { + struct State { enum Route: Equatable { case splash case intro @@ -25,7 +25,7 @@ struct AppFeature { var mainTab = MainTabFeature.State() } - enum Action: Equatable { + enum Action { case splash(SplashFeature.Action) case intro(IntroFeature.Action) case mainTab(MainTabFeature.Action) diff --git a/Projects/App/Sources/DoriApp.swift b/Projects/App/Sources/DoriApp.swift index 5eaf3e0..b5f2929 100644 --- a/Projects/App/Sources/DoriApp.swift +++ b/Projects/App/Sources/DoriApp.swift @@ -13,6 +13,7 @@ import DoriNetworkImpl import FeatureMyPage import FeatureOnboarding import FeatureAddDori +import FeatureHistory import PlatformKakaoAuth import PlatformKeychain @@ -46,7 +47,9 @@ struct DoriApp: App { ) $0.addDoriAPIClient = .live(networkService: networkService) - + + $0.historyAPIClient = .live(networkService: networkService) + $0.myPageAPIClient = .live( networkService: networkService, tokenStore: tokenStore diff --git a/Projects/App/Sources/MainTabView.swift b/Projects/App/Sources/MainTabView.swift index 7080325..9b8150f 100644 --- a/Projects/App/Sources/MainTabView.swift +++ b/Projects/App/Sources/MainTabView.swift @@ -14,7 +14,7 @@ import FeatureMyPage @Reducer struct MainTabFeature { @ObservableState - struct State: Equatable { + struct State { var selectedTab: Tab = .calendar var calendar = CalendarFeature.State() var history = HistoryFeature.State() @@ -25,13 +25,13 @@ struct MainTabFeature { } } - enum Action: Equatable { + enum Action { case tabSelected(State.Tab) case calendar(CalendarFeature.Action) case history(HistoryFeature.Action) case myPage(MyPageFeature.Action) case delegate(Delegate) - + enum Delegate: Equatable { case needsAuthentication } diff --git a/Projects/Core/DoriCore/Sources/Models/DoriDomain.swift b/Projects/Core/DoriCore/Sources/Models/DoriDomain.swift index 356bf12..4007171 100644 --- a/Projects/Core/DoriCore/Sources/Models/DoriDomain.swift +++ b/Projects/Core/DoriCore/Sources/Models/DoriDomain.swift @@ -8,28 +8,30 @@ import Foundation -public struct Dori: Equatable, Sendable { +public struct Dori: Identifiable, Equatable, Hashable, Sendable { + public var id: Int64 { doriId } + public let doriId: Int64 public let userId: Int64 public let partnerId: Int64 - public let direction: Direction + public let direction: TransactionType public let partnerName: String - public let relationship: Relationship - public let eventType: EventType + public let relationship: String + public let eventType: String public let amount: Int32 public let eventDate: String public let isVisited: Bool public let memo: String public let createdAt: String - + public init( doriId: Int64, userId: Int64, partnerId: Int64, - direction: Direction, + direction: TransactionType, partnerName: String, - relationship: Relationship, - eventType: EventType, + relationship: String, + eventType: String, amount: Int32, eventDate: String, isVisited: Bool, @@ -50,10 +52,3 @@ public struct Dori: Equatable, Sendable { self.createdAt = createdAt } } - -public extension Dori { - enum Direction: String, Equatable, Sendable { - case `in` = "IN" - case out = "OUT" - } -} diff --git a/Projects/Core/DoriCore/Sources/Models/DoriInput.swift b/Projects/Core/DoriCore/Sources/Models/DoriInput.swift new file mode 100644 index 0000000..93d1759 --- /dev/null +++ b/Projects/Core/DoriCore/Sources/Models/DoriInput.swift @@ -0,0 +1,71 @@ +// +// DoriInput.swift +// Dori-iOS +// +// Created by 강동영 on 2/23/26. +// + +import Foundation + +// MARK: - 생성 요청용 + +public struct DoriPostInput: Equatable, Sendable { + public let partnerId: Int64? + public let direction: TransactionType + public let partnerName: String + public let relationship: String + public let eventType: String + public let amount: Int32 + public let eventDate: String + public let isVisited: Bool + public let memo: String? + + public init( + partnerId: Int64? = nil, + direction: TransactionType, + partnerName: String, + relationship: String, + eventType: String, + amount: Int32, + eventDate: String, + isVisited: Bool, + memo: String? = nil + ) { + self.partnerId = partnerId + self.direction = direction + self.partnerName = partnerName + self.relationship = relationship + self.eventType = eventType + self.amount = amount + self.eventDate = eventDate + self.isVisited = isVisited + self.memo = memo + } +} + +// MARK: - 수정 요청용 + +public struct DoriUpdateInput: Equatable, Sendable { + public let direction: TransactionType? + public let eventType: String? + public let amount: Int32? + public let eventDate: String? + public let isVisited: Bool? + public let memo: String? + + public init( + direction: TransactionType? = nil, + eventType: String? = nil, + amount: Int32? = nil, + eventDate: String? = nil, + isVisited: Bool? = nil, + memo: String? = nil + ) { + self.direction = direction + self.eventType = eventType + self.amount = amount + self.eventDate = eventDate + self.isVisited = isVisited + self.memo = memo + } +} diff --git a/Projects/Core/DoriCore/Sources/Models/PartnerDoriList.swift b/Projects/Core/DoriCore/Sources/Models/PartnerDoriList.swift new file mode 100644 index 0000000..d7f18cd --- /dev/null +++ b/Projects/Core/DoriCore/Sources/Models/PartnerDoriList.swift @@ -0,0 +1,39 @@ +// +// PartnerDoriList.swift +// Dori-iOS +// +// Created by 강동영 on 2/23/26. +// + +import Foundation + +public struct PartnerDoriList: Equatable, Sendable { + public let userId: Int64 + public let partnerId: Int64 + public let partnerName: String + public let relationship: String + public let inDoriTotalAmount: Int64 + public let inDoriList: [Dori] + public let outDoriTotalAmount: Int64 + public let outDoriList: [Dori] + + public init( + userId: Int64, + partnerId: Int64, + partnerName: String, + relationship: String, + inDoriTotalAmount: Int64, + inDoriList: [Dori], + outDoriTotalAmount: Int64, + outDoriList: [Dori] + ) { + self.userId = userId + self.partnerId = partnerId + self.partnerName = partnerName + self.relationship = relationship + self.inDoriTotalAmount = inDoriTotalAmount + self.inDoriList = inDoriList + self.outDoriTotalAmount = outDoriTotalAmount + self.outDoriList = outDoriList + } +} diff --git a/Projects/Core/DoriCore/Sources/Models/PartnerSummary.swift b/Projects/Core/DoriCore/Sources/Models/PartnerSummary.swift new file mode 100644 index 0000000..2cddb4c --- /dev/null +++ b/Projects/Core/DoriCore/Sources/Models/PartnerSummary.swift @@ -0,0 +1,35 @@ +// +// PartnerSummary.swift +// Dori-iOS +// +// Created by 강동영 on 2/23/26. +// + +import Foundation + +public struct PartnerSummary: Identifiable, Equatable, Hashable, Sendable { + public var id: Int64 { partnerId } + + public let partnerId: Int64 + public let partnerName: String + public let relationship: String + public let recentDoriList: [Dori] + public let inDoriTotalAmount: Int64 + public let outDoriTotalAmount: Int64 + + public init( + partnerId: Int64, + partnerName: String, + relationship: String, + recentDoriList: [Dori], + inDoriTotalAmount: Int64, + outDoriTotalAmount: Int64 + ) { + self.partnerId = partnerId + self.partnerName = partnerName + self.relationship = relationship + self.recentDoriList = recentDoriList + self.inDoriTotalAmount = inDoriTotalAmount + self.outDoriTotalAmount = outDoriTotalAmount + } +} diff --git a/Projects/Core/DoriCore/Sources/Models/TransactionType.swift b/Projects/Core/DoriCore/Sources/Models/TransactionType.swift index 8588d33..17fd8f5 100644 --- a/Projects/Core/DoriCore/Sources/Models/TransactionType.swift +++ b/Projects/Core/DoriCore/Sources/Models/TransactionType.swift @@ -8,8 +8,17 @@ import Foundation public enum TransactionType: String, CaseIterable, Codable, Equatable, Sendable, Hashable, Identifiable { - case given = "주도리" - case received = "받도리" + case judori = "OUT" + case baddori = "IN" public var id: String { rawValue } + + public var displayName: String { + switch self { + case .judori: + "주도리" + case .baddori: + "받도리" + } + } } diff --git a/Projects/Feature/AddDori/Sources/Visited.swift b/Projects/Core/DoriCore/Sources/Models/Visited.swift similarity index 91% rename from Projects/Feature/AddDori/Sources/Visited.swift rename to Projects/Core/DoriCore/Sources/Models/Visited.swift index 9b45c41..3e0a206 100644 --- a/Projects/Feature/AddDori/Sources/Visited.swift +++ b/Projects/Core/DoriCore/Sources/Models/Visited.swift @@ -11,7 +11,7 @@ public enum Visited: String, CaseIterable, Hashable, Sendable { case yes = "예" case no = "아니오" - var boolValue: Bool { + public var boolValue: Bool { switch self { case .yes: return true case .no: return false diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_birthday.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_birthday.imageset/Contents.json new file mode 100644 index 0000000..7fdc1b7 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_birthday.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "baddori_birthday.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_birthday.imageset/baddori_birthday.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_birthday.imageset/baddori_birthday.svg new file mode 100644 index 0000000..526cc81 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_birthday.imageset/baddori_birthday.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_etc.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_etc.imageset/Contents.json new file mode 100644 index 0000000..d83d45a --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_etc.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "baddori_etc.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_etc.imageset/baddori_etc.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_etc.imageset/baddori_etc.svg new file mode 100644 index 0000000..9f9f0c9 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_etc.imageset/baddori_etc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_firstBirthday.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_firstBirthday.imageset/Contents.json new file mode 100644 index 0000000..bcb41e2 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_firstBirthday.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "baddori_firstBirthday.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_firstBirthday.imageset/baddori_firstBirthday.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_firstBirthday.imageset/baddori_firstBirthday.svg new file mode 100644 index 0000000..aa7ad26 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_firstBirthday.imageset/baddori_firstBirthday.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_funeral.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_funeral.imageset/Contents.json new file mode 100644 index 0000000..437cd1f --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_funeral.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "baddori_funeral.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_funeral.imageset/baddori_funeral.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_funeral.imageset/baddori_funeral.svg new file mode 100644 index 0000000..e593426 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_funeral.imageset/baddori_funeral.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_housewarming.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_housewarming.imageset/Contents.json new file mode 100644 index 0000000..697c39a --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_housewarming.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "baddori_housewarming.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_housewarming.imageset/baddori_housewarming.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_housewarming.imageset/baddori_housewarming.svg new file mode 100644 index 0000000..cd63c12 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_housewarming.imageset/baddori_housewarming.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_wedding.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_wedding.imageset/Contents.json new file mode 100644 index 0000000..2c75a74 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_wedding.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "baddori_wedding.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_wedding.imageset/baddori_wedding.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_wedding.imageset/baddori_wedding.svg new file mode 100644 index 0000000..56cbd90 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/baddori_wedding.imageset/baddori_wedding.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/icon_baddori.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/icon_baddori.imageset/Contents.json new file mode 100644 index 0000000..6df9f07 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/icon_baddori.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon_baddori.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/icon_baddori.imageset/icon_baddori.png b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/icon_baddori.imageset/icon_baddori.png new file mode 100644 index 0000000..7acc012 Binary files /dev/null and b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/baddori/icon_baddori.imageset/icon_baddori.png differ diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_delete.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_delete.imageset/Contents.json new file mode 100644 index 0000000..aad4968 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_delete.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon_delete.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_delete.imageset/icon_delete.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_delete.imageset/icon_delete.svg new file mode 100644 index 0000000..4d65b65 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_delete.imageset/icon_delete.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_edit.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_edit.imageset/Contents.json new file mode 100644 index 0000000..98905f8 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_edit.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon_edit.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_edit.imageset/icon_edit.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_edit.imageset/icon_edit.svg new file mode 100644 index 0000000..30f8f18 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_edit.imageset/icon_edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_filter.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_filter.imageset/Contents.json new file mode 100644 index 0000000..786eb4a --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_filter.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon_filter.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_filter.imageset/icon_filter.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_filter.imageset/icon_filter.svg new file mode 100644 index 0000000..2d1ce08 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/icon_filter.imageset/icon_filter.svg @@ -0,0 +1,3 @@ + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/icon_judori.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/icon_judori.imageset/Contents.json new file mode 100644 index 0000000..8e68c88 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/icon_judori.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon_judori.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/icon_judori.imageset/icon_judori.png b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/icon_judori.imageset/icon_judori.png new file mode 100644 index 0000000..86bde0c Binary files /dev/null and b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/icon_judori.imageset/icon_judori.png differ diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_birthday.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_birthday.imageset/Contents.json new file mode 100644 index 0000000..bb35a50 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_birthday.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "judori_birthday.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_birthday.imageset/judori_birthday.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_birthday.imageset/judori_birthday.svg new file mode 100644 index 0000000..fd17661 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_birthday.imageset/judori_birthday.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_etc.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_etc.imageset/Contents.json new file mode 100644 index 0000000..93600dc --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_etc.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "judori_etc.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_etc.imageset/judori_etc.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_etc.imageset/judori_etc.svg new file mode 100644 index 0000000..9075b78 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_etc.imageset/judori_etc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_firstBirthday.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_firstBirthday.imageset/Contents.json new file mode 100644 index 0000000..de4119c --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_firstBirthday.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "judori_firstBirthday.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_firstBirthday.imageset/judori_firstBirthday.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_firstBirthday.imageset/judori_firstBirthday.svg new file mode 100644 index 0000000..ee657c0 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_firstBirthday.imageset/judori_firstBirthday.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_funeral.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_funeral.imageset/Contents.json new file mode 100644 index 0000000..91bed96 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_funeral.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "judori_funeral.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_funeral.imageset/judori_funeral.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_funeral.imageset/judori_funeral.svg new file mode 100644 index 0000000..b000d48 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_funeral.imageset/judori_funeral.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_housewarming.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_housewarming.imageset/Contents.json new file mode 100644 index 0000000..6c1d17e --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_housewarming.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "judori_housewarming.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_housewarming.imageset/judori_housewarming.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_housewarming.imageset/judori_housewarming.svg new file mode 100644 index 0000000..6aa02ac --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_housewarming.imageset/judori_housewarming.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_wedding.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_wedding.imageset/Contents.json new file mode 100644 index 0000000..f33b340 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_wedding.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "judori_wedding.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_wedding.imageset/judori_wedding.svg b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_wedding.imageset/judori_wedding.svg new file mode 100644 index 0000000..83d1a77 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Icons.xcassets/judori/judori_wedding.imageset/judori_wedding.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/placeholder_empty.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/placeholder_empty.imageset/Contents.json new file mode 100644 index 0000000..3e81dde --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/placeholder_empty.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "placeholder_empty.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/placeholder_empty.imageset/placeholder_empty.png b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/placeholder_empty.imageset/placeholder_empty.png new file mode 100644 index 0000000..8ea6b5e Binary files /dev/null and b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/placeholder_empty.imageset/placeholder_empty.png differ diff --git a/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/plcaeholder_search.imageset/Contents.json b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/plcaeholder_search.imageset/Contents.json new file mode 100644 index 0000000..26b5812 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/plcaeholder_search.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "plcaeholder_search.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/plcaeholder_search.imageset/plcaeholder_search.png b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/plcaeholder_search.imageset/plcaeholder_search.png new file mode 100644 index 0000000..8fe1673 Binary files /dev/null and b/Projects/Core/DoriDesignSystem/Resources/Images.xcassets/plcaeholder_search.imageset/plcaeholder_search.png differ diff --git a/Projects/Core/DoriDesignSystem/Sources/DoriEmptyView.swift b/Projects/Core/DoriDesignSystem/Sources/DoriEmptyView.swift new file mode 100644 index 0000000..c9df4fe --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/DoriEmptyView.swift @@ -0,0 +1,80 @@ +// +// DoriEmptyView.swift +// Dori-iOS +// +// Created by 강동영 on 2/23/26. +// + +import SwiftUI + +public struct DoriEmptyView: View { + + public struct Content: Sendable { + public let image: Image + public let title: String + public let description: String + + public init( + image: Image, + title: String, + description: String + ) { + self.image = image + self.title = title + self.description = description + } + } + + private let content: Content + + public init(_ content: Content) { + self.content = content + } + + public var body: some View { + ContentUnavailableView { + Label { + Text(content.title) + .pretendard(.headline(.h1)) + .foregroundStyle(.doriBlack) + } icon: { + content.image + .resizable() + .frame(width: 200, height: 200) + } + } description: { + Text(content.description) + .pretendard(.body(.r4)) + .foregroundStyle(.grey600) + } + } +} + +// MARK: - Predefined Content + +public extension DoriEmptyView.Content { + + /// 파트너 목록이 없을 때 + static let partnerList = DoriEmptyView.Content( + image: UIAsset.Images.placeholderEmpty.image, + title: "아직 주고받은 도리가 없어요", + description: "마음을 주고받은 순간들을 기록해보세요!" + ) + + /// 특정 파트너의 도리 내역이 없을 때 + static let doriHistory = DoriEmptyView.Content( + image: UIAsset.Images.plcaeholderSearch.image, + title: "검색된 도리가 없어요.", + description: "해당 도리의 기록을 찾을 수 없어요.\n다른 이름으로 검색해보세요." + ) +} + +// MARK: - Preview + +#Preview("파트너 목록 없음") { + DoriEmptyView(.partnerList) +} + +#Preview("도리 내역 없음") { + DoriEmptyView(.doriHistory) +} diff --git a/Projects/Core/DoriDesignSystem/Sources/DoriSegmentButton.swift b/Projects/Core/DoriDesignSystem/Sources/DoriSegmentButton.swift new file mode 100644 index 0000000..8370285 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/DoriSegmentButton.swift @@ -0,0 +1,47 @@ +// +// DoriSegmentButton.swift +// DoriCore +// +// Created by 강동영 on 2/23/26. +// Copyright © 2026 com.arex. All rights reserved. +// + +import SwiftUI + +public struct DoriSegmentButton: View { + let option: DoriSegmentOption + @Binding var selection: ID + + var isOn: Bool { selection == option.id } + + public var body: some View { + PrimaryButton(title: option.title) { + selection = option.id + } + .applyDoriSegmentStyle(isOn: isOn) + } +} + +fileprivate extension PrimaryButton { + func applyDoriSegmentStyle(isOn: Bool) -> Self { + isOn ? self.doriSelected() : self.doriUnselected() + } +} + +fileprivate extension PrimaryButton { + func doriSelected() -> Self { + self + .pretendard(.body(.sb3)) + .backgroundColor(.main) + .foregroundColor(.doriWhite) + + } + + func doriUnselected() -> Self { + self + .pretendard(.body(.r3)) + .backgroundColor(.doriWhite) + .foregroundColor(.grey500) + .strokeColor(.grey300) + } +} diff --git a/Projects/Feature/AddDori/Sources/Views/DoriTextField.swift b/Projects/Core/DoriDesignSystem/Sources/DoriTextField.swift similarity index 92% rename from Projects/Feature/AddDori/Sources/Views/DoriTextField.swift rename to Projects/Core/DoriDesignSystem/Sources/DoriTextField.swift index ef4d661..7aa8e89 100644 --- a/Projects/Feature/AddDori/Sources/Views/DoriTextField.swift +++ b/Projects/Core/DoriDesignSystem/Sources/DoriTextField.swift @@ -7,15 +7,14 @@ // import SwiftUI -import DoriDesignSystem -struct DoriTextField: View { +public struct DoriTextField: View { @Binding var memo: String private let placeholder: String private let maxLength: Int - init( + public init( _ placeholder: String, memo: Binding, maxLength: Int = 10 @@ -24,7 +23,8 @@ struct DoriTextField: View { self.placeholder = placeholder self.maxLength = maxLength } - var body: some View { + + public var body: some View { ZStack(alignment: .center) { RoundedRectangle(cornerRadius: 10) .stroke(.grey300, lineWidth: 1) diff --git a/Projects/Core/DoriDesignSystem/Sources/Modifiers/Modifiers.swift b/Projects/Core/DoriDesignSystem/Sources/Modifiers/Modifiers.swift new file mode 100644 index 0000000..76cdf65 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/Modifiers/Modifiers.swift @@ -0,0 +1,42 @@ +// +// Modifiers.swift +// DoriCore +// +// Created by 강동영 on 2/23/26. +// Copyright © 2026 com.arex. All rights reserved. +// + +import SwiftUI + +struct AddDoriSectionTitleStyleModifier: ViewModifier { + func body(content: Content) -> some View { + content + .pretendard(.subtitle(.m2)) + .foregroundStyle(.grey600) + } +} + +public extension Text { + func addDoriSectionTitleStyle() -> some View { + modifier(AddDoriSectionTitleStyleModifier()) + } +} + +struct RoundedBorderModifier: ViewModifier { + func body(content: Content) -> some View { + content + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .stroke(DoriColors.grey300.color) + ) + } +} + +public extension View { + /// H padding: 16, V padding: 12, r: 10, grey300 border + func roundedStyle() -> some View { + modifier(RoundedBorderModifier()) + } +} diff --git a/Projects/Core/DoriDesignSystem/Sources/PrimaryButton.swift b/Projects/Core/DoriDesignSystem/Sources/PrimaryButton.swift index eb651c0..d8f6c3d 100644 --- a/Projects/Core/DoriDesignSystem/Sources/PrimaryButton.swift +++ b/Projects/Core/DoriDesignSystem/Sources/PrimaryButton.swift @@ -26,7 +26,8 @@ public struct PrimaryButton: View { Text(titleKey) .pretendard(titleStyle) .foregroundStyle(foregroundColor) - .frame(maxWidth: .infinity, maxHeight: 53) + .frame(maxWidth: .infinity) + .frame(height: 46) .background( RoundedRectangle(cornerRadius: cornerRadius) .fill(backgroundColor) diff --git a/Projects/Feature/AddDori/Sources/Views/DoriSegmentGridWithMemo.swift b/Projects/Core/DoriDesignSystem/Sources/Segment/DoriSegmentGridWithMemo.swift similarity index 56% rename from Projects/Feature/AddDori/Sources/Views/DoriSegmentGridWithMemo.swift rename to Projects/Core/DoriDesignSystem/Sources/Segment/DoriSegmentGridWithMemo.swift index 77bbb13..4d80854 100644 --- a/Projects/Feature/AddDori/Sources/Views/DoriSegmentGridWithMemo.swift +++ b/Projects/Core/DoriDesignSystem/Sources/Segment/DoriSegmentGridWithMemo.swift @@ -1,24 +1,14 @@ // // DoriSegmentGridWithMemo.swift -// DoriFeature +// DoriCore // -// Created by 강동영 on 2/19/26. +// Created by 강동영 on 2/23/26. // Copyright © 2026 com.arex. All rights reserved. // import SwiftUI -import DoriDesignSystem -import DoriCore -struct DoriSegmentOption: Identifiable { - enum Role { case normal, other } - - let id: ID - let title: String - let role: Role -} - -struct DoriSegmentGridWithMemo: View { +public struct DoriSegmentGridWithMemo: View { let options: [DoriSegmentOption] @Binding var selection: ID @Binding var memo: String @@ -27,7 +17,7 @@ struct DoriSegmentGridWithMemo: View { private var otherID: ID? { options.first(where: { $0.role == .other })?.id } private var isOtherSelected: Bool { selection == otherID } - init( + public init( options: [DoriSegmentOption], selection: Binding, memo: Binding = .constant("") @@ -37,7 +27,7 @@ struct DoriSegmentGridWithMemo: View { self._memo = memo } - var body: some View { + public var body: some View { VStack(spacing: 12) { grid if isOtherSelected { memoField } @@ -101,68 +91,3 @@ private func chunked(_ array: [T], size: Int) -> [[T]] { Array(array[$0..: View { - let option: DoriSegmentOption - @Binding var selection: ID - - var isOn: Bool { selection == option.id } - - var body: some View { - PrimaryButton(title: option.title) { - selection = option.id - } - .applyDoriSegmentStyle(isOn: isOn) - } -} - -fileprivate extension PrimaryButton { - func applyDoriSegmentStyle(isOn: Bool) -> Self { - isOn ? self.doriSelected() : self.doriUnselected() - } -} - -fileprivate extension PrimaryButton { - func doriSelected() -> Self { - self - .pretendard(.body(.sb3)) - .backgroundColor(.main) - .foregroundColor(.doriWhite) - - } - - func doriUnselected() -> Self { - self - .pretendard(.body(.r3)) - .backgroundColor(.doriWhite) - .foregroundColor(.grey500) - .strokeColor(.grey300) - } -} - -extension Relationship { - func toSegmentOptions() -> DoriSegmentOption { - if self == .other { - DoriSegmentOption(id: self, title: self.rawValue, role: .other) - } else { - DoriSegmentOption(id: self, title: self.rawValue, role: .normal) - } - } -} - -extension EventType { - func toSegmentOptions() -> DoriSegmentOption { - if self == .other { - DoriSegmentOption(id: self, title: self.rawValue, role: .other) - } else { - DoriSegmentOption(id: self, title: self.rawValue, role: .normal) - } - } -} - -extension Visited { - func toSegmentOptions() -> DoriSegmentOption { - DoriSegmentOption(id: self, title: self.rawValue, role: .normal) - } -} - diff --git a/Projects/Core/DoriDesignSystem/Sources/Segment/DoriSegmentOption+Extensions.swift b/Projects/Core/DoriDesignSystem/Sources/Segment/DoriSegmentOption+Extensions.swift new file mode 100644 index 0000000..194965c --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/Segment/DoriSegmentOption+Extensions.swift @@ -0,0 +1,35 @@ +// +// DoriSegmentOption+Extensions.swift +// DoriCore +// +// Created by 강동영 on 2/23/26. +// Copyright © 2026 com.arex. All rights reserved. +// + +import DoriCore + +public extension Relationship { + func toSegmentOptions() -> DoriSegmentOption { + if self == .other { + DoriSegmentOption(id: self, title: self.rawValue, role: .other) + } else { + DoriSegmentOption(id: self, title: self.rawValue, role: .normal) + } + } +} + +public extension EventType { + func toSegmentOptions() -> DoriSegmentOption { + if self == .other { + DoriSegmentOption(id: self, title: self.rawValue, role: .other) + } else { + DoriSegmentOption(id: self, title: self.rawValue, role: .normal) + } + } +} + +public extension Visited { + func toSegmentOptions() -> DoriSegmentOption { + DoriSegmentOption(id: self, title: self.rawValue, role: .normal) + } +} diff --git a/Projects/Core/DoriDesignSystem/Sources/Segment/DoriSegmentOption.swift b/Projects/Core/DoriDesignSystem/Sources/Segment/DoriSegmentOption.swift new file mode 100644 index 0000000..df660e1 --- /dev/null +++ b/Projects/Core/DoriDesignSystem/Sources/Segment/DoriSegmentOption.swift @@ -0,0 +1,22 @@ +// +// DoriSegmentOption.swift +// DoriCore +// +// Created by 강동영 on 2/23/26. +// Copyright © 2026 com.arex. All rights reserved. +// + + +public struct DoriSegmentOption: Identifiable { + public enum Role { case normal, other } + + public let id: ID + public let title: String + public let role: Role + + public init(id: ID, title: String, role: Role) { + self.id = id + self.title = title + self.role = role + } +} diff --git a/Projects/Feature/AddDori/Sources/AddDoriAPIClient.swift b/Projects/Feature/AddDori/Sources/AddDoriAPIClient.swift index 9dec70b..6bbfebb 100644 --- a/Projects/Feature/AddDori/Sources/AddDoriAPIClient.swift +++ b/Projects/Feature/AddDori/Sources/AddDoriAPIClient.swift @@ -7,17 +7,18 @@ import Foundation import ComposableArchitecture +import DoriCore import DoriNetwork public struct AddDoriAPIClient: Sendable { - public var searchPartners: @Sendable (_ query: String) async throws -> [DoriResponsesDTO] - public var createDori: @Sendable (DoriPostRequest) async throws -> DoriResponsesDTO - public var updateDori: @Sendable (_ doriId: Int64, _ request: DoriUpdateRequest) async throws -> DoriResponsesDTO + public var searchPartners: @Sendable (_ query: String) async throws -> [Dori] + public var createDori: @Sendable (DoriPostInput) async throws -> Dori + public var updateDori: @Sendable (_ doriId: Int64, _ input: DoriUpdateInput) async throws -> Dori public init( - searchPartners: @escaping @Sendable (_ query: String) async throws -> [DoriResponsesDTO], - createDori: @escaping @Sendable (DoriPostRequest) async throws -> DoriResponsesDTO, - updateDori: @escaping @Sendable (_ doriId: Int64, _ request: DoriUpdateRequest) async throws -> DoriResponsesDTO + searchPartners: @escaping @Sendable (_ query: String) async throws -> [Dori], + createDori: @escaping @Sendable (DoriPostInput) async throws -> Dori, + updateDori: @escaping @Sendable (_ doriId: Int64, _ input: DoriUpdateInput) async throws -> Dori ) { self.searchPartners = searchPartners self.createDori = createDori @@ -52,11 +53,11 @@ extension AddDoriAPIClient: DependencyKey { public static let previewValue = Self( searchPartners: { query in [ - DoriResponsesDTO( + Dori( doriId: 1, userId: 1, partnerId: 1, - direction: "주도리", + direction: .judori, partnerName: "박수진", relationship: "친구", eventType: "결혼식", @@ -66,11 +67,11 @@ extension AddDoriAPIClient: DependencyKey { memo: "", createdAt: "2026-01-10" ), - DoriResponsesDTO( + Dori( doriId: 2, userId: 1, partnerId: 2, - direction: "주도리", + direction: .judori, partnerName: "박민준", relationship: "가족", eventType: "장례식", @@ -80,11 +81,11 @@ extension AddDoriAPIClient: DependencyKey { memo: "많이 힘드셨을텐데", createdAt: "2025-08-20" ), - DoriResponsesDTO( + Dori( doriId: 3, userId: 1, partnerId: 3, - direction: "받도리", + direction: .baddori, partnerName: "김철수", relationship: "직장동료", eventType: "돌잔치", @@ -94,11 +95,11 @@ extension AddDoriAPIClient: DependencyKey { memo: "", createdAt: "2025-11-05" ), - DoriResponsesDTO( + Dori( doriId: 4, userId: 1, partnerId: 4, - direction: "받도리", + direction: .baddori, partnerName: "김영희", relationship: "친척", eventType: "생신", @@ -110,35 +111,35 @@ extension AddDoriAPIClient: DependencyKey { ), ].filter { $0.partnerName.contains(query) } }, - createDori: { request in - DoriResponsesDTO( + createDori: { input in + Dori( doriId: 1, userId: 1, - partnerId: 0, - direction: request.direction, - partnerName: request.partnerName, - relationship: request.relationship, - eventType: request.eventType, - amount: request.amount, - eventDate: request.eventDate, - isVisited: request.isVisited, - memo: request.memo ?? "", + partnerId: input.partnerId ?? 0, + direction: input.direction, + partnerName: input.partnerName, + relationship: input.relationship, + eventType: input.eventType, + amount: input.amount, + eventDate: input.eventDate, + isVisited: input.isVisited, + memo: input.memo ?? "", createdAt: "2026-02-15" ) }, - updateDori: { doriId, request in - DoriResponsesDTO( + updateDori: { doriId, input in + Dori( doriId: doriId, userId: 1, partnerId: 1, - direction: request.direction ?? "주도리", + direction: input.direction ?? .judori, partnerName: "김철수", relationship: "친구", - eventType: request.eventType ?? "결혼식", - amount: request.amount ?? 50_000, - eventDate: request.eventDate ?? "2026-02-15", - isVisited: request.isVisited ?? true, - memo: request.memo ?? "", + eventType: input.eventType ?? "결혼식", + amount: input.amount ?? 50_000, + eventDate: input.eventDate ?? "2026-02-15", + isVisited: input.isVisited ?? true, + memo: input.memo ?? "", createdAt: "2026-02-15" ) } @@ -146,28 +147,28 @@ extension AddDoriAPIClient: DependencyKey { public static let testValue = Self( searchPartners: { _ in [] }, - createDori: { request in - DoriResponsesDTO( + createDori: { input in + Dori( doriId: 1, userId: 1, - partnerId: 0, - direction: request.direction, - partnerName: request.partnerName, - relationship: request.relationship, - eventType: request.eventType, - amount: request.amount, - eventDate: request.eventDate, - isVisited: request.isVisited, - memo: request.memo ?? "", + partnerId: input.partnerId ?? 0, + direction: input.direction, + partnerName: input.partnerName, + relationship: input.relationship, + eventType: input.eventType, + amount: input.amount, + eventDate: input.eventDate, + isVisited: input.isVisited, + memo: input.memo ?? "", createdAt: "2026-02-15" ) }, updateDori: { _, _ in - DoriResponsesDTO( + Dori( doriId: 1, userId: 1, partnerId: 1, - direction: "주도리", + direction: .judori, partnerName: "테스트", relationship: "친구", eventType: "결혼식", @@ -206,10 +207,10 @@ public extension AddDoriAPIClient { throw AddDoriAPIClientError.invalidResponse } - return data + return data.map { $0.toDomain() } }, - createDori: { request in - let endpoint = CreateDoriEndpoint(request: request) + createDori: { input in + let endpoint = CreateDoriEndpoint(request: input.toRequest()) let response = try await networkService.request( endpoint, responseType: SuccessResponse.self @@ -223,10 +224,10 @@ public extension AddDoriAPIClient { throw AddDoriAPIClientError.invalidResponse } - return data + return data.toDomain() }, - updateDori: { doriId, request in - let endpoint = UpdateDoriEndpoint(doriId: doriId, request: request) + updateDori: { doriId, input in + let endpoint = UpdateDoriEndpoint(doriId: doriId, request: input.toRequest()) let response = try await networkService.request( endpoint, responseType: SuccessResponse.self @@ -240,8 +241,58 @@ public extension AddDoriAPIClient { throw AddDoriAPIClientError.invalidResponse } - return data + return data.toDomain() } ) } } + +// MARK: - DTO → Domain 매핑 + +private extension DoriResponsesDTO { + func toDomain() -> Dori { + Dori( + doriId: doriId, + userId: userId, + partnerId: partnerId, + direction: direction == "OUT" ? .judori : .baddori, + partnerName: partnerName, + relationship: relationship, + eventType: eventType, + amount: amount, + eventDate: eventDate, + isVisited: isVisited, + memo: memo ?? "", + createdAt: createdAt + ) + } +} + +private extension DoriPostInput { + func toRequest() -> DoriPostRequest { + DoriPostRequest( + partnerId: partnerId, + direction: direction.rawValue, + partnerName: partnerName, + relationship: relationship, + eventType: eventType, + amount: amount, + eventDate: eventDate, + isVisited: isVisited, + memo: memo + ) + } +} + +private extension DoriUpdateInput { + func toRequest() -> DoriUpdateRequest { + DoriUpdateRequest( + direction: direction?.rawValue, + eventType: eventType, + amount: amount, + eventDate: eventDate, + isVisited: isVisited, + memo: memo + ) + } +} diff --git a/Projects/Feature/AddDori/Sources/AddDoriFeature.swift b/Projects/Feature/AddDori/Sources/AddDoriFeature.swift index 06a0e7e..7409880 100644 --- a/Projects/Feature/AddDori/Sources/AddDoriFeature.swift +++ b/Projects/Feature/AddDori/Sources/AddDoriFeature.swift @@ -13,65 +13,57 @@ import DoriNetwork @Reducer public struct AddDoriFeature { public init() {} - - // MARK: - Mode - - public enum Mode: Equatable, Sendable { - case create - case edit(DoriResponsesDTO) - } - + // MARK: - State - + @ObservableState public struct State: Equatable, Sendable { - public var mode: Mode public var currentPage: Int = 0 - + // Page 1: 이름/구분 - public var transactionType: TransactionType = .given + public var transactionType: TransactionType = .judori public var searchQuery: String = "" - public var searchResults: [DoriResponsesDTO] = [] - public var selectedPartner: DoriResponsesDTO? = nil + public var searchResults: [Dori] = [] + public var selectedPartner: Dori? = nil public var isSearching: Bool = false - + // Page 2: 관계/경조사 public var selectedRelationship: Relationship = .friend public var customRelationship: String = "" public var selectedEventType: EventType = .wedding public var customEventType: String = "" - + // Page 3: 금액/날짜 public var amountText: String = "" public var eventDate: Date = .init() public var isDatePickerVisible: Bool = false public var isVisited: Visited = .yes public var memo: String = "" - + public var isSubmitting: Bool = false - + // MARK: - Validation - + public var isPage1Valid: Bool { selectedPartner != nil || !searchQuery.trimmingCharacters(in: .whitespaces).isEmpty } - + public var isPage2Valid: Bool { let hasRelationship = (selectedRelationship != Relationship.other || !customRelationship.trimmingCharacters(in: .whitespaces).isEmpty) let hasEventType = (selectedEventType != EventType.other || !customEventType.trimmingCharacters(in: .whitespaces).isEmpty) - + print("hasRelationship: \(hasRelationship), hasEventType: \(hasEventType)") return hasRelationship && hasEventType } - + public var isPage3Valid: Bool { let digits = amountText.filter(\.isNumber) - guard let amount = Int(digits), amount > 0 else { return false } - return isVisited.boolValue + guard let amount = Int(digits) else { return false } + return amount > 0 } - + // MARK: - Computed - + public var partnerName: String { selectedPartner?.partnerName ?? searchQuery.trimmingCharacters(in: .whitespaces) } @@ -82,73 +74,39 @@ public struct AddDoriFeature { } return selectedRelationship.rawValue } - + public var resolvedEventType: String { if selectedEventType == EventType.other { return customEventType.trimmingCharacters(in: .whitespaces) } return selectedEventType.rawValue } - - public var navigationTitle: String { - switch mode { - case .create: "내역 추가" - case .edit: "내역 수정" - } - } - + // MARK: - Init - - public init(mode: Mode = .create) { - self.mode = mode - - if case let .edit(dori) = mode { - self.transactionType = dori.direction == TransactionType.given.rawValue ? .given : .received - self.searchQuery = dori.partnerName - - let knownRelationships = Relationship.allCases.map(\.rawValue) - if knownRelationships.contains(dori.relationship) { - self.selectedRelationship = Relationship(rawValue: dori.relationship) ?? .other - } else { - self.selectedRelationship = Relationship.other - self.customRelationship = dori.relationship - } - - let knownEventTypes = EventType.allCases.map(\.rawValue) - if knownEventTypes.contains(dori.eventType) { - self.selectedEventType = EventType(rawValue: dori.eventType) ?? .other - } else { - self.selectedEventType = EventType.other - self.customEventType = dori.eventType - } - - self.amountText = Int(dori.amount).decimalFormatted - self.isVisited = dori.isVisited ? .yes : .no - self.memo = dori.memo - } - } + + public init() {} } - + // MARK: - Action - + public enum Action: Equatable, Sendable { // Navigation case nextPageTapped case previousPageTapped - + // Page 1 case transactionTypeChanged(TransactionType) case searchQueryChanged(String) - case searchResponse([DoriResponsesDTO]) - case partnerSelected(DoriResponsesDTO?) + case searchResponse([Dori]) + case partnerSelected(Dori?) case clearSearchTapped - + // Page 2 case relationshipSelected(Relationship) case customRelationshipChanged(String) case eventTypeSelected(EventType) case customEventTypeChanged(String) - + // Page 3 case amountTextChanged(String) case addAmountTapped(Int) @@ -156,42 +114,41 @@ public struct AddDoriFeature { case eventDateChanged(Date) case isVisitedChanged(Visited) case memoChanged(String) - + // Submit case submitTapped - case submitResponse(Result) - + case submitResponse(Result) + // Delegate case delegate(Delegate) - + public enum Delegate: Equatable, Sendable { - case doriCreated(DoriResponsesDTO) - case doriUpdated(DoriResponsesDTO) + case doriCreated(Dori) case dismissed } } - + public struct SubmitError: Error, Equatable, Sendable { public let message: String - + public init(message: String) { self.message = message } } - + static let maxAmount = Int(Int32.max) // 2,147,483,647 (약 21.47억) private enum CancelID { case search } - + // MARK: - Dependencies @Dependency(\.continuousClock) var clock @Dependency(\.addDoriAPIClient) var apiClient - + // MARK: - Reducer - + public var body: some ReducerOf { Reduce { state, action in switch action { @@ -201,29 +158,29 @@ public struct AddDoriFeature { state.currentPage += 1 } return .none - + case .previousPageTapped: if state.currentPage > 0 { state.currentPage -= 1 } return .none - + // MARK: Page 1 case let .transactionTypeChanged(type): state.transactionType = type return .none - + case let .searchQueryChanged(query): state.searchQuery = String(query.prefix(10)) print("state.searchQuery: \(state.searchQuery)") state.selectedPartner = nil - + guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { state.searchResults = [] state.isSearching = false return .cancel(id: CancelID.search) } - + state.isSearching = true let searchPartners = apiClient.searchPartners let clock = self.clock @@ -236,18 +193,18 @@ public struct AddDoriFeature { id: CancelID.search, cancelInFlight: true ) - + case let .searchResponse(results): state.searchResults = results state.isSearching = false return .none - + case let .partnerSelected(partner): state.selectedPartner = partner if let partner { state.searchQuery = partner.partnerName state.searchResults = [] - + let knownRelationships = Relationship.allCases.map(\.rawValue) if knownRelationships.contains(partner.relationship) { state.selectedRelationship = Relationship(rawValue: partner.relationship) ?? .other @@ -257,13 +214,13 @@ public struct AddDoriFeature { } } return .none - + case .clearSearchTapped: state.searchQuery = "" state.searchResults = [] state.selectedPartner = nil return .cancel(id: CancelID.search) - + // MARK: Page 2 case let .relationshipSelected(relationship): state.selectedRelationship = relationship @@ -271,22 +228,22 @@ public struct AddDoriFeature { state.customRelationship = "" } return .none - + case let .customRelationshipChanged(text): state.customRelationship = String(text.prefix(10)) return .none - + case let .eventTypeSelected(eventType): state.selectedEventType = eventType if eventType != EventType.other { state.customEventType = "" } return .none - + case let .customEventTypeChanged(text): state.customEventType = String(text.prefix(10)) return .none - + // MARK: Page 3 case let .amountTextChanged(text): let digits = text.filter(\.isNumber) @@ -303,7 +260,7 @@ public struct AddDoriFeature { let total = min(current + amount, Self.maxAmount) state.amountText = total.decimalFormatted return .none - + case .datePickerToggled: state.isDatePickerVisible.toggle() return .none @@ -311,84 +268,53 @@ public struct AddDoriFeature { case let .eventDateChanged(date): state.eventDate = date return .none - + case let .isVisitedChanged(visited): state.isVisited = visited return .none - + case let .memoChanged(text): state.memo = String(text.prefix(40)) return .none - + // MARK: Submit case .submitTapped: guard !state.isSubmitting else { return .none } state.isSubmitting = true - + let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" let eventDateString = dateFormatter.string(from: state.eventDate) - - switch state.mode { - case .create: - let request = DoriPostRequest( - partnerId: 0, - direction: state.transactionType.rawValue, - partnerName: state.partnerName, - relationship: state.resolvedRelationship, - eventType: state.resolvedEventType, - amount: Int32(state.amountText.filter(\.isNumber)) ?? 0, - eventDate: eventDateString, - isVisited: state.isVisited.boolValue, - memo: state.memo.isEmpty ? nil : state.memo - ) - let createDori = apiClient.createDori - return .run { send in - do { - let response = try await createDori(request) - await send(.submitResponse(.success(response))) - } catch { - await send(.submitResponse(.failure(SubmitError(message: error.localizedDescription)))) - } - } - - case let .edit(existing): - let request = DoriUpdateRequest( - direction: state.transactionType.rawValue, - eventType: state.resolvedEventType, - amount: Int32(state.amountText.filter(\.isNumber)) ?? 0, - eventDate: eventDateString, - isVisited: state.isVisited.boolValue, - memo: state.memo.isEmpty ? nil : state.memo - ) - let doriId = existing.doriId - let updateDori = apiClient.updateDori - return .run { send in - do { - let response = try await updateDori( - doriId, - request - ) - await send(.submitResponse(.success(response))) - } catch { - await send(.submitResponse(.failure(SubmitError(message: error.localizedDescription)))) - } + + let request = DoriPostInput( + partnerId: nil, + direction: state.transactionType, + partnerName: state.partnerName, + relationship: state.resolvedRelationship, + eventType: state.resolvedEventType, + amount: Int32(state.amountText.filter(\.isNumber)) ?? 0, + eventDate: eventDateString, + isVisited: state.isVisited.boolValue, + memo: state.memo.isEmpty ? nil : state.memo + ) + let createDori = apiClient.createDori + return .run { send in + do { + let response = try await createDori(request) + await send(.submitResponse(.success(response))) + } catch { + await send(.submitResponse(.failure(SubmitError(message: error.localizedDescription)))) } } - + case let .submitResponse(.success(response)): state.isSubmitting = false - switch state.mode { - case .create: - return .send(.delegate(.doriCreated(response))) - case .edit: - return .send(.delegate(.doriUpdated(response))) - } - + return .send(.delegate(.doriCreated(response))) + case .submitResponse(.failure): state.isSubmitting = false return .none - + case .delegate: return .none } diff --git a/Projects/Feature/AddDori/Sources/AddDoriView.swift b/Projects/Feature/AddDori/Sources/AddDoriView.swift index 3830fc9..69adce5 100644 --- a/Projects/Feature/AddDori/Sources/AddDoriView.swift +++ b/Projects/Feature/AddDori/Sources/AddDoriView.swift @@ -26,18 +26,9 @@ public struct AddDoriView: View { value: store.currentPage ) } - .navigationTitle(store.state.navigationTitle) + .background(.doriWhite) + .navigationTitle("내역 추가") .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - store.send(.previousPageTapped) - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 17, weight: .semibold)) - } - } - } } private var pageIndicator: some View { @@ -86,7 +77,7 @@ public struct AddDoriView: View { #Preview { NavigationStack { AddDoriView( - store: Store(initialState: AddDoriFeature.State(mode: .create)) { + store: Store(initialState: AddDoriFeature.State()) { AddDoriFeature() } ) diff --git a/Projects/Feature/AddDori/Sources/Modifier/AddDoriModifiers.swift b/Projects/Feature/AddDori/Sources/Modifier/AddDoriModifiers.swift index 3f94b45..96265d7 100644 --- a/Projects/Feature/AddDori/Sources/Modifier/AddDoriModifiers.swift +++ b/Projects/Feature/AddDori/Sources/Modifier/AddDoriModifiers.swift @@ -9,36 +9,3 @@ import SwiftUI import DoriDesignSystem -struct AddDoriSectionTitleStyleModifier: ViewModifier { - func body(content: Content) -> some View { - content - .font(.pretendard(.subtitle(.m2))) - .foregroundStyle(.grey600) - } -} - -struct RoundedStyleModifier: ViewModifier { - func body(content: Content) -> some View { - content - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 10) - .stroke(DoriColors.grey300.color) - ) - } -} - -extension View { - /// H padding: 16, V padding: 12, r: 10 - func roundedStyle() -> some View { - modifier(RoundedStyleModifier()) - } - -} - -extension Text { - func addDoriSectionTitleStyle() -> some View { - modifier(AddDoriSectionTitleStyleModifier()) - } -} diff --git a/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift b/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift index 1405a03..dcde545 100644 --- a/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift +++ b/Projects/Feature/AddDori/Sources/Views/Page1NameTypeView.swift @@ -15,8 +15,8 @@ struct Page1NameTypeView: View { @Bindable var store: StoreOf private let options2x2: [DoriSegmentOption] = [ - .init(id: .given, title: TransactionType.given.rawValue, role: .normal), - .init(id: .received, title: TransactionType.received.rawValue, role: .normal), + .init(id: .judori, title: TransactionType.judori.displayName, role: .normal), + .init(id: .baddori, title: TransactionType.baddori.displayName, role: .normal), ] var body: some View { @@ -46,6 +46,7 @@ struct Page1NameTypeView: View { ) ) .pretendard(.body(.sb3)) + .foregroundStyle(.doriBlack) if !store.searchQuery.isEmpty { Button { @@ -100,14 +101,14 @@ struct Page1NameTypeView: View { #Preview("검색 결과 있음") { let state: AddDoriFeature.State = { - var s = AddDoriFeature.State(mode: .create) + var s = AddDoriFeature.State() s.searchQuery = "" s.searchResults = [ - DoriResponsesDTO( + Dori( doriId: 1, userId: 1, partnerId: 1, - direction: "주도리", + direction: .judori, partnerName: "박수진수진수진수진수", relationship: "친구야친구야", eventType: "결혼식", @@ -117,11 +118,11 @@ struct Page1NameTypeView: View { memo: "", createdAt: "2026-05-12" ), - DoriResponsesDTO( + Dori( doriId: 1, userId: 1, partnerId: 1, - direction: "주도리", + direction: .judori, partnerName: "박수진", relationship: "친구", eventType: "결혼식", @@ -131,11 +132,11 @@ struct Page1NameTypeView: View { memo: "", createdAt: "2026-05-12" ), - DoriResponsesDTO( + Dori( doriId: 2, userId: 1, partnerId: 2, - direction: "받도리", + direction: .baddori, partnerName: "박지민", relationship: "직장동료", eventType: "돌잔치", @@ -145,11 +146,11 @@ struct Page1NameTypeView: View { memo: "", createdAt: "2026-01-15" ), - DoriResponsesDTO( + Dori( doriId: 3, userId: 1, partnerId: 3, - direction: "주도리", + direction: .judori, partnerName: "박민준", relationship: "가족", eventType: "장례식", @@ -175,7 +176,7 @@ struct Page1NameTypeView: View { #Preview("빈 상태") { NavigationStack { Page1NameTypeView( - store: Store(initialState: AddDoriFeature.State(mode: .create)) { + store: Store(initialState: AddDoriFeature.State()) { AddDoriFeature() } ) diff --git a/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift b/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift index a792d44..7e9eac9 100644 --- a/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift +++ b/Projects/Feature/AddDori/Sources/Views/Page2RelationEventView.swift @@ -63,7 +63,7 @@ struct Page2RelationEventView: View { #Preview { Page2RelationEventView( - store: Store(initialState: AddDoriFeature.State(mode: .create)) { + store: Store(initialState: AddDoriFeature.State()) { AddDoriFeature() } ) diff --git a/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift b/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift index 5a3039b..6d53c02 100644 --- a/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift +++ b/Projects/Feature/AddDori/Sources/Views/Page3AmountDateView.swift @@ -157,7 +157,7 @@ struct Page3AmountDateView: View { struct AmountPreset: Hashable { let title: String let amount: Int - + init(_ title: String, _ amount: Int) { self.title = title self.amount = amount @@ -175,7 +175,7 @@ extension [AmountPreset] { #Preview { Page3AmountDateView( - store: Store(initialState: AddDoriFeature.State(mode: .create)) { + store: Store(initialState: AddDoriFeature.State()) { AddDoriFeature() } ) diff --git a/Projects/Feature/AddDori/Sources/Views/PartnerSearchResultRow.swift b/Projects/Feature/AddDori/Sources/Views/PartnerSearchResultRow.swift index d43e344..dec7a68 100644 --- a/Projects/Feature/AddDori/Sources/Views/PartnerSearchResultRow.swift +++ b/Projects/Feature/AddDori/Sources/Views/PartnerSearchResultRow.swift @@ -8,10 +8,11 @@ import SwiftUI import DoriDesignSystem import DoriNetwork +import DoriCore struct PartnerSearchResultRow: View { @Binding var searchQuery: String - let partner: DoriResponsesDTO + let partner: Dori var body: some View { HStack(spacing: 6) { @@ -76,11 +77,11 @@ public extension Font { @Previewable @State var searchQuery = "박수진" PartnerSearchResultRow( searchQuery: $searchQuery, - partner: DoriResponsesDTO( + partner: Dori( doriId: 1, userId: 1, partnerId: 1, - direction: "주도리", + direction: .judori, partnerName: "박수진수진수진수진수", relationship: "친구야친구야", eventType: "결혼식", diff --git a/Projects/Feature/AddDori/Tests/AddDoriFeatureTests.swift b/Projects/Feature/AddDori/Tests/AddDoriFeatureTests.swift index 2dff6bb..18d45c3 100644 --- a/Projects/Feature/AddDori/Tests/AddDoriFeatureTests.swift +++ b/Projects/Feature/AddDori/Tests/AddDoriFeatureTests.swift @@ -54,11 +54,11 @@ struct AddDoriFeatureTests { @Suite("초기 상태 확인") struct InitialStateTests { - @Test("create 모드 기본값 확인") - func createModeInitialState() { - let state = AddDoriFeature.State(mode: .create) + @Test("기본값 확인") + func initialState() { + let state = AddDoriFeature.State() #expect(state.currentPage == 0) - #expect(state.transactionType == .given) + #expect(state.transactionType == .judori) #expect(state.searchQuery == "") #expect(state.searchResults == []) #expect(state.selectedPartner == nil) @@ -71,50 +71,6 @@ struct AddDoriFeatureTests { #expect(state.isVisited == .yes) #expect(state.memo == "") #expect(state.isSubmitting == false) - #expect(state.navigationTitle == "내역 추가") - } - - @Test("edit 모드 - known relationship/eventType 초기화") - func editModeWithKnownTypes() { - let dto = DoriResponsesDTO.mock( - direction: "받도리", - partnerName: "이영희", - relationship: "가족", - eventType: "장례식", - amount: 50_000, - isVisited: false, - memo: "메모 내용" - ) - let state = AddDoriFeature.State(mode: .edit(dto)) - - #expect(state.transactionType == .received) - #expect(state.searchQuery == "이영희") - #expect(state.selectedRelationship == .family) - #expect(state.customRelationship == "") - #expect(state.selectedEventType == .funeral) - #expect(state.customEventType == "") - #expect(state.amountText == "50,000") - #expect(state.isVisited == .no) - #expect(state.memo == "메모 내용") - #expect(state.navigationTitle == "내역 수정") - } - - @Test("edit 모드 - unknown relationship 초기화") - func editModeWithUnknownRelationship() { - let dto = DoriResponsesDTO.mock(relationship: "동네친구") - let state = AddDoriFeature.State(mode: .edit(dto)) - - #expect(state.selectedRelationship == .other) - #expect(state.customRelationship == "동네친구") - } - - @Test("edit 모드 - unknown eventType 초기화") - func editModeWithUnknownEventType() { - let dto = DoriResponsesDTO.mock(eventType: "회갑잔치") - let state = AddDoriFeature.State(mode: .edit(dto)) - - #expect(state.selectedEventType == .other) - #expect(state.customEventType == "회갑잔치") } } @@ -125,21 +81,21 @@ struct AddDoriFeatureTests { @Test("isPage1Valid - searchQuery만 있을 때 유효") func page1ValidWithSearchQuery() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.searchQuery = "홍길동" #expect(state.isPage1Valid == true) } @Test("isPage1Valid - selectedPartner만 있을 때 유효") func page1ValidWithSelectedPartner() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.selectedPartner = .mock() #expect(state.isPage1Valid == true) } @Test("isPage1Valid - 공백 searchQuery, partner 없으면 무효") func page1InvalidWhenEmpty() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.searchQuery = " " state.selectedPartner = nil #expect(state.isPage1Valid == false) @@ -147,7 +103,7 @@ struct AddDoriFeatureTests { @Test("isPage2Valid - other 관계에 customRelationship 없으면 무효") func page2InvalidWithOtherRelationshipEmpty() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.selectedRelationship = .other state.customRelationship = "" #expect(state.isPage2Valid == false) @@ -155,7 +111,7 @@ struct AddDoriFeatureTests { @Test("isPage2Valid - other 관계에 customRelationship 있으면 유효") func page2ValidWithOtherRelationshipFilled() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.selectedRelationship = .other state.customRelationship = "동창" state.selectedEventType = .wedding @@ -164,7 +120,7 @@ struct AddDoriFeatureTests { @Test("isPage3Valid - 유효한 금액이면 true") func page3ValidWithAmount() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.amountText = "50000" state.isVisited = .yes #expect(state.isPage3Valid == true) @@ -172,21 +128,21 @@ struct AddDoriFeatureTests { @Test("isPage3Valid - 금액 0이면 무효") func page3InvalidWithZeroAmount() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.amountText = "0" #expect(state.isPage3Valid == false) } @Test("isPage3Valid - 금액 비어있으면 무효") func page3InvalidWithEmptyAmount() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.amountText = "" #expect(state.isPage3Valid == false) } @Test("isPage3Valid - 포맷된 문자열 '100,000'으로도 유효") func page3ValidWithFormattedAmount() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.amountText = "100,000" state.isVisited = .yes #expect(state.isPage3Valid == true) @@ -194,7 +150,7 @@ struct AddDoriFeatureTests { @Test("isPage3Valid - 공백 문자 포함된 포맷 문자열도 유효") func page3ValidWithLargeFormattedAmount() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.amountText = "1,000,000" state.isVisited = .yes #expect(state.isPage3Valid == true) @@ -209,7 +165,7 @@ struct AddDoriFeatureTests { @Test("nextPageTapped - 0에서 1로 이동") func nextPageFrom0To1() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -221,7 +177,7 @@ struct AddDoriFeatureTests { @Test("nextPageTapped - 1에서 2로 이동") func nextPageFrom1To2() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.currentPage = 1 let store = TestStore( @@ -237,7 +193,7 @@ struct AddDoriFeatureTests { @Test("nextPageTapped - 2에서 no-op") func nextPageFrom2IsNoOp() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.currentPage = 2 let store = TestStore( @@ -251,7 +207,7 @@ struct AddDoriFeatureTests { @Test("previousPageTapped - 2에서 1로 이동") func previousPageFrom2To1() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.currentPage = 2 let store = TestStore( @@ -267,7 +223,7 @@ struct AddDoriFeatureTests { @Test("previousPageTapped - 1에서 0으로 이동") func previousPageFrom1To0() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.currentPage = 1 let store = TestStore( @@ -284,7 +240,7 @@ struct AddDoriFeatureTests { @Test("previousPageTapped - 0에서 no-op") func previousPageFrom0IsNoOp() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -301,13 +257,13 @@ struct AddDoriFeatureTests { @Test("transactionTypeChanged - 받도리로 변경") func transactionTypeChanged() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } - await store.send(.transactionTypeChanged(.received)) { - $0.transactionType = .received + await store.send(.transactionTypeChanged(.baddori)) { + $0.transactionType = .baddori } } @@ -316,7 +272,7 @@ struct AddDoriFeatureTests { let mockResults = [DoriResponsesDTO.mock(partnerName: "김철수")] let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } withDependencies: { @@ -339,7 +295,7 @@ struct AddDoriFeatureTests { @Test("searchQueryChanged - 10자 초과 시 앞 10자만 저장") func searchQueryChangedTruncatedAt10() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } withDependencies: { @@ -362,7 +318,7 @@ struct AddDoriFeatureTests { @Test("searchQueryChanged - 빈 문자열 입력 시 results 초기화, isSearching = false") func searchQueryChangedWithEmptyStringClearsResults() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.searchQuery = "김" initial.searchResults = [.mock()] initial.isSearching = true @@ -383,7 +339,7 @@ struct AddDoriFeatureTests { @Test("searchQueryChanged - 공백만 입력 시 results 초기화") func searchQueryChangedWithWhitespaceOnlyClearsResults() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.searchResults = [.mock()] let store = TestStore( @@ -402,7 +358,7 @@ struct AddDoriFeatureTests { @Test("searchResponse - results 업데이트 및 isSearching = false") func searchResponseUpdatesResults() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.isSearching = true let store = TestStore( @@ -420,7 +376,7 @@ struct AddDoriFeatureTests { @Test("partnerSelected - 파트너 선택 시 searchQuery, relationship 업데이트") func partnerSelectedUpdatesState() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.searchResults = [.mock(partnerName: "박민수", relationship: "회사")] let store = TestStore( @@ -445,7 +401,7 @@ struct AddDoriFeatureTests { @Test("partnerSelected - unknown relationship 파트너 선택 시 customRelationship 설정") func partnerSelectedWithUnknownRelationship() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -466,7 +422,7 @@ struct AddDoriFeatureTests { @Test("partnerSelected(nil) - 선택 해제") func partnerDeselected() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.selectedPartner = .mock() let store = TestStore( @@ -484,7 +440,7 @@ struct AddDoriFeatureTests { func clearSearchTappedResetsSearchState() async { // clearSearchTapped Reducer는 isSearching을 별도로 변경하지 않으므로 // initial에서 isSearching은 기본값(false)으로 유지한다 - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.searchQuery = "김철수" initial.searchResults = [.mock()] initial.selectedPartner = .mock() @@ -511,7 +467,7 @@ struct AddDoriFeatureTests { @Test("relationshipSelected - 관계 변경") func relationshipSelected() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -523,7 +479,7 @@ struct AddDoriFeatureTests { @Test("relationshipSelected - other 아닌 값 선택 시 customRelationship 초기화") func relationshipSelectedClearsCustomWhenNotOther() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.selectedRelationship = .other initial.customRelationship = "동창" @@ -542,7 +498,7 @@ struct AddDoriFeatureTests { @Test("customRelationshipChanged - 10자 이내 저장") func customRelationshipChangedWithinLimit() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -555,7 +511,7 @@ struct AddDoriFeatureTests { @Test("customRelationshipChanged - 10자 초과 시 앞 10자만 저장") func customRelationshipChangedTruncatedAt10() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -569,7 +525,7 @@ struct AddDoriFeatureTests { @Test("eventTypeSelected - 경조사 변경") func eventTypeSelected() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -581,7 +537,7 @@ struct AddDoriFeatureTests { @Test("eventTypeSelected - other 아닌 값 선택 시 customEventType 초기화") func eventTypeSelectedClearsCustomWhenNotOther() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.selectedEventType = .other initial.customEventType = "회갑잔치" @@ -600,7 +556,7 @@ struct AddDoriFeatureTests { @Test("customEventTypeChanged - 10자 초과 시 앞 10자만 저장") func customEventTypeChangedTruncatedAt10() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -620,7 +576,7 @@ struct AddDoriFeatureTests { @Test("amountTextChanged - 빈 문자열 입력 시 빈 문자열 저장") func amountTextChangedWithEmptyString() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -633,7 +589,7 @@ struct AddDoriFeatureTests { @Test("amountTextChanged - 문자 포함 입력 시 숫자만 추출 후 포맷") func amountTextChangedFiltersNumbers() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -646,7 +602,7 @@ struct AddDoriFeatureTests { @Test("amountTextChanged - 100,000 포맷팅 검증") func amountTextChangedFormatsDecimal() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -659,7 +615,7 @@ struct AddDoriFeatureTests { @Test("amountTextChanged - 순수 숫자 입력") func amountTextChangedWithPureNumbers() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -672,7 +628,7 @@ struct AddDoriFeatureTests { @Test("amountTextChanged - 21억 초과 입력 시 Int32.max로 클리핑") func amountTextChangedClipsAtInt32Max() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -685,7 +641,7 @@ struct AddDoriFeatureTests { @Test("amountTextChanged - Int32.max 정확히 입력 시 그대로 저장") func amountTextChangedWithExactInt32Max() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -697,7 +653,7 @@ struct AddDoriFeatureTests { @Test("addAmountTapped - 기존 금액에 추가") func addAmountTappedAddsToExisting() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.amountText = "30,000" let store = TestStore( @@ -714,7 +670,7 @@ struct AddDoriFeatureTests { @Test("addAmountTapped - 빈 금액에서 추가") func addAmountTappedFromEmpty() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -726,7 +682,7 @@ struct AddDoriFeatureTests { @Test("addAmountTapped - 21억 초과 시 Int32.max로 클리핑") func addAmountTappedClipsAtInt32Max() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.amountText = Int(Int32.max - 100).decimalFormatted let store = TestStore( @@ -743,7 +699,7 @@ struct AddDoriFeatureTests { @Test("eventDateChanged - 날짜 변경") func eventDateChanged() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -757,7 +713,7 @@ struct AddDoriFeatureTests { @Test("isVisitedChanged - 방문 여부 변경") func isVisitedChanged() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -770,7 +726,7 @@ struct AddDoriFeatureTests { @Test("memoChanged - 40자 이내 저장") func memoChangedWithinLimit() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -783,7 +739,7 @@ struct AddDoriFeatureTests { @Test("memoChanged - 40자 초과 시 앞 40자만 저장") func memoChangedTruncatedAt40() async { let store = TestStore( - initialState: AddDoriFeature.State(mode: .create) + initialState: AddDoriFeature.State() ) { AddDoriFeature() } @@ -803,7 +759,7 @@ struct AddDoriFeatureTests { @Test("create 모드 - 제출 성공 시 doriCreated delegate 발생") func createSubmitSuccess() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.searchQuery = "김철수" initial.selectedRelationship = .friend initial.selectedEventType = .wedding @@ -838,7 +794,7 @@ struct AddDoriFeatureTests { @Test("create 모드 - 제출 실패 시 isSubmitting = false") func createSubmitFailure() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.searchQuery = "김철수" initial.amountText = "100,000" @@ -872,8 +828,8 @@ struct AddDoriFeatureTests { @Test("create 모드 - request에 올바른 값이 전달되는지 검증") func createSubmitRequestValues() async { - var initial = AddDoriFeature.State(mode: .create) - initial.transactionType = .given + var initial = AddDoriFeature.State() + initial.transactionType = .judori initial.searchQuery = "박수진" initial.selectedRelationship = .friend initial.selectedEventType = .wedding @@ -915,137 +871,9 @@ struct AddDoriFeatureTests { #expect(capturedRequest?.memo == "테스트 메모") } - @Test("edit 모드 - 제출 성공 시 doriUpdated delegate 발생") - func editSubmitSuccess() async { - let existingDori = DoriResponsesDTO.mock( - doriId: 42, - partnerName: "이영희", - relationship: "가족", - eventType: "생일", - amount: 50_000 - ) - - var initial = AddDoriFeature.State(mode: .edit(existingDori)) - initial.amountText = "80,000" - - let updatedResponse = DoriResponsesDTO.mock( - doriId: 42, - partnerName: "이영희", - relationship: "가족", - eventType: "생일", - amount: 80_000 - ) - - let store = TestStore( - initialState: initial - ) { - AddDoriFeature() - } withDependencies: { - $0.addDoriAPIClient.updateDori = { _, _ in updatedResponse } - } - - await store.send(.submitTapped) { - $0.isSubmitting = true - } - - await store.receive(.submitResponse(.success(updatedResponse))) { - $0.isSubmitting = false - } - - await store.receive(.delegate(.doriUpdated(updatedResponse))) - } - - @Test("edit 모드 - 제출 실패 시 isSubmitting = false") - func editSubmitFailure() async { - let existingDori = DoriResponsesDTO.mock() - - var initial = AddDoriFeature.State(mode: .edit(existingDori)) - initial.amountText = "80,000" - - struct TestError: Error { - let message = "네트워크 오류" - var localizedDescription: String { message } - } - - let store = TestStore( - initialState: initial - ) { - AddDoriFeature() - } withDependencies: { - $0.addDoriAPIClient.updateDori = { _, _ in throw TestError() } - } - - await store.send(.submitTapped) { - $0.isSubmitting = true - } - - await store.receive( - .submitResponse( - .failure( - AddDoriFeature.SubmitError(message: "네트워크 오류") - ) - ) - ) { - $0.isSubmitting = false - } - } - - @Test("edit 모드 - request에 올바른 값이 전달되는지 검증") - func editSubmitRequestValues() async { - let existingDori = DoriResponsesDTO.mock( - doriId: 42, - direction: "받도리", - partnerName: "이영희", - relationship: "가족", - eventType: "장례식", - amount: 50_000 - ) - - var initial = AddDoriFeature.State(mode: .edit(existingDori)) - initial.amountText = "80,000" - initial.selectedEventType = .funeral - - let updatedResponse = DoriResponsesDTO.mock( - doriId: 42, - direction: "받도리", - eventType: "장례식", - amount: 80_000 - ) - - nonisolated(unsafe) var capturedDoriId: Int64? - nonisolated(unsafe) var capturedRequest: DoriUpdateRequest? - - let store = TestStore( - initialState: initial - ) { - AddDoriFeature() - } withDependencies: { - $0.addDoriAPIClient.updateDori = { doriId, request in - capturedDoriId = doriId - capturedRequest = request - return updatedResponse - } - } - - await store.send(.submitTapped) { - $0.isSubmitting = true - } - - await store.receive(.submitResponse(.success(updatedResponse))) { - $0.isSubmitting = false - } - - await store.receive(.delegate(.doriUpdated(updatedResponse))) - - #expect(capturedDoriId == 42) - #expect(capturedRequest?.amount == 80_000) - #expect(capturedRequest?.eventType == "장례식") - #expect(capturedRequest?.direction == "받도리") - } - @Test("isSubmitting = true 상태에서 submitTapped - no-op") func submitTappedWhileSubmittingIsNoOp() async { - var initial = AddDoriFeature.State(mode: .create) + var initial = AddDoriFeature.State() initial.isSubmitting = true let store = TestStore( @@ -1065,7 +893,7 @@ struct AddDoriFeatureTests { @Test("partnerName - selectedPartner가 있으면 partnerName 반환") func partnerNameFromSelectedPartner() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.selectedPartner = .mock(partnerName: "박철수") state.searchQuery = "박" #expect(state.partnerName == "박철수") @@ -1073,7 +901,7 @@ struct AddDoriFeatureTests { @Test("partnerName - selectedPartner 없으면 searchQuery trim 반환") func partnerNameFromSearchQuery() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.selectedPartner = nil state.searchQuery = " 홍길동 " #expect(state.partnerName == "홍길동") @@ -1081,7 +909,7 @@ struct AddDoriFeatureTests { @Test("resolvedRelationship - other 선택 시 customRelationship 반환") func resolvedRelationshipWithOther() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.selectedRelationship = .other state.customRelationship = "지인" #expect(state.resolvedRelationship == "지인") @@ -1089,14 +917,14 @@ struct AddDoriFeatureTests { @Test("resolvedRelationship - known 선택 시 rawValue 반환") func resolvedRelationshipWithKnown() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.selectedRelationship = .friend #expect(state.resolvedRelationship == "친구") } @Test("resolvedEventType - other 선택 시 customEventType 반환") func resolvedEventTypeWithOther() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.selectedEventType = .other state.customEventType = "회갑잔치" #expect(state.resolvedEventType == "회갑잔치") @@ -1104,7 +932,7 @@ struct AddDoriFeatureTests { @Test("resolvedEventType - known 선택 시 rawValue 반환") func resolvedEventTypeWithKnown() { - var state = AddDoriFeature.State(mode: .create) + var state = AddDoriFeature.State() state.selectedEventType = .wedding #expect(state.resolvedEventType == "결혼식") } diff --git a/Projects/Feature/Calendar/Sources/CalendarFeature.swift b/Projects/Feature/Calendar/Sources/CalendarFeature.swift index fa7b6ff..723f0b0 100644 --- a/Projects/Feature/Calendar/Sources/CalendarFeature.swift +++ b/Projects/Feature/Calendar/Sources/CalendarFeature.swift @@ -34,7 +34,7 @@ public struct CalendarFeature { return .none case .fabTapped: - state.addDori = AddDoriFeature.State(mode: .create) + state.addDori = AddDoriFeature.State() return .none case .addDori(.presented(.delegate(.doriCreated))): diff --git a/Projects/Feature/History/Sources/Components/DateHeaderView.swift b/Projects/Feature/History/Sources/Components/DateHeaderView.swift new file mode 100644 index 0000000..726865f --- /dev/null +++ b/Projects/Feature/History/Sources/Components/DateHeaderView.swift @@ -0,0 +1,20 @@ +import SwiftUI +import DoriDesignSystem + +public struct DateHeaderView: View { + private let date: Date + + public init(date: Date) { + self.date = date + } + + public var body: some View { + Text(date.koreanDateWithWeekday) + .pretendard(.medium(.m12)) + .foregroundStyle(.grey600) + } +} + +#Preview { + DateHeaderView(date: Date()) +} diff --git a/Projects/Feature/History/Sources/Components/DoriBarGraphView.swift b/Projects/Feature/History/Sources/Components/DoriBarGraphView.swift new file mode 100644 index 0000000..8faf418 --- /dev/null +++ b/Projects/Feature/History/Sources/Components/DoriBarGraphView.swift @@ -0,0 +1,119 @@ +import SwiftUI +import DoriDesignSystem + +public struct DoriBarGraphView: View { + let givenAmount: Int + let receivedAmount: Int + + public init(givenAmount: Int, receivedAmount: Int) { + self.givenAmount = givenAmount + self.receivedAmount = receivedAmount + } + + private var totalAmount: Int { + givenAmount + receivedAmount + } + + private var givenRatio: CGFloat { + guard totalAmount > 0 else { return 0.5 } + let divided = CGFloat(givenAmount) / CGFloat(totalAmount) + // 이쁜 값 - 0.22 + guard divided > 0.1 else { return 0.22 } + return divided + } + + public var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + let dividerPosition = width * givenRatio + + ZStack(alignment: .leading) { + // 배경 + RoundedRectangle(cornerRadius: 20) + .fill(UIAsset.Colors.grey200.color) + + // 주도리 바 (왼쪽) + if givenAmount > 0 { + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: 20) + .fill(UIAsset.Colors.secondary.color) + .frame(width: dividerPosition) + Spacer(minLength: 0) + } + } + + // 받도리 바 (오른쪽) + if receivedAmount > 0 { + HStack(spacing: 0) { + Spacer(minLength: 0) + RoundedRectangle(cornerRadius: 20) + .fill(UIAsset.Colors.grey200.color) + .frame(width: width - dividerPosition) + } + } + + // 아이콘들 + HStack { + // 왼쪽 사람 아이콘 (주도리) + if givenAmount > 0 { + HStack(spacing: 3) { + UIAsset.Icons.iconJudori.image + .resizable() + .frame(width: 26, height: 26) + .padding(.leading, 4) + Text("주도리") + .pretendard(.body(.sb6)) + } + .foregroundStyle(.doriWhite) + } + + Spacer() + + // 오른쪽 사람 아이콘 (받도리) + HStack(spacing: 3) { + Text("받도리") + .pretendard(.body(.sb6)) + UIAsset.Icons.iconBaddori.image + .resizable() + .frame(width: 26, height: 26) + .padding(.trailing, 4) + } + .foregroundStyle(.grey500) + + } + } + } + .frame(height: 34) + } +} + +#Preview { + VStack(spacing: 20) { + VStack(alignment: .leading) { + Text("주도리 < 받도리") + DoriBarGraphView(givenAmount: 1, receivedAmount: 100_000) + } + + VStack(alignment: .leading) { + Text("주도리 > 받도리") + DoriBarGraphView(givenAmount: 300_000, receivedAmount: 100_000) + } + + VStack(alignment: .leading) { + Text("같은 금액") + DoriBarGraphView(givenAmount: 50000, receivedAmount: 50000) + } + + VStack(alignment: .leading) { + Text("받도리만") + DoriBarGraphView(givenAmount: 0, receivedAmount: 100000) + } + + VStack(alignment: .leading) { + Text("주도리만") + DoriBarGraphView(givenAmount: 100000, receivedAmount: 0) + } + + } + .padding() +} diff --git a/Projects/Feature/History/Sources/Components/PersonCardView.swift b/Projects/Feature/History/Sources/Components/PersonCardView.swift new file mode 100644 index 0000000..5ea8f02 --- /dev/null +++ b/Projects/Feature/History/Sources/Components/PersonCardView.swift @@ -0,0 +1,163 @@ +// +// PersonCardView.swift +// Dori-iOS +// +// Created by 강동영 on 2/21/26. +// + +import SwiftUI +import DoriDesignSystem +import DoriCore + +public struct PersonCardView: View { + private let partner: PartnerSummary + @State private var isExpanded: Bool = false + private let onViewAllTapped: (() -> Void)? + + public init( + partner: PartnerSummary, + onViewAllTapped: (() -> Void)? = nil + ) { + self.partner = partner + self.onViewAllTapped = onViewAllTapped + } + + public var body: some View { + VStack { + VStack(alignment: .leading, spacing: 12) { + // 헤더: 이름, 관계, 화살표 + HStack { + Text(partner.partnerName) + .pretendard(.body(.b4)) + .foregroundStyle(.doriBlack) + + Text(partner.relationship) + .pretendard(.caption(.m2)) + .foregroundStyle(.grey600) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 5) + .foregroundStyle(.grey100) + ) + + Spacer() + + OutspreadChevron(isExpanded: $isExpanded) + } + .padding(.horizontal, 8) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation { + isExpanded.toggle() + } + } + + // 막대 그래프 + DoriBarGraphView( + givenAmount: Int(partner.outDoriTotalAmount), + receivedAmount: Int(partner.inDoriTotalAmount) + ) + + // 금액 표시 + HStack { + AmountLabel(Int(partner.outDoriTotalAmount)) + .pretendard(.caption(.b1)) + .foregroundStyle(.secondary) + + Spacer() + + AmountLabel(Int(partner.inDoriTotalAmount)) + .pretendard(.caption(.b1)) + .foregroundStyle(.grey500) + } + .padding(.horizontal, 8) + } + .padding(.horizontal, 10) + .padding(.top, 14) + .padding(.bottom, 24) + .background(.doriWhite) + + Divider() + + if isExpanded { + VStack { + VStack(spacing: 0) { + ForEach(partner.recentDoriList, id: \.doriId) { dori in + TransactionRowView(dori: dori, isChevronHidden: true) + } + + if partner.recentDoriList.isEmpty { + Text("최근 도리 내역이 없습니다") + .pretendard(.regular(.r12)) + .foregroundStyle(.grey600) + .padding(.vertical, 12) + } + } + .padding(.vertical, 12) + + PrimaryButton(title: "전체 보기") { + onViewAllTapped?() + } + .backgroundColor(.doriWhite) + .foregroundColor(.main) + .strokeColor(.main) + .padding(.bottom, 16) + } + .padding(.horizontal, 16) + .transition(.move(edge: .top).combined(with: .opacity).combined(with: .blurReplace)) + } + } + .background(.doriWhite) + .cornerRadius(10) + } +} + +// MARK: - Private + +fileprivate struct OutspreadChevron: View { + @Binding var isExpanded: Bool + + var body: some View { + Image(systemName: "chevron.down") + .font(.system(size: 18)) + .foregroundStyle(.doriBlack) + .rotationEffect(.degrees(isExpanded ? 180 : 0)) + .animation(.spring(response: 0.5, dampingFraction: 0.6), value: isExpanded) + } +} + +// MARK: - Preview + +#Preview { + let samplePartner = PartnerSummary( + partnerId: 1, + partnerName: "홍길동", + relationship: "친구", + recentDoriList: [ + Dori( + doriId: 1, + userId: 1, + partnerId: 1, + direction: .judori, + partnerName: "홍길동", + relationship: "친구", + eventType: "설날", + amount: 100_000, + eventDate: "2026-01-01", + isVisited: true, + memo: "", + createdAt: "2026-01-01" + ) + ], + inDoriTotalAmount: 50_000, + outDoriTotalAmount: 100_000 + ) + + VStack(spacing: 16) { + PersonCardView(partner: samplePartner) + PersonCardView(partner: samplePartner) + } + .padding() + .background(.grey100) +} diff --git a/Projects/Feature/History/Sources/Components/TransactionRowView.swift b/Projects/Feature/History/Sources/Components/TransactionRowView.swift new file mode 100644 index 0000000..12ba069 --- /dev/null +++ b/Projects/Feature/History/Sources/Components/TransactionRowView.swift @@ -0,0 +1,128 @@ +// +// TransactionRowView.swift +// Dori-iOS +// +// Created by 강동영 on 2/21/26. +// + +import SwiftUI +import DoriDesignSystem +import DoriCore + +public struct TransactionRowView: View { + private let dori: Dori + private let isChevronHidden: Bool + + public init( + dori: Dori, + isChevronHidden: Bool = false + ) { + self.dori = dori + self.isChevronHidden = isChevronHidden + } + + private var isJudori: Bool { + dori.direction == .judori + } + + private var eventIcon: UIAsset.Icons { + let eventType = EventType(rawValue: dori.eventType) + if isJudori { + switch eventType { + case .wedding: return .judoriWedding + case .funeral: return .judoriFuneral + case .firstBirthday: return .judoriFirstBirthday + case .housewarming: return .judoriHousewarming + case .birthday: return .judoriBirthday + default: return .judoriEtc + } + } else { + switch eventType { + case .wedding: return .baddoriWedding + case .funeral: return .baddoriFuneral + case .firstBirthday: return .baddoriFirstBirthday + case .housewarming: return .baddoriHousewarming + case .birthday: return .baddoriBirthday + default: return .baddoriEtc + } + } + } + + public var body: some View { + HStack { + eventIcon.image + .resizable() + .frame(width: 34, height: 34) + .padding(4) + + VStack(alignment: .leading, spacing: 4) { + Text(dori.eventType) + .pretendard(.semiBold(.sb14)) + .foregroundStyle(.doriBlack) + + if !(dori.memo ?? "").isEmpty { + Text(dori.memo ?? "") + .pretendard(.regular(.r12)) + .foregroundStyle(.grey600) + } + } + + Spacer() + + HStack(spacing: 8) { + AmountLabel(Int(dori.amount)) + .pretendard(.body(.sb3)) + .foregroundStyle(.doriBlack) + + if !isChevronHidden { + Image(systemName: "chevron.right") + .font(.system(size: 12)) + .foregroundStyle(.grey400) + } + } + } + .padding(.vertical, 8) + } + +} + +// MARK: - Preview + +#Preview { + VStack { + TransactionRowView( + dori: Dori( + doriId: 1, + userId: 1, + partnerId: 1, + direction: .judori, + partnerName: "홍길동", + relationship: "친구", + eventType: "설날 세뱃돈", + amount: 100_000, + eventDate: "2026-01-01", + isVisited: true, + memo: "조카에게", + createdAt: "2026-01-01" + ) + ) + + TransactionRowView( + dori: Dori( + doriId: 2, + userId: 1, + partnerId: 1, + direction: .baddori, + partnerName: "홍길동", + relationship: "친구", + eventType: "생일 선물", + amount: 50_000, + eventDate: "2026-02-01", + isVisited: true, + memo: "", + createdAt: "2026-02-01" + ) + ) + } + .padding() +} diff --git a/Projects/Feature/History/Sources/DoriList/DoriListFeature.swift b/Projects/Feature/History/Sources/DoriList/DoriListFeature.swift new file mode 100644 index 0000000..629406f --- /dev/null +++ b/Projects/Feature/History/Sources/DoriList/DoriListFeature.swift @@ -0,0 +1,126 @@ +// +// DoriListFeature.swift +// Dori-iOS +// +// Created by 강동영 on 2/21/26. +// + +import ComposableArchitecture +import DoriCore + +@Reducer +public struct DoriListFeature { + public init() {} + + private static let pageSize = 20 + + // MARK: - State + + @ObservableState + public struct State: Equatable, Sendable { + public var partners: [PartnerSummary] = [] + public var searchText: String = "" + public var isLoading: Bool = false + public var currentPage: Int = 0 + public var hasMorePages: Bool = true + + public var filteredPartners: [PartnerSummary] { + if searchText.trimmingCharacters(in: .whitespaces).isEmpty { + return partners + } + return partners.filter { + $0.partnerName.localizedCaseInsensitiveContains(searchText) || + $0.relationship.localizedCaseInsensitiveContains(searchText) + } + } + + public init() {} + } + + // MARK: - Action + + public enum Action: Equatable, Sendable { + case onAppear + case fetchPartnersResponse(Result<[PartnerSummary], APIError>) + case searchTextChanged(String) + case refresh + case fabTapped + case partnerTapped(PartnerSummary) + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case partnerTapped(PartnerSummary) + case fabTapped + } + } + + public struct APIError: Error, Equatable, Sendable { + public let message: String + public init(message: String) { self.message = message } + } + + // MARK: - Dependencies + + @Dependency(\.historyAPIClient) var apiClient + + // MARK: - Reducer + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onAppear: + guard state.partners.isEmpty else { return .none } + return fetchPartners(state: &state) + + case let .fetchPartnersResponse(.success(partners)): + state.isLoading = false + if state.currentPage == 0 { + state.partners = partners + } else { + state.partners.append(contentsOf: partners) + } + state.hasMorePages = partners.count >= Self.pageSize + return .none + + case .fetchPartnersResponse(.failure): + state.isLoading = false + return .none + + case let .searchTextChanged(query): + state.searchText = query + return .none + + case .refresh: + state.currentPage = 0 + state.hasMorePages = true + return fetchPartners(state: &state) + + case let .partnerTapped(partner): + return .send(.delegate(.partnerTapped(partner))) + + case .fabTapped: + return .send(.delegate(.fabTapped)) + + case .delegate: + return .none + } + } + } + + // MARK: - Private + + private func fetchPartners(state: inout State) -> Effect { + state.isLoading = true + let page = state.currentPage + let size = Self.pageSize + let fetch = apiClient.fetchPartners + return .run { send in + do { + let result = try await fetch(page, size) + await send(.fetchPartnersResponse(.success(result))) + } catch { + await send(.fetchPartnersResponse(.failure(APIError(message: error.localizedDescription)))) + } + } + } +} diff --git a/Projects/Feature/History/Sources/DoriList/DoriListView.swift b/Projects/Feature/History/Sources/DoriList/DoriListView.swift new file mode 100644 index 0000000..841154c --- /dev/null +++ b/Projects/Feature/History/Sources/DoriList/DoriListView.swift @@ -0,0 +1,213 @@ +// +// DoriListView.swift +// Dori-iOS +// +// Created by 강동영 on 2/21/26. +// + +import SwiftUI +import ComposableArchitecture +import DoriDesignSystem + +import DoriCore + +public struct DoriListView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(store.filteredPartners, id: \.partnerId) { partner in + PersonCardView(partner: partner) { + store.send(.partnerTapped(partner)) + } + } + } + .padding() + } + .overlay { + if store.filteredPartners.isEmpty && !store.isLoading { + DoriEmptyView(.partnerList) + .background(.grey100) + } + } + .overlay(alignment: .bottomTrailing) { + FloatingActionButton { + store.send(.fabTapped) + } + .padding(20) + } + .background(.grey100) + .navigationTitle("내역") + .navigationBarTitleDisplayMode(.inline) + .searchable( + text: Binding( + get: { store.searchText }, + set: { store.send(.searchTextChanged($0)) } + ), + prompt: "이름 또는 관계 검색" + ) + .refreshable { + store.send(.refresh) + } + .onAppear { + store.send(.onAppear) + } + } +} + +// MARK: - Preview + +#Preview { + let previewPartners: [PartnerSummary] = [ + PartnerSummary( + partnerId: 1, + partnerName: "길태환", + relationship: "친구", + recentDoriList: [ + Dori( + doriId: 1, + userId: 1, + partnerId: 1, + direction: .judori, + partnerName: "길태환", + relationship: Relationship.friend.rawValue, + eventType: EventType.birthday.rawValue, + amount: 1, + eventDate: "2025-05-01", + isVisited: true, + memo: "퇴근하고 바로 찾아가서 축하함", + createdAt: "2026-02-17T09:00:00" + ), + Dori( + doriId: 2, + userId: 1, + partnerId: 1, + direction: .baddori, + partnerName: "길태환", + relationship: Relationship.friend.rawValue, + eventType: EventType.wedding.rawValue, + amount: 100_000, + eventDate: "2025-03-15", + isVisited: true, + memo: "퇴근하고 바로 찾아가서 축하함", + createdAt: "2026-02-17T09:00:00" + ), + ], + inDoriTotalAmount: 100_000, + outDoriTotalAmount: 1 + ), + PartnerSummary( + partnerId: 2, + partnerName: "박수진", + relationship: "직장짱친동료", + recentDoriList: [ + Dori( + doriId: 3, + userId: 1, + partnerId: 2, + direction: .judori, + partnerName: "김철수", + relationship: Relationship.company.rawValue, + eventType: EventType.wedding.rawValue, + amount: 100_000, + eventDate: "2025-08-20", + isVisited: false, + memo: "축의금", + createdAt: "2026-02-17T09:00:00" + ) + ], + inDoriTotalAmount: 100_000, + outDoriTotalAmount: 300_000 + ), + PartnerSummary( + partnerId: 3, + partnerName: "박수진", + relationship: "가족", + recentDoriList: [ + Dori( + doriId: 4, + userId: 1, + partnerId: 3, + direction: .baddori, + partnerName: "이영희", + relationship: Relationship.family.rawValue, + eventType: EventType.birthday.rawValue, + amount: 300_000, + eventDate: "2025-01-10", + isVisited: true, + memo: "생일 선물", + createdAt: "2026-02-17T09:00:00" + ) + ], + inDoriTotalAmount: 300_000, + outDoriTotalAmount: 100_000 + ), + PartnerSummary( + partnerId: 4, + partnerName: "강성범", + relationship: "지인", + recentDoriList: [ + Dori( + doriId: 5, + userId: 1, + partnerId: 4, + direction: .baddori, + partnerName: "박민준", + relationship: Relationship.friend.rawValue, + eventType: EventType.firstBirthday.rawValue, + amount: 50_000, + eventDate: "2025-11-05", + isVisited: true, + memo: "", + createdAt: "2026-02-17T09:00:00" + ) + ], + inDoriTotalAmount: 100_000, + outDoriTotalAmount: 0 + ), + PartnerSummary( + partnerId: 5, + partnerName: "최수진", + relationship: "친구", + recentDoriList: [ + Dori( + doriId: 6, + userId: 1, + partnerId: 5, + direction: .judori, + partnerName: "최수진", + relationship: Relationship.friend.rawValue, + eventType: EventType.birthday.rawValue, + amount: 80_000, + eventDate: "2025-07-22", + isVisited: true, + memo: "생일 케이크", + createdAt: "2026-02-17T09:00:00" + ) + ], + inDoriTotalAmount: 300_000, + outDoriTotalAmount: 100_000 + ), + ] + + NavigationStack { + DoriListView( + store: Store( + initialState: { + var state = DoriListFeature.State() + state.partners = previewPartners + return state + }() + ) { + DoriListFeature() + } withDependencies: { + $0.historyAPIClient = .previewValue + } + ) + } +} diff --git a/Projects/Feature/History/Sources/HistoryAPIClient.swift b/Projects/Feature/History/Sources/HistoryAPIClient.swift new file mode 100644 index 0000000..75e1c6d --- /dev/null +++ b/Projects/Feature/History/Sources/HistoryAPIClient.swift @@ -0,0 +1,672 @@ +// +// HistoryAPIClient.swift +// Dori-iOS +// +// Created by 강동영 on 2/21/26. +// + +import Foundation +import ComposableArchitecture +import DoriCore +import DoriNetwork + +public struct HistoryAPIClient: Sendable { + public var fetchPartners: @Sendable (_ page: Int, _ size: Int) async throws -> [PartnerSummary] + public var searchPartners: @Sendable (_ query: String) async throws -> [Dori] + public var fetchPartnerDoriList: @Sendable (_ partnerId: Int64) async throws -> PartnerDoriList + public var fetchDoriDetail: @Sendable (_ doriId: Int64) async throws -> Dori + public var updateDori: @Sendable (_ doriId: Int64, _ input: DoriUpdateInput) async throws -> Dori + public var deleteDori: @Sendable (_ doriId: Int64) async throws -> Void + public var bulkDeleteDori: @Sendable (_ doriIds: [Int64]) async throws -> [Int64] + + public init( + fetchPartners: @escaping @Sendable (_ page: Int, _ size: Int) async throws -> [PartnerSummary], + searchPartners: @escaping @Sendable (_ query: String) async throws -> [Dori], + fetchPartnerDoriList: @escaping @Sendable (_ partnerId: Int64) async throws -> PartnerDoriList, + fetchDoriDetail: @escaping @Sendable (_ doriId: Int64) async throws -> Dori, + updateDori: @escaping @Sendable (_ doriId: Int64, _ input: DoriUpdateInput) async throws -> Dori, + deleteDori: @escaping @Sendable (_ doriId: Int64) async throws -> Void, + bulkDeleteDori: @escaping @Sendable (_ doriIds: [Int64]) async throws -> [Int64] + ) { + self.fetchPartners = fetchPartners + self.searchPartners = searchPartners + self.fetchPartnerDoriList = fetchPartnerDoriList + self.fetchDoriDetail = fetchDoriDetail + self.updateDori = updateDori + self.deleteDori = deleteDori + self.bulkDeleteDori = bulkDeleteDori + } +} + +// MARK: - Error + +private enum HistoryAPIClientError: LocalizedError { + case unconfigured + case invalidResponse + case backendError(String) + + var errorDescription: String? { + switch self { + case .unconfigured: + return "HistoryAPIClient가 구성되지 않았습니다." + case .invalidResponse: + return "서버 응답이 올바르지 않습니다." + case .backendError(let message): + return message + } + } +} + +// MARK: - DependencyKey + +extension HistoryAPIClient: DependencyKey { + public static let liveValue = Self( + fetchPartners: { _, _ in throw HistoryAPIClientError.unconfigured }, + searchPartners: { _ in throw HistoryAPIClientError.unconfigured }, + fetchPartnerDoriList: { _ in throw HistoryAPIClientError.unconfigured }, + fetchDoriDetail: { _ in throw HistoryAPIClientError.unconfigured }, + updateDori: { _, _ in throw HistoryAPIClientError.unconfigured }, + deleteDori: { _ in throw HistoryAPIClientError.unconfigured }, + bulkDeleteDori: { _ in throw HistoryAPIClientError.unconfigured } + ) + + public static let previewValue = Self( + fetchPartners: { _, _ in + [ + PartnerSummary( + partnerId: 1, + partnerName: "홍길동", + relationship: "친구", + recentDoriList: [ + Dori( + doriId: 1, + userId: 1, + partnerId: 1, + direction: .judori, + partnerName: "홍길동", + relationship: "친구", + eventType: "생일", + amount: 50_000, + eventDate: "2025-05-01", + isVisited: true, + memo: "생일 축하", + createdAt: "2026-02-17T09:00:00" + ), + Dori( + doriId: 2, + userId: 1, + partnerId: 1, + direction: .baddori, + partnerName: "홍길동", + relationship: "친구", + eventType: "결혼", + amount: 100_000, + eventDate: "2025-03-15", + isVisited: true, + memo: "", + createdAt: "2026-02-17T09:00:00" + ) + ], + inDoriTotalAmount: 100_000, + outDoriTotalAmount: 50_000 + ), + PartnerSummary( + partnerId: 2, + partnerName: "김철수", + relationship: "직장동료", + recentDoriList: [ + Dori( + doriId: 3, + userId: 1, + partnerId: 2, + direction: .judori, + partnerName: "김철수", + relationship: "직장동료", + eventType: "결혼", + amount: 100_000, + eventDate: "2025-08-20", + isVisited: false, + memo: "축의금", + createdAt: "2026-02-17T09:00:00" + ) + ], + inDoriTotalAmount: 0, + outDoriTotalAmount: 200_000 + ), + PartnerSummary( + partnerId: 3, + partnerName: "이영희", + relationship: "가족", + recentDoriList: [ + Dori( + doriId: 4, + userId: 1, + partnerId: 3, + direction: .baddori, + partnerName: "이영희", + relationship: "가족", + eventType: "생일", + amount: 300_000, + eventDate: "2025-01-10", + isVisited: true, + memo: "생일 선물", + createdAt: "2026-02-17T09:00:00" + ) + ], + inDoriTotalAmount: 300_000, + outDoriTotalAmount: 150_000 + ), + PartnerSummary( + partnerId: 4, + partnerName: "박민준", + relationship: "지인", + recentDoriList: [ + Dori( + doriId: 5, + userId: 1, + partnerId: 4, + direction: .baddori, + partnerName: "박민준", + relationship: "지인", + eventType: "돌잔치", + amount: 50_000, + eventDate: "2025-11-05", + isVisited: true, + memo: "", + createdAt: "2026-02-17T09:00:00" + ) + ], + inDoriTotalAmount: 50_000, + outDoriTotalAmount: 0 + ), + PartnerSummary( + partnerId: 5, + partnerName: "최수진", + relationship: "친구", + recentDoriList: [ + Dori( + doriId: 6, + userId: 1, + partnerId: 5, + direction: .judori, + partnerName: "최수진", + relationship: "친구", + eventType: "생일", + amount: 80_000, + eventDate: "2025-07-22", + isVisited: true, + memo: "생일 케이크", + createdAt: "2026-02-17T09:00:00" + ) + ], + inDoriTotalAmount: 80_000, + outDoriTotalAmount: 80_000 + ) + ] + }, + searchPartners: { query in + [ + Dori( + doriId: 1, + userId: 1, + partnerId: 1, + direction: .judori, + partnerName: "홍길동", + relationship: "친구", + eventType: "생일", + amount: 50_000, + eventDate: "2024-05-01", + isVisited: true, + memo: "", + createdAt: "2026-02-17T09:00:00" + ) + ].filter { $0.partnerName.contains(query) } + }, + fetchPartnerDoriList: { partnerId in + PartnerDoriList( + userId: 1, + partnerId: partnerId, + partnerName: "홍길동", + relationship: "친구", + inDoriTotalAmount: 550_000, + inDoriList: [ + Dori( + doriId: 1, + userId: 1, + partnerId: partnerId, + direction: .baddori, + partnerName: "홍길동", + relationship: "친구", + eventType: "결혼식", + amount: 200_000, + eventDate: "2025-08-15", + isVisited: true, + memo: "축하해요", + createdAt: "2026-02-17T09:00:00" + ), + Dori( + doriId: 2, + userId: 1, + partnerId: partnerId, + direction: .baddori, + partnerName: "홍길동", + relationship: "친구", + eventType: "생일", + amount: 100_000, + eventDate: "2025-05-01", + isVisited: true, + memo: "생일 축하", + createdAt: "2026-02-17T09:00:00" + ), + Dori( + doriId: 3, + userId: 1, + partnerId: partnerId, + direction: .baddori, + partnerName: "홍길동", + relationship: "친구", + eventType: "돌잔치", + amount: 150_000, + eventDate: "2025-03-10", + isVisited: false, + memo: "", + createdAt: "2026-02-17T09:00:00" + ), + Dori( + doriId: 4, + userId: 1, + partnerId: partnerId, + direction: .baddori, + partnerName: "홍길동", + relationship: "친구", + eventType: "집들이", + amount: 50_000, + eventDate: "2025-01-20", + isVisited: true, + memo: "새집 축하", + createdAt: "2026-02-17T09:00:00" + ), + Dori( + doriId: 5, + userId: 1, + partnerId: partnerId, + direction: .baddori, + partnerName: "홍길동", + relationship: "친구", + eventType: "장례식", + amount: 50_000, + eventDate: "2024-11-05", + isVisited: true, + memo: "", + createdAt: "2026-02-17T09:00:00" + ) + ], + outDoriTotalAmount: 480_000, + outDoriList: [ + Dori( + doriId: 6, + userId: 1, + partnerId: partnerId, + direction: .judori, + partnerName: "홍길동", + relationship: "친구", + eventType: "결혼식", + amount: 150_000, + eventDate: "2025-06-20", + isVisited: true, + memo: "축의금", + createdAt: "2026-02-17T09:00:00" + ), + Dori( + doriId: 7, + userId: 1, + partnerId: partnerId, + direction: .judori, + partnerName: "홍길동", + relationship: "친구", + eventType: "생일", + amount: 80_000, + eventDate: "2025-05-01", + isVisited: true, + memo: "생일 케이크", + createdAt: "2026-02-17T09:00:00" + ), + Dori( + doriId: 8, + userId: 1, + partnerId: partnerId, + direction: .judori, + partnerName: "홍길동", + relationship: "친구", + eventType: "장례식", + amount: 100_000, + eventDate: "2025-04-15", + isVisited: false, + memo: "조의금", + createdAt: "2026-02-17T09:00:00" + ), + Dori( + doriId: 9, + userId: 1, + partnerId: partnerId, + direction: .judori, + partnerName: "홍길동", + relationship: "친구", + eventType: "기타", + amount: 50_000, + eventDate: "2025-02-14", + isVisited: true, + memo: "발렌타인", + createdAt: "2026-02-17T09:00:00" + ), + Dori( + doriId: 10, + userId: 1, + partnerId: partnerId, + direction: .judori, + partnerName: "홍길동", + relationship: "친구", + eventType: "집들이", + amount: 100_000, + eventDate: "2024-12-25", + isVisited: true, + memo: "", + createdAt: "2026-02-17T09:00:00" + ) + ] + ) + }, + fetchDoriDetail: { doriId in + let previews: [Int64: Dori] = [ + 1: Dori( + doriId: 1, + userId: 1, + partnerId: 1, + direction: .baddori, + partnerName: "홍길동", + relationship: "친구", + eventType: "결혼식", + amount: 200_000, + eventDate: "2025-08-15", + isVisited: true, + memo: "축하해요", + createdAt: "2026-02-17T09:00:00" + ), + 6: Dori( + doriId: 6, + userId: 1, + partnerId: 1, + direction: .judori, + partnerName: "홍길동", + relationship: "친구", + eventType: "결혼식", + amount: 150_000, + eventDate: "2025-06-20", + isVisited: true, + memo: "축의금", + createdAt: "2026-02-17T09:00:00" + ) + ] + return previews[doriId] ?? Dori( + doriId: doriId, + userId: 1, + partnerId: 1, + direction: .baddori, + partnerName: "홍길동", + relationship: "친구", + eventType: "생일", + amount: 50_000, + eventDate: "2024-05-01", + isVisited: true, + memo: "메모", + createdAt: "2026-02-17T09:00:00" + ) + }, + updateDori: { doriId, input in + Dori( + doriId: doriId, + userId: 1, + partnerId: 1, + direction: input.direction ?? .judori, + partnerName: "홍길동", + relationship: "친구", + eventType: input.eventType ?? "결혼식", + amount: input.amount ?? 50_000, + eventDate: input.eventDate ?? "2026-02-15", + isVisited: input.isVisited ?? true, + memo: input.memo ?? "", + createdAt: "2026-02-15" + ) + }, + deleteDori: { _ in }, + bulkDeleteDori: { doriIds in doriIds } + ) + + public static let testValue = Self( + fetchPartners: { _, _ in [] }, + searchPartners: { _ in [] }, + fetchPartnerDoriList: { partnerId in + PartnerDoriList( + userId: 1, + partnerId: partnerId, + partnerName: "테스트", + relationship: "친구", + inDoriTotalAmount: 0, + inDoriList: [], + outDoriTotalAmount: 0, + outDoriList: [] + ) + }, + fetchDoriDetail: { doriId in + Dori( + doriId: doriId, + userId: 1, + partnerId: 1, + direction: .baddori, + partnerName: "테스트", + relationship: "친구", + eventType: "생일", + amount: 0, + eventDate: "2024-01-01", + isVisited: true, + memo: "", + createdAt: "2024-01-01" + ) + }, + updateDori: { _, _ in + Dori( + doriId: 1, + userId: 1, + partnerId: 1, + direction: .judori, + partnerName: "테스트", + relationship: "친구", + eventType: "결혼식", + amount: 50_000, + eventDate: "2026-02-15", + isVisited: true, + memo: "", + createdAt: "2026-02-15" + ) + }, + deleteDori: { _ in }, + bulkDeleteDori: { _ in [] } + ) +} + +// MARK: - DependencyValues + +public extension DependencyValues { + var historyAPIClient: HistoryAPIClient { + get { self[HistoryAPIClient.self] } + set { self[HistoryAPIClient.self] = newValue } + } +} + +// MARK: - Live Factory + +public extension HistoryAPIClient { + static func live(networkService: any NetworkService) -> Self { + Self( + fetchPartners: { page, size in + let endpoint = FetchPartnersEndpoint(page: page, size: size) + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse<[PartnerSummaryResponse]>.self + ) + if let apiError = response.error { + throw HistoryAPIClientError.backendError(apiError.message ?? apiError.code) + } + guard response.success, let data = response.data else { + throw HistoryAPIClientError.invalidResponse + } + return data.map { $0.toDomain() } + }, + searchPartners: { query in + let endpoint = SearchPartnersEndpoint(query: query) + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse<[DoriResponsesDTO]>.self + ) + if let apiError = response.error { + throw HistoryAPIClientError.backendError(apiError.message ?? apiError.code) + } + guard response.success, let data = response.data else { + throw HistoryAPIClientError.invalidResponse + } + return data.map { $0.toDomain() } + }, + fetchPartnerDoriList: { partnerId in + let endpoint = FetchPartnerDoriListEndpoint(partnerId: partnerId) + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + if let apiError = response.error { + throw HistoryAPIClientError.backendError(apiError.message ?? apiError.code) + } + guard response.success, let data = response.data else { + throw HistoryAPIClientError.invalidResponse + } + return data.toDomain() + }, + fetchDoriDetail: { doriId in + let endpoint = FetchDoriDetailEndpoint(doriId: doriId) + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + if let apiError = response.error { + throw HistoryAPIClientError.backendError(apiError.message ?? apiError.code) + } + guard response.success, let data = response.data else { + throw HistoryAPIClientError.invalidResponse + } + return data.toDomain() + }, + updateDori: { doriId, input in + let endpoint = UpdateDoriEndpoint(doriId: doriId, request: input.toRequest()) + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + if let apiError = response.error { + throw HistoryAPIClientError.backendError(apiError.message ?? apiError.code) + } + guard response.success, let data = response.data else { + throw HistoryAPIClientError.invalidResponse + } + return data.toDomain() + }, + deleteDori: { doriId in + let endpoint = DeleteDoriEndpoint(doriId: doriId) + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + if let apiError = response.error { + throw HistoryAPIClientError.backendError(apiError.message ?? apiError.code) + } + guard response.success else { + throw HistoryAPIClientError.invalidResponse + } + }, + bulkDeleteDori: { doriIds in + let endpoint = BulkDeleteDoriEndpoint(doriIds: doriIds) + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse<[Int64]>.self + ) + if let apiError = response.error { + throw HistoryAPIClientError.backendError(apiError.message ?? apiError.code) + } + guard response.success, let data = response.data else { + throw HistoryAPIClientError.invalidResponse + } + return data + } + ) + } +} + +// MARK: - DTO → Domain 매핑 + +private extension DoriResponsesDTO { + func toDomain() -> Dori { + Dori( + doriId: doriId, + userId: userId, + partnerId: partnerId, + direction: direction == "OUT" ? .judori : .baddori, + partnerName: partnerName, + relationship: relationship, + eventType: eventType, + amount: amount, + eventDate: eventDate, + isVisited: isVisited, + memo: memo ?? "", + createdAt: createdAt + ) + } +} + +private extension PartnerSummaryResponse { + func toDomain() -> PartnerSummary { + PartnerSummary( + partnerId: partnerId, + partnerName: partnerName, + relationship: relationship, + recentDoriList: recentDoriList.map { $0.toDomain() }, + inDoriTotalAmount: inDoriTotalAmount, + outDoriTotalAmount: outDoriTotalAmount + ) + } +} + +private extension PartnerDoriListResponse { + func toDomain() -> PartnerDoriList { + PartnerDoriList( + userId: userId, + partnerId: partnerId, + partnerName: partnerName, + relationship: relationship, + inDoriTotalAmount: inDoriTotalAmount, + inDoriList: inDoriList.map { $0.toDomain() }, + outDoriTotalAmount: outDoriTotalAmount, + outDoriList: outDoriList.map { $0.toDomain() } + ) + } +} + +private extension DoriUpdateInput { + func toRequest() -> DoriUpdateRequest { + DoriUpdateRequest( + direction: direction?.rawValue, + eventType: eventType, + amount: amount, + eventDate: eventDate, + isVisited: isVisited, + memo: memo + ) + } +} diff --git a/Projects/Feature/History/Sources/HistoryFeature.swift b/Projects/Feature/History/Sources/HistoryFeature.swift index a0539ec..45a343b 100644 --- a/Projects/Feature/History/Sources/HistoryFeature.swift +++ b/Projects/Feature/History/Sources/HistoryFeature.swift @@ -8,53 +8,113 @@ import ComposableArchitecture import SwiftUI import DoriDesignSystem +import DoriNetwork import FeatureAddDori +// MARK: - HistoryFeature + @Reducer public struct HistoryFeature { public init() {} + // MARK: - State + @ObservableState - public struct State: Equatable, Sendable { + public struct State { + public var doriList: DoriListFeature.State = .init() + @Presents public var partnerHistory: PartnerDoriHistoryFeature.State? @Presents public var addDori: AddDoriFeature.State? - + @Presents public var editDori: EditDoriFeature.State? public init() {} } - public enum Action: Equatable, Sendable { - case onAppear - case fabTapped + // MARK: - Action + + public enum Action { + case doriList(DoriListFeature.Action) + case partnerHistory(PresentationAction) case addDori(PresentationAction) + case editDori(PresentationAction) } + // MARK: - Reducer + public var body: some ReducerOf { + Scope(state: \.doriList, action: \.doriList) { + DoriListFeature() + } + Reduce { state, action in switch action { - case .onAppear: + + // MARK: DoriList delegate + + case .doriList(.delegate(.partnerTapped(let partner))): + state.partnerHistory = PartnerDoriHistoryFeature.State( + partnerId: partner.partnerId, + partnerName: partner.partnerName, + relationship: partner.relationship + ) return .none - case .fabTapped: - state.addDori = AddDoriFeature.State(mode: .create) + case .doriList(.delegate(.fabTapped)): + state.addDori = AddDoriFeature.State() return .none - case .addDori(.presented(.delegate(.doriCreated))): - state.addDori = nil + case .doriList: return .none + // MARK: PartnerDoriHistory delegate + + case .partnerHistory(.presented(.delegate(.allDoriDeleted))): + state.partnerHistory = nil + return .send(.doriList(.refresh)) + + case .partnerHistory(.presented(.delegate(.editTapped(let dori)))): + state.editDori = EditDoriFeature.State(dori: dori) + return .none + + case .partnerHistory: + return .none + + // MARK: AddDori + + case .addDori(.presented(.delegate(.doriCreated(_)))): + state.addDori = nil + return .send(.doriList(.refresh)) + case .addDori(.presented(.delegate(.dismissed))): state.addDori = nil return .none case .addDori: return .none + + // MARK: EditDori + + case .editDori(.presented(.delegate(.doriUpdated(_)))): + state.editDori = nil + state.partnerHistory = nil + return .send(.doriList(.refresh)) + + case .editDori: + return .none } } + .ifLet(\.$partnerHistory, action: \.partnerHistory) { + PartnerDoriHistoryFeature() + } .ifLet(\.$addDori, action: \.addDori) { AddDoriFeature() } + .ifLet(\.$editDori, action: \.editDori) { + EditDoriFeature() + } } } +// MARK: - HistoryView + public struct HistoryView: View { @Bindable var store: StoreOf @@ -64,27 +124,36 @@ public struct HistoryView: View { public var body: some View { NavigationStack { - ZStack(alignment: .bottomTrailing) { - Text("내역") - .frame( - maxWidth: .infinity, - maxHeight: .infinity - ) - - FloatingActionButton { - store.send(.fabTapped) - } - .padding(20) + DoriListView( + store: store.scope(state: \.doriList, action: \.doriList) + ) + .navigationDestination( + item: $store.scope(state: \.partnerHistory, action: \.partnerHistory) + ) { historyStore in + PartnerDoriHistoryView(store: historyStore) } - .onAppear { store.send(.onAppear) } .navigationDestination( - item: $store.scope( - state: \.addDori, - action: \.addDori - ) + item: $store.scope(state: \.addDori, action: \.addDori) ) { addDoriStore in AddDoriView(store: addDoriStore) } + .navigationDestination( + item: $store.scope(state: \.editDori, action: \.editDori) + ) { editDoriStore in + NavigationStack { + EditDoriView(store: editDoriStore) + } + } } } } + +#Preview { + HistoryView( + store: Store(initialState: HistoryFeature.State()) { + HistoryFeature() + } withDependencies: { + $0.historyAPIClient = .previewValue + } + ) +} diff --git a/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriFeature.swift b/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriFeature.swift new file mode 100644 index 0000000..e22cfd9 --- /dev/null +++ b/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriFeature.swift @@ -0,0 +1,207 @@ +// +// EditDoriFeature.swift +// Dori-iOS +// +// Created by 강동영 on 2/23/26. +// + +import Foundation +import ComposableArchitecture +import DoriCore +import DoriNetwork + +@Reducer +public struct EditDoriFeature { + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable, Sendable { + public var dori: Dori + + public var transactionType: TransactionType + public var selectedEventType: EventType + public var customEventType: String + + public var amountText: String + public var eventDate: Date + public var isDatePickerVisible: Bool = false + public var isVisited: Visited + public var memo: String + + public var isSubmitting: Bool = false + + // MARK: - Computed + + public var resolvedEventType: String { + if selectedEventType == .other { + return customEventType.trimmingCharacters(in: .whitespaces) + } + return selectedEventType.rawValue + } + + public var isFormValid: Bool { + let digits = amountText.filter(\.isNumber) + guard let amount = Int(digits), amount > 0 else { return false } + let hasEventType = selectedEventType != .other || !customEventType.trimmingCharacters(in: .whitespaces).isEmpty + return hasEventType + } + + // MARK: - Init + + public init(dori: Dori) { + self.dori = dori + + self.transactionType = dori.direction + + let knownEventTypes = EventType.allCases.map(\.rawValue) + if knownEventTypes.contains(dori.eventType) { + self.selectedEventType = EventType(rawValue: dori.eventType) ?? .other + self.customEventType = "" + } else { + self.selectedEventType = .other + self.customEventType = dori.eventType + } + + self.amountText = Int(dori.amount).decimalFormatted + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + self.eventDate = formatter.date(from: dori.eventDate) ?? .init() + + self.isVisited = dori.isVisited ? .yes : .no + self.memo = dori.memo + } + } + + // MARK: - Action + + public enum Action: Equatable, Sendable { + case transactionTypeChanged(TransactionType) + case eventTypeSelected(EventType) + case customEventTypeChanged(String) + + case amountTextChanged(String) + case addAmountTapped(Int) + + case datePickerToggled + case eventDateChanged(Date) + + case isVisitedChanged(Visited) + case memoChanged(String) + + case submitTapped + case submitResponse(Result) + + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case doriUpdated(Dori) + } + } + + public struct SubmitError: Error, Equatable, Sendable { + public let message: String + public init(message: String) { self.message = message } + } + + static let maxAmount = Int(Int32.max) + + // MARK: - Dependencies + + @Dependency(\.historyAPIClient) var apiClient + + // MARK: - Reducer + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .transactionTypeChanged(type): + state.transactionType = type + return .none + + case let .eventTypeSelected(eventType): + state.selectedEventType = eventType + if eventType != .other { + state.customEventType = "" + } + return .none + + case let .customEventTypeChanged(text): + state.customEventType = String(text.prefix(10)) + return .none + + case let .amountTextChanged(text): + let digits = text.filter(\.isNumber) + if let amount = Int(digits), amount > 0 { + let capped = min(amount, Self.maxAmount) + state.amountText = capped.decimalFormatted + } else { + state.amountText = "" + } + return .none + + case let .addAmountTapped(amount): + let current = Int(state.amountText.filter(\.isNumber)) ?? 0 + let total = min(current + amount, Self.maxAmount) + state.amountText = total.decimalFormatted + return .none + + case .datePickerToggled: + state.isDatePickerVisible.toggle() + return .none + + case let .eventDateChanged(date): + state.eventDate = date + return .none + + case let .isVisitedChanged(visited): + state.isVisited = visited + return .none + + case let .memoChanged(text): + state.memo = String(text.prefix(40)) + return .none + + case .submitTapped: + guard !state.isSubmitting else { return .none } + state.isSubmitting = true + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let eventDateString = dateFormatter.string(from: state.eventDate) + + let request = DoriUpdateInput( + direction: state.transactionType, + eventType: state.resolvedEventType, + amount: Int32(state.amountText.filter(\.isNumber)) ?? 0, + eventDate: eventDateString, + isVisited: state.isVisited.boolValue, + memo: state.memo.isEmpty ? nil : state.memo + ) + let doriId = state.dori.doriId + let updateDori = apiClient.updateDori + return .run { send in + do { + let response = try await updateDori(doriId, request) + await send(.submitResponse(.success(response))) + } catch { + await send(.submitResponse(.failure(SubmitError(message: error.localizedDescription)))) + } + } + + case let .submitResponse(.success(response)): + state.isSubmitting = false + return .send(.delegate(.doriUpdated(response))) + + case .submitResponse(.failure): + state.isSubmitting = false + return .none + + case .delegate: + return .none + } + } + } +} diff --git a/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift b/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift new file mode 100644 index 0000000..0e11d53 --- /dev/null +++ b/Projects/Feature/History/Sources/PartnerDoriDetail/EditDoriView.swift @@ -0,0 +1,235 @@ +// +// EditDoriView.swift +// Dori-iOS +// +// Created by 강동영 on 2/23/26. +// + +import SwiftUI +import ComposableArchitecture +import DoriDesignSystem +import DoriCore + +public struct EditDoriView: View { + @Bindable var store: StoreOf + + private let options2x2: [DoriSegmentOption] = [ + .init(id: .judori, title: TransactionType.judori.displayName, role: .normal), + .init(id: .baddori, title: TransactionType.baddori.displayName, role: .normal), + ] + private let options3x2: [DoriSegmentOption] = EventType.allCases.map { + $0.toSegmentOptions() + } + private let options1x2: [DoriSegmentOption] = Visited.allCases.map { + $0.toSegmentOptions() + } + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // 도리(금액) + VStack(alignment: .leading, spacing: 12) { + Text("도리") + .addDoriSectionTitleStyle() + + HStack { + TextField( + "금액을 입력해주세요", + text: $store.amountText.sending(\.amountTextChanged) + ) + .keyboardType(.numberPad) + .pretendard(.body(.sb3)) + + Spacer() + + Text("원") + .pretendard(.semiBold(.sb15)) + } + .roundedStyle() + + } + + // 내역 구분 + VStack(alignment: .leading, spacing: 10) { + Text("내역 구분") + .addDoriSectionTitleStyle() + + DoriSegmentGridWithMemo( + options: options2x2, + selection: $store.transactionType.sending(\.transactionTypeChanged) + ) + } + + // 경조사 + VStack(alignment: .leading, spacing: 12) { + Text("경조사") + .addDoriSectionTitleStyle() + + DoriSegmentGridWithMemo( + options: options3x2, + selection: $store.selectedEventType.sending(\.eventTypeSelected), + memo: $store.customEventType.sending(\.customEventTypeChanged) + ) + } + + // 날짜 + VStack(alignment: .leading, spacing: 12) { + Text("날짜") + .addDoriSectionTitleStyle() + + let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy/MM/dd" + return f + }() + + HStack { + Text(dateFormatter.string(from: store.eventDate)) + .pretendard(.body(.sb3)) + .foregroundStyle(.doriBlack) + + Spacer() + + Button { + store.send(.datePickerToggled) + } label: { + Image(.iconCalendar) + } + } + .roundedStyle() + } + + // 방문 여부 + VStack(alignment: .leading, spacing: 12) { + Text("방문 여부") + .addDoriSectionTitleStyle() + + DoriSegmentGridWithMemo( + options: options1x2, + selection: $store.isVisited.sending(\.isVisitedChanged) + ) + } + + // 메모(선택) + VStack(alignment: .leading, spacing: 12) { + Text("메모(선택)") + .addDoriSectionTitleStyle() + + DoriTextField( + "메모를 입력해주세요 (40자)", + memo: $store.memo.sending(\.memoChanged), + maxLength: 40 + ) + .lineLimit(3...5) + } + + // 저장 버튼 + PrimaryButton(title: "저장") { + store.send(.submitTapped) + } + .isEnable(store.isFormValid) + } + .padding(.horizontal, 16) + .padding(.bottom, 20) + } + .navigationTitle(store.dori.partnerName) + .navigationBarTitleDisplayMode(.inline) + .overlay { + if store.isDatePickerVisible { + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { store.send(.datePickerToggled) } + .overlay { + EditDoriCalendarView(initialDate: store.eventDate) { + store.send(.datePickerToggled) + } selectionAction: { date in + store.send(.eventDateChanged(date)) + store.send(.datePickerToggled) + } + } + } + } + } +} + +// MARK: - Calendar Picker + +private struct EditDoriCalendarView: View { + @State private var selection: Date + + private let dismissAction: () -> Void + private let selectionAction: (Date) -> Void + + init( + initialDate: Date, + dismissAction: @escaping () -> Void, + selectionAction: @escaping (Date) -> Void + ) { + self._selection = State(initialValue: initialDate) + self.dismissAction = dismissAction + self.selectionAction = selectionAction + } + + var body: some View { + VStack { + DatePicker( + "", + selection: $selection, + displayedComponents: .date + ) + .datePickerStyle(.graphical) + .tint(DoriColors.main.color) + .padding() + .background(DoriColors.doriWhite.color) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + HStack { + PrimaryButton(title: "나가기") { + dismissAction() + } + .backgroundColor(.grey100) + .foregroundColor(.doriBlack) + + PrimaryButton(title: "날짜 선택") { + selectionAction(selection) + } + } + } + .padding(.horizontal, 16) + } +} + +// MARK: - Preview + +//#Preview { +// NavigationStack { +// EditDoriView( +// store: Store( +// initialState: EditDoriFeature.State( +// dori: DoriResponsesDTO( +// doriId: 1, +// userId: 1, +// partnerId: 1, +// direction: "OUT", +// partnerName: "홍길동", +// relationship: "친구", +// eventType: "결혼식", +// amount: 100_000, +// eventDate: "2025-08-15", +// isVisited: true, +// memo: "축하해요", +// createdAt: "2026-02-17T09:00:00" +// ) +// ) +// ) { +// EditDoriFeature() +// } withDependencies: { +// $0.historyAPIClient = .previewValue +// } +// ) +// } +//} diff --git a/Projects/Feature/History/Sources/PartnerDoriDetail/PartnerDoriDetailFeature.swift b/Projects/Feature/History/Sources/PartnerDoriDetail/PartnerDoriDetailFeature.swift new file mode 100644 index 0000000..29b3eb8 --- /dev/null +++ b/Projects/Feature/History/Sources/PartnerDoriDetail/PartnerDoriDetailFeature.swift @@ -0,0 +1,119 @@ +// +// PartnerDoriDetailFeature.swift +// Dori-iOS +// +// Created by 강동영 on 2/21/26. +// + +import ComposableArchitecture +import DoriDesignSystem +import DoriNetwork +import DoriCore + +@Reducer +public struct PartnerDoriDetailFeature { + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable, Sendable { + public var doriId: Int64 + public var doriDetail: Dori? + public var isLoading: Bool = false + public var showDeleteAlert: Bool = false + public var toast: DoriToast? = nil + + public init(dori: Dori) { + self.doriId = dori.doriId + self.doriDetail = dori + } + } + + // MARK: - Action + + public enum Action: Equatable, Sendable { + case onAppear + case fetchDetailResponse(Result) + case editTapped + case deleteTapped + case setDeleteAlert(Bool) + case confirmDeleteTapped + case deleteResponse(Result) + case toastDismissed + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case editTapped(Dori) + case doriDeleted + } + } + + public struct APIError: Error, Equatable, Sendable { + public let message: String + public init(message: String) { self.message = message } + } + + // MARK: - Dependencies + + @Dependency(\.historyAPIClient) var apiClient + + // MARK: - Reducer + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onAppear: + return .none + + case let .fetchDetailResponse(.success(dori)): + state.isLoading = false + state.doriDetail = dori + return .none + + case .fetchDetailResponse(.failure): + state.isLoading = false + return .none + + case .editTapped: + guard let dori = state.doriDetail else { return .none } + return .send(.delegate(.editTapped(dori))) + + case .deleteTapped: + state.showDeleteAlert = true + return .none + + case let .setDeleteAlert(value): + state.showDeleteAlert = value + return .none + + case .confirmDeleteTapped: + state.showDeleteAlert = false + let doriId = state.doriId + let delete = apiClient.deleteDori + return .run { send in + do { + try await delete(doriId) + await send(.deleteResponse(.success(true))) + } catch { + await send(.deleteResponse(.failure(APIError(message: error.localizedDescription)))) + } + } + + case .deleteResponse(.success): + state.toast = DoriToast(type: .success, message: "해당 내역이 삭제되었습니다.") + return .send(.delegate(.doriDeleted)) + + case .deleteResponse(.failure): + return .none + + case .toastDismissed: + state.toast = nil + return .none + + case .delegate: + return .none + } + } + } +} diff --git a/Projects/Feature/History/Sources/PartnerDoriDetail/PartnerDoriDetailView.swift b/Projects/Feature/History/Sources/PartnerDoriDetail/PartnerDoriDetailView.swift new file mode 100644 index 0000000..adbdcbb --- /dev/null +++ b/Projects/Feature/History/Sources/PartnerDoriDetail/PartnerDoriDetailView.swift @@ -0,0 +1,183 @@ +// +// PartnerDoriDetailView.swift +// Dori-iOS +// +// Created by 강동영 on 2/21/26. +// + +import SwiftUI +import ComposableArchitecture +import DoriDesignSystem +import DoriCore + +public struct PartnerDoriDetailView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + Group { + if let dori = store.doriDetail { + ScrollView { + VStack(spacing: 16) { + HStack { + Text(Int(dori.amount).wonFormatted) + .pretendard(.bold(.b30)) + .foregroundStyle(.doriBlack) + + Spacer() + } + .padding(.top, 24) + + VStack(spacing: 32) { + ForEach(makeProps(from: dori), id: \.self) { prop in + DetailRow(prop: prop) + } + } + + Spacer() + } + .padding(.horizontal, 16) + } + } else { + ContentUnavailableView( + "데이터 없음", + systemImage: "doc.slash", + description: Text("도리 정보를 찾을 수 없습니다.") + ) + } + } + .background(.doriWhite) + .navigationTitle(store.doriDetail?.partnerName ?? "") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + HStack(spacing: 16) { + Button { + store.send(.editTapped) + } label: { + Image(.iconEdit) + } + + Button { + store.send(.deleteTapped) + } label: { + Image(.iconDelete) + } + } + } + } + .overlay { + if store.showDeleteAlert { + DoriCommonAlert( + isPresented: Binding( + get: { store.showDeleteAlert }, + set: { store.send(.setDeleteAlert($0)) } + ), + title: "해당 내역을 삭제할까요?", + description: "삭제한 도리는 다시 복구할 수 없어요", + secondaryButton: AlertButton(title: "취소") { + store.send(.setDeleteAlert(false)) + }, + primaryButton: AlertButton(title: "삭제") { + store.send(.confirmDeleteTapped) + } + ) + } + } + .doriToast(store.toast) { + store.send(.toastDismissed) + } + .onAppear { + store.send(.onAppear) + } + } + + private func makeProps(from dori: Dori) -> [DetailProp] { + let isGiven = dori.direction == .judori + let directionText = isGiven ? "주도리" : "받도리" + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + let dateText: String + if let date = formatter.date(from: dori.eventDate) { + let displayFormatter = DateFormatter() + displayFormatter.locale = Locale(identifier: "ko_KR") + displayFormatter.dateFormat = "yyyy년 M월 d일" + dateText = displayFormatter.string(from: date) + } else { + dateText = dori.eventDate + } + + return [ + DetailProp(category: "내역구분", description: directionText), + DetailProp(category: "경조사", description: dori.eventType), + DetailProp(category: "나와의관계", description: dori.relationship), + DetailProp(category: "날짜", description: dateText), + DetailProp(category: "방문 여부", description: dori.isVisited ? "예" : "아니오"), + DetailProp(category: "메모", description: dori.memo.isEmpty ? "-" : dori.memo), + ] + } +} + +// MARK: - Supporting Views + +struct DetailProp: Hashable { + let category: String + let description: String +} + +struct DetailRow: View { + private let prop: DetailProp + + init(prop: DetailProp) { + self.prop = prop + } + + var body: some View { + HStack { + Text(prop.category) + .pretendard(.medium(.m14)) + .foregroundStyle(.grey600) + + Spacer() + + Text(prop.description) + .pretendard(.semiBold(.sb15)) + .foregroundStyle(.doriBlack) + } + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + PartnerDoriDetailView( + store: Store( + initialState: PartnerDoriDetailFeature.State( + dori: Dori( + doriId: 1, + userId: 1, + partnerId: 1, + direction: .baddori, + partnerName: "홍길동", + relationship: "친구", + eventType: "생일", + amount: 50_000, + eventDate: "2024-05-01", + isVisited: true, + memo: "생일 축하합니다", + createdAt: "2026-02-17T09:00:00" + ) + ) + ) { + PartnerDoriDetailFeature() + } withDependencies: { + $0.historyAPIClient = .previewValue + } + ) + } +} diff --git a/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryFeature.swift b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryFeature.swift new file mode 100644 index 0000000..ded4339 --- /dev/null +++ b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryFeature.swift @@ -0,0 +1,202 @@ +// +// PartnerDoriHistoryFeature.swift +// Dori-iOS +// +// Created by 강동영 on 2/21/26. +// + +import ComposableArchitecture +import DoriDesignSystem +import DoriNetwork +import DoriCore + +public enum DoriFilter: String, CaseIterable, Equatable, Sendable { + case all = "전체" + case judori = "주도리" + case baddori = "받도리" +} + +@Reducer +public struct PartnerDoriHistoryFeature { + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable, Sendable { + public var partnerId: Int64 + public var partnerName: String + public var relationship: String + public var inDoriTotalAmount: Int64 = 0 + public var outDoriTotalAmount: Int64 = 0 + public var inDoriList: [Dori] = [] + public var outDoriList: [Dori] = [] + public var isLoading: Bool = false + public var filter: DoriFilter = .all + public var showDeleteAlert: Bool = false + public var showFilterSheet: Bool = false + public var toast: DoriToast? = nil + @Presents public var doriDetail: PartnerDoriDetailFeature.State? + + public var doriByDate: [(date: String, doris: [Dori])] { + let filtered: [Dori] + switch filter { + case .all: filtered = inDoriList + outDoriList + case .judori: filtered = outDoriList + case .baddori: filtered = inDoriList + } + let sorted = filtered.sorted { $0.eventDate > $1.eventDate } + let grouped = Dictionary(grouping: sorted) { $0.eventDate } + return grouped.keys + .sorted(by: >) + .map { date in (date: date, doris: grouped[date] ?? []) } + } + + public init( + partnerId: Int64, + partnerName: String, + relationship: String + ) { + self.partnerId = partnerId + self.partnerName = partnerName + self.relationship = relationship + } + } + + // MARK: - Action + + public enum Action: Equatable, Sendable { + case onAppear + case fetchResponse(Result) + case filterChanged(DoriFilter) + case bulkDeleteTapped + case setDeleteAlert(Bool) + case confirmDeleteTapped + case bulkDeleteResponse(Result<[Int64], APIError>) + case doriTapped(Dori) + case toastDismissed + case setFilterSheet(Bool) + case doriDetail(PresentationAction) + case delegate(Delegate) + + public enum Delegate: Equatable, Sendable { + case allDoriDeleted + case editTapped(Dori) + } + } + + public struct APIError: Error, Equatable, Sendable { + public let message: String + public init(message: String) { self.message = message } + } + + // MARK: - Dependencies + + @Dependency(\.historyAPIClient) var apiClient + + // MARK: - Reducer + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onAppear: + state.isLoading = true + let partnerId = state.partnerId + let fetch = apiClient.fetchPartnerDoriList + return .run { send in + do { + let result = try await fetch(partnerId) + await send(.fetchResponse(.success(result))) + } catch { + await send(.fetchResponse(.failure(APIError(message: error.localizedDescription)))) + } + } + + case let .fetchResponse(.success(response)): + state.isLoading = false + state.inDoriTotalAmount = response.inDoriTotalAmount + state.outDoriTotalAmount = response.outDoriTotalAmount + state.inDoriList = response.inDoriList + state.outDoriList = response.outDoriList + return .none + + case .fetchResponse(.failure): + state.isLoading = false + return .none + + case let .filterChanged(filter): + state.filter = filter + state.showFilterSheet = false + return .none + + case .bulkDeleteTapped: + let allIds = (state.inDoriList + state.outDoriList).map { $0.doriId } + guard !allIds.isEmpty else { return .none } + state.showDeleteAlert = true + return .none + + case let .setDeleteAlert(value): + state.showDeleteAlert = value + return .none + + case .confirmDeleteTapped: + state.showDeleteAlert = false + let allIds = (state.inDoriList + state.outDoriList).map { $0.doriId } + let bulkDelete = apiClient.bulkDeleteDori + return .run { send in + do { + let result = try await bulkDelete(allIds) + await send(.bulkDeleteResponse(.success(result))) + } catch { + await send(.bulkDeleteResponse(.failure(APIError(message: error.localizedDescription)))) + } + } + + case .bulkDeleteResponse(.success): + state.inDoriList = [] + state.outDoriList = [] + state.inDoriTotalAmount = 0 + state.outDoriTotalAmount = 0 + state.toast = DoriToast(type: .success, message: "모든 내역이 삭제되었습니다.") + return .send(.delegate(.allDoriDeleted)) + + case .bulkDeleteResponse(.failure): + return .none + + case let .doriTapped(dori): + state.doriDetail = PartnerDoriDetailFeature.State(dori: dori) + return .none + + case .toastDismissed: + state.toast = nil + return .none + + case let .setFilterSheet(value): + state.showFilterSheet = value + return .none + + // doriDetail 위임 처리 + case .doriDetail(.presented(.delegate(.editTapped(let dori)))): + return .send(.delegate(.editTapped(dori))) + + case .doriDetail(.presented(.delegate(.doriDeleted))): + let removedId = state.doriDetail?.doriId + state.doriDetail = nil + if let id = removedId { + state.inDoriList.removeAll { $0.doriId == id } + state.outDoriList.removeAll { $0.doriId == id } + } + return .none + + case .doriDetail: + return .none + + case .delegate: + return .none + } + } + .ifLet(\.$doriDetail, action: \.doriDetail) { + PartnerDoriDetailFeature() + } + } +} diff --git a/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift new file mode 100644 index 0000000..b12372e --- /dev/null +++ b/Projects/Feature/History/Sources/PartnerDoriHistory/PartnerDoriHistoryView.swift @@ -0,0 +1,215 @@ +// +// PartnerDoriHistoryView.swift +// Dori-iOS +// +// Created by 강동영 on 2/21/26. +// + +import SwiftUI +import ComposableArchitecture +import DoriDesignSystem + +public struct PartnerDoriHistoryView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView { + VStack(spacing: 20) { + // 요약 카드 + VStack { + DoriBarGraphView( + givenAmount: Int(store.outDoriTotalAmount), + receivedAmount: Int(store.inDoriTotalAmount) + ) + + HStack { + AmountLabel(Int(store.outDoriTotalAmount)) + .pretendard(.caption(.b1)) + .foregroundStyle(.secondary) + + Spacer() + + AmountLabel(Int(store.inDoriTotalAmount)) + .pretendard(.caption(.b1)) + .foregroundStyle(.grey500) + } + .padding(.horizontal, 8) + } + .padding(.horizontal, 16) + .padding(.vertical, 36) + .background(.grey100) + + // 도리 내역 + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("도리 내역") + .pretendard(.bold(.b16)) + + Spacer() + + Button { + store.send(.setFilterSheet(true)) + } label: { + Image(.iconFilter) + } + } + .padding(.horizontal, 16) + + Divider() + + if store.doriByDate.isEmpty && !store.isLoading { + DoriEmptyView(.doriHistory) + .frame(height: 200) + } else { + ForEach(store.doriByDate, id: \.date) { group in + VStack(alignment: .leading, spacing: 8) { + DateHeaderView(date: group.date.parsedEventDate) + .padding(.horizontal) + + VStack(spacing: 0) { + ForEach(group.doris, id: \.doriId) { dori in + Button { + store.send(.doriTapped(dori)) + } label: { + TransactionRowView(dori: dori) + .padding(.horizontal) + } + .buttonStyle(.plain) + } + } + .cornerRadius(10) + } + } + } + } + } + } + .background(.doriWhite) + .navigationTitle(store.partnerName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + store.send(.bulkDeleteTapped) + } label: { + Image(.iconDelete) + } + } + } + .navigationDestination( + item: $store.scope(state: \.doriDetail, action: \.doriDetail) + ) { detailStore in + PartnerDoriDetailView(store: detailStore) + } + .sheet( + isPresented: Binding( + get: { store.showFilterSheet }, + set: { if !$0 { store.send(.setFilterSheet(false)) } } + ) + ) { + DoriFilterSheet( + selected: store.filter, + onSelect: { filter in + store.send(.filterChanged(filter)) + } + ) + .presentationDetents([.height(200)]) + } + .overlay { + if store.showDeleteAlert { + DoriCommonAlert( + isPresented: Binding( + get: { store.showDeleteAlert }, + set: { store.send(.setDeleteAlert($0)) } + ), + title: "모든 내용을 삭제할까요?", + description: "삭제한 도리는 다시 복구할 수 없어요", + secondaryButton: AlertButton(title: "취소") { + store.send(.setDeleteAlert(false)) + }, + primaryButton: AlertButton(title: "삭제") { + store.send(.confirmDeleteTapped) + } + ) + } + } + .doriToast(store.toast) { + store.send(.toastDismissed) + } + .onAppear { + store.send(.onAppear) + } + } +} + +// MARK: - Filter Sheet + +private struct DoriFilterSheet: View { + let selected: DoriFilter + let onSelect: (DoriFilter) -> Void + + var body: some View { + VStack(spacing: 50) { + RoundedRectangle(cornerRadius: 2.5) + .frame(width: 50, height: 5) + + VStack(spacing: 30) { + ForEach(DoriFilter.allCases, id: \.self) { filter in + Button { + onSelect(filter) + } label: { + HStack { + Text(filter.rawValue) + .pretendard(selected == filter ? .bold(.b16) : .regular(.r16)) + .foregroundStyle(.doriBlack) + + Spacer() + + if selected == filter { + Image(systemName: "checkmark") + .foregroundStyle(.main) + } + } + .padding(.horizontal, 20) + } + } + } + + } + .padding(.top, 12) + } +} + +// MARK: - String Helper + +private extension String { + var parsedEventDate: Date { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: self) ?? Date() + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + PartnerDoriHistoryView( + store: Store( + initialState: PartnerDoriHistoryFeature.State( + partnerId: 1, + partnerName: "홍길동", + relationship: "친구" + ) + ) { + PartnerDoriHistoryFeature() + } withDependencies: { + $0.historyAPIClient = .previewValue + } + ) + } +} diff --git a/Projects/Feature/Project.swift b/Projects/Feature/Project.swift index dffadba..3e038cb 100644 --- a/Projects/Feature/Project.swift +++ b/Projects/Feature/Project.swift @@ -45,6 +45,7 @@ let project = Project.dori( DoriModules.addDori.module.targetDependency, DoriModules.designSystem.module.projectDependency, DoriModules.core.module.projectDependency, + DoriModules.network.module.projectDependency, .external(.composableArchitecture) ] ), @@ -56,20 +57,5 @@ let project = Project.dori( .external(.composableArchitecture) ] ), -// .app( -// name: "MyPageDemoApp", -// bundleId: "com.arex.dori.mypage.demo", -// infoPlist: .extendingDefault(with: [ -// "CFBundleDisplayName": "Home Demo", -// "UILaunchStoryboardName": "LaunchScreen", -// "UISupportedInterfaceOrientations": .array([ -// .string("UIInterfaceOrientationPortrait") -// ]) -// ]), -// sources: ["Demo/Sources/**"], -// resources: ["Demo/Resources/**"], -// dependencies: [], -// settings: .demoAppSettings, -// ), ] ) diff --git a/Projects/Infra/DoriNetwork/Sources/Endpoints/HistoryEndpoints.swift b/Projects/Infra/DoriNetwork/Sources/Endpoints/HistoryEndpoints.swift new file mode 100644 index 0000000..e3c8a2a --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/Endpoints/HistoryEndpoints.swift @@ -0,0 +1,97 @@ +// +// HistoryEndpoints.swift +// Dori-iOS +// +// Created by 강동영 on 2/21/26. +// + +import Foundation + +public struct FetchPartnersEndpoint: Endpoint { + public let baseURL: String + public let path: String = "/dori/partners" + public let method: HTTPMethod = .GET + public let headers: [String: String] = [:] + public let queryParameters: [String: String] + public let body: Data? = nil + + public init( + page: Int, + size: Int, + baseURL: String = NetworkConfig.baseURL + ) { + self.baseURL = baseURL + self.queryParameters = [ + "page": String(page), + "size": String(size) + ] + } +} + +public struct FetchPartnerDoriListEndpoint: Endpoint { + public let baseURL: String + public let path: String = "/dori/list/partner" + public let method: HTTPMethod = .GET + public let headers: [String: String] = [:] + public let queryParameters: [String: String] + public let body: Data? = nil + + public init( + partnerId: Int64, + baseURL: String = NetworkConfig.baseURL + ) { + self.baseURL = baseURL + self.queryParameters = ["partnerId": String(partnerId)] + } +} + +public struct FetchDoriDetailEndpoint: Endpoint { + public let baseURL: String + public let path: String = "/dori/detail" + public let method: HTTPMethod = .GET + public let headers: [String: String] = [:] + public let queryParameters: [String: String] + public let body: Data? = nil + + public init( + doriId: Int64, + baseURL: String = NetworkConfig.baseURL + ) { + self.baseURL = baseURL + self.queryParameters = ["doriId": String(doriId)] + } +} + +public struct DeleteDoriEndpoint: Endpoint { + public let baseURL: String + public let path: String = "/dori" + public let method: HTTPMethod = .DELETE + public let headers: [String: String] = [:] + public let queryParameters: [String: String] + public let body: Data? = nil + + public init( + doriId: Int64, + baseURL: String = NetworkConfig.baseURL + ) { + self.baseURL = baseURL + self.queryParameters = ["doriId": String(doriId)] + } +} + +public struct BulkDeleteDoriEndpoint: Endpoint { + public let baseURL: String + public let path: String = "/dori/bulk" + public let method: HTTPMethod = .DELETE + public let headers: [String: String] = [:] + public let queryParameters: [String: String] + public let body: Data? = nil + + public init( + doriIds: [Int64], + baseURL: String = NetworkConfig.baseURL + ) { + self.baseURL = baseURL + self.queryParameters = ["doriIds": doriIds.map(String.init).joined(separator: ",")] + } +} diff --git a/Projects/Infra/DoriNetwork/Sources/Requests/DoriRequests.swift b/Projects/Infra/DoriNetwork/Sources/Requests/DoriRequests.swift index 913ebbf..36e98f2 100644 --- a/Projects/Infra/DoriNetwork/Sources/Requests/DoriRequests.swift +++ b/Projects/Infra/DoriNetwork/Sources/Requests/DoriRequests.swift @@ -9,7 +9,7 @@ import Foundation // MARK: - Dori Request DTOs public struct DoriPostRequest: Codable, Equatable, Sendable { - public let partnerId: Int64 + public let partnerId: Int64? public let direction: String public let partnerName: String public let relationship: String @@ -20,7 +20,7 @@ public struct DoriPostRequest: Codable, Equatable, Sendable { public let memo: String? public init( - partnerId: Int64, + partnerId: Int64?, direction: String, partnerName: String, relationship: String, diff --git a/Projects/Infra/DoriNetwork/Sources/Responses/DoriResponses.swift b/Projects/Infra/DoriNetwork/Sources/Responses/DoriResponses.swift index cbe89e4..972363a 100644 --- a/Projects/Infra/DoriNetwork/Sources/Responses/DoriResponses.swift +++ b/Projects/Infra/DoriNetwork/Sources/Responses/DoriResponses.swift @@ -20,7 +20,7 @@ public struct DoriResponsesDTO: Codable, Equatable, Sendable { public let amount: Int32 public let eventDate: String public let isVisited: Bool - public let memo: String + public let memo: String? public let createdAt: String public init( @@ -34,7 +34,7 @@ public struct DoriResponsesDTO: Codable, Equatable, Sendable { amount: Int32, eventDate: String, isVisited: Bool, - memo: String, + memo: String?, createdAt: String ) { self.doriId = doriId diff --git a/Projects/Infra/DoriNetwork/Sources/Responses/PartnerDoriResponses.swift b/Projects/Infra/DoriNetwork/Sources/Responses/PartnerDoriResponses.swift new file mode 100644 index 0000000..49582ac --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/Responses/PartnerDoriResponses.swift @@ -0,0 +1,68 @@ +// +// PartnerDoriResponses.swift +// Dori-iOS +// +// Created by 강동영 on 2/21/26. +// + +import Foundation + +// MARK: - Partner Summary Response (GET /dori/partners) + +public struct PartnerSummaryResponse: Codable, Equatable, Sendable { + public let partnerId: Int64 + public let partnerName: String + public let relationship: String + public let recentDoriList: [DoriResponsesDTO] + public let inDoriTotalAmount: Int64 + public let outDoriTotalAmount: Int64 + + public init( + partnerId: Int64, + partnerName: String, + relationship: String, + recentDoriList: [DoriResponsesDTO], + inDoriTotalAmount: Int64, + outDoriTotalAmount: Int64 + ) { + self.partnerId = partnerId + self.partnerName = partnerName + self.relationship = relationship + self.recentDoriList = recentDoriList + self.inDoriTotalAmount = inDoriTotalAmount + self.outDoriTotalAmount = outDoriTotalAmount + } +} + +// MARK: - Partner Dori List Response (GET /dori/list/partner) + +public struct PartnerDoriListResponse: Codable, Equatable, Sendable { + public let userId: Int64 + public let partnerId: Int64 + public let partnerName: String + public let relationship: String + public let inDoriTotalAmount: Int64 + public let inDoriList: [DoriResponsesDTO] + public let outDoriTotalAmount: Int64 + public let outDoriList: [DoriResponsesDTO] + + public init( + userId: Int64, + partnerId: Int64, + partnerName: String, + relationship: String, + inDoriTotalAmount: Int64, + inDoriList: [DoriResponsesDTO], + outDoriTotalAmount: Int64, + outDoriList: [DoriResponsesDTO] + ) { + self.userId = userId + self.partnerId = partnerId + self.partnerName = partnerName + self.relationship = relationship + self.inDoriTotalAmount = inDoriTotalAmount + self.inDoriList = inDoriList + self.outDoriTotalAmount = outDoriTotalAmount + self.outDoriList = outDoriList + } +} diff --git a/Projects/Infra/DoriNetwork/Sources/Responses/PartnerResponses.swift b/Projects/Infra/DoriNetwork/Sources/Responses/PartnerResponses.swift index 0b1d0e6..ff2dc54 100644 --- a/Projects/Infra/DoriNetwork/Sources/Responses/PartnerResponses.swift +++ b/Projects/Infra/DoriNetwork/Sources/Responses/PartnerResponses.swift @@ -10,48 +10,45 @@ import Foundation // MARK: - Partner Response DTOs public struct PartnerDetailResponse: Codable, Equatable, Sendable { - public let partnerId: Int64 - public let userId: Int64 - public let name: String - public let relationship: String - public let nickname: String? - public let ageGroup: String? - public let gender: String? - public let createdAt: String - - public init( - partnerId: Int64, - userId: Int64, - name: String, - relationship: String, - nickname: String? = nil, - ageGroup: String? = nil, - gender: String? = nil, - createdAt: String - ) { - self.partnerId = partnerId - self.userId = userId - self.name = name - self.relationship = relationship - self.nickname = nickname - self.ageGroup = ageGroup - self.gender = gender - self.createdAt = createdAt - } + public let partnerId: Int64 + public let userId: Int64 + public let name: String + public let relationship: String + public let nickname: String + public let gender: String + public let createdAt: String + + public init( + partnerId: Int64, + userId: Int64, + name: String, + relationship: String, + nickname: String, + gender: String, + createdAt: String + ) { + self.partnerId = partnerId + self.userId = userId + self.name = name + self.relationship = relationship + self.nickname = nickname + self.gender = gender + self.createdAt = createdAt + } } public struct PartnerUpdateResponse: Codable, Equatable, Sendable { - public let updatedCount: Int32 - - public init(updatedCount: Int32) { - self.updatedCount = updatedCount - } + public let updatedCount: Int32 + + public init(updatedCount: Int32) { + self.updatedCount = updatedCount + } } public struct PartnerExistsResponse: Codable, Equatable, Sendable { - public let exists: Bool - - public init(exists: Bool) { - self.exists = exists - } + public let exists: Bool + + public init(exists: Bool) { + self.exists = exists + } } diff --git a/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift b/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift index 949fee0..41fa43e 100644 --- a/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift +++ b/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift @@ -36,8 +36,8 @@ public final class NetworkServiceImpl: NetworkService { #endif let dataTask = session.request(urlRequest) - .validate() - .serializingDecodable(T.self, decoder: decoder) + .validate(statusCode: 200..<300) + .serializingData() let response = await dataTask.response @@ -47,14 +47,20 @@ public final class NetworkServiceImpl: NetworkService { throw mapAlamofireError(error) } -#if DEBUG - guard let httpResponse = response.response, let data = response.data else { + guard let httpResponse = response.response else { throw NetworkError.invalidResponse } + + guard let data = response.data else { + throw NetworkError.noData + } +#if DEBUG self.logger?.responseLogger(response: httpResponse, data: data) #endif - return try await dataTask.value + let decodedData = try JSONDecoder().decode(T.self, from: data) + + return decodedData } catch let error as AFError { throw mapAlamofireError(error) } catch let error as DecodingError { diff --git a/Tuist/ProjectDescriptionHelpers/Environment.swift b/Tuist/ProjectDescriptionHelpers/Environment.swift index db881b3..0ebdd1f 100644 --- a/Tuist/ProjectDescriptionHelpers/Environment.swift +++ b/Tuist/ProjectDescriptionHelpers/Environment.swift @@ -32,7 +32,7 @@ public enum BuildConfiguration: String, CaseIterable { // MARK: - Environment public struct Environment { public static let deploymentTarget = "17.6" - public static let teamID = "T5D2PB4P5T" + public static let teamID = "FL4QTRRKMD" public static let organizationName = "com.arex" public static let defaultRegion = "ko" public static let projectName = "Dori-iOS" diff --git a/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift b/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift index 6c08689..950dad9 100644 --- a/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift +++ b/Tuist/ProjectDescriptionHelpers/Settings+Extension.swift @@ -27,21 +27,20 @@ public extension Settings { "ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES": "YES", "ENABLE_TESTABILITY": "YES", "IPHONEOS_DEPLOYMENT_TARGET": .string(Environment.deploymentTarget), - "SWIFT_VERSION": "6.0" + "SWIFT_VERSION": "6.0", + "DEVELOPMENT_TEAM": .string(Environment.teamID) ] ) /// 앱용 설정 - static func appSettings( - teamID: String = Environment.teamID - ) -> Settings { + static func appSettings() -> Settings { let rootPath = "Projects/App" let xcconfigPath = "\(rootPath)/Resources/Common.xcconfig" let baseSettings: [String: SettingValue] = [ "APP_NAME": .string(Environment.App.displayName), "CODE_SIGN_STYLE": "Automatic", - "DEVELOPMENT_TEAM": .string(teamID), + "DEVELOPMENT_TEAM": .string(Environment.teamID), "MARKETING_VERSION": .string(Environment.App.version), "CURRENT_PROJECT_VERSION": .string(Environment.App.buildNumber), "ENABLE_BITCODE": "NO", @@ -87,7 +86,6 @@ public extension Settings { static let demoAppSettings: Settings = .settings( base: [ "CODE_SIGN_STYLE": "Automatic", - "DEVELOPMENT_TEAM": .string(Environment.teamID), "IPHONEOS_DEPLOYMENT_TARGET": .string(Environment.deploymentTarget), "SWIFT_VERSION": "6.0", "ENABLE_TESTABILITY": "YES"