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"