From 2078ebc8f31fdec362d32867343492d9e7d51d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Thu, 11 Dec 2025 10:46:27 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=EC=A0=95=EC=82=B0=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=EB=AA=A8=EB=8D=B8=20=EB=B0=8F?= =?UTF-8?q?=20UseCase=20=ED=94=84=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settlement/SettlementCalculation.swift | 60 +++++++++++++++++ .../Entity/Settlement/SettlementDetail.swift | 66 +++++++++++++++++++ .../CalculateSettlementUseCaseProtocol.swift | 52 --------------- 3 files changed, 126 insertions(+), 52 deletions(-) create mode 100644 Domain/Sources/Entity/Settlement/SettlementCalculation.swift create mode 100644 Domain/Sources/Entity/Settlement/SettlementDetail.swift diff --git a/Domain/Sources/Entity/Settlement/SettlementCalculation.swift b/Domain/Sources/Entity/Settlement/SettlementCalculation.swift new file mode 100644 index 0000000..9509f40 --- /dev/null +++ b/Domain/Sources/Entity/Settlement/SettlementCalculation.swift @@ -0,0 +1,60 @@ +// +// SettlementCalculation.swift +// Domain +// +// Created by 홍석현 on 12/10/25. +// + +import Foundation + +// 정산 계산 결과 +public struct SettlementCalculation: Equatable { + public let totalExpenseAmount: Double // 총 지출 금액 + public let myShareAmount: Double // 내가 부담해야 할 금액 (내가 참여한 지출들의 분담금 합계) + public let totalPersonCount: Int // 인원수 + public let averagePerPerson: Double // 1인 평균 지출 + public let myNetBalance: Double // 내 순 차액 (Pay - Owe) + public let memberBalances: [String: Double] // 각 멤버의 순 차액 + public let paymentsToMake: [PaymentInfo] // 지급 예정 금액 + public let paymentsToReceive: [PaymentInfo] // 수령 예정 금액 + + public init( + totalExpenseAmount: Double, + myShareAmount: Double, + totalPersonCount: Int, + averagePerPerson: Double, + myNetBalance: Double, + memberBalances: [String: Double], + paymentsToMake: [PaymentInfo], + paymentsToReceive: [PaymentInfo] + ) { + self.totalExpenseAmount = totalExpenseAmount + self.myShareAmount = myShareAmount + self.totalPersonCount = totalPersonCount + self.averagePerPerson = averagePerPerson + self.myNetBalance = myNetBalance + self.memberBalances = memberBalances + self.paymentsToMake = paymentsToMake + self.paymentsToReceive = paymentsToReceive + } +} + +// 지급/수령 정보 +public struct PaymentInfo: Equatable, Identifiable { + public let id: String + public let memberId: String + public let memberName: String + public let amount: Double + + public init( + id: String, + memberId: String, + memberName: String, + amount: Double + ) { + self.id = id + self.memberId = memberId + self.memberName = memberName + self.amount = amount + } +} diff --git a/Domain/Sources/Entity/Settlement/SettlementDetail.swift b/Domain/Sources/Entity/Settlement/SettlementDetail.swift new file mode 100644 index 0000000..3b862e7 --- /dev/null +++ b/Domain/Sources/Entity/Settlement/SettlementDetail.swift @@ -0,0 +1,66 @@ +// +// SettlementDetail.swift +// Domain +// +// Created by SseuDam on 2025. +// + +import Foundation + +// MARK: - Member Settlement Detail +public struct MemberSettlementDetail: Equatable, Identifiable { + public let id: String + public let memberId: String + public let memberName: String + public let netBalance: Double + public let totalPaid: Double + public let totalOwe: Double + public let paidExpenses: [ExpenseDetail] + public let sharedExpenses: [ExpenseDetail] + + public init( + id: String, + memberId: String, + memberName: String, + netBalance: Double, + totalPaid: Double, + totalOwe: Double, + paidExpenses: [ExpenseDetail], + sharedExpenses: [ExpenseDetail] + ) { + self.id = id + self.memberId = memberId + self.memberName = memberName + self.netBalance = netBalance + self.totalPaid = totalPaid + self.totalOwe = totalOwe + self.paidExpenses = paidExpenses + self.sharedExpenses = sharedExpenses + } +} + +// MARK: - Expense Detail +public struct ExpenseDetail: Equatable, Identifiable { + public let id: String + public let title: String + public let amount: Double + public let shareAmount: Double + public let participantCount: Int + public let expenseDate: Date + + public init( + id: String, + title: String, + amount: Double, + shareAmount: Double, + participantCount: Int, + expenseDate: Date + ) { + self.id = id + self.title = title + self.amount = amount + self.shareAmount = shareAmount + self.participantCount = participantCount + self.expenseDate = expenseDate + } +} diff --git a/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift b/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift index 80a0fa9..19a9135 100644 --- a/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift +++ b/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift @@ -7,58 +7,6 @@ import Foundation -// 정산 계산 결과 -public struct SettlementCalculation: Equatable { - public let totalExpenseAmount: Double // 총 지출 금액 - public let myShareAmount: Double // 내가 부담해야 할 금액 (내가 참여한 지출들의 분담금 합계) - public let totalPersonCount: Int // 인원수 - public let averagePerPerson: Double // 1인 평균 지출 - public let myNetBalance: Double // 내 순 차액 (Pay - Owe) - public let memberBalances: [String: Double] // 각 멤버의 순 차액 - public let paymentsToMake: [PaymentInfo] // 지급 예정 금액 - public let paymentsToReceive: [PaymentInfo] // 수령 예정 금액 - - public init( - totalExpenseAmount: Double, - myShareAmount: Double, - totalPersonCount: Int, - averagePerPerson: Double, - myNetBalance: Double, - memberBalances: [String: Double], - paymentsToMake: [PaymentInfo], - paymentsToReceive: [PaymentInfo] - ) { - self.totalExpenseAmount = totalExpenseAmount - self.myShareAmount = myShareAmount - self.totalPersonCount = totalPersonCount - self.averagePerPerson = averagePerPerson - self.myNetBalance = myNetBalance - self.memberBalances = memberBalances - self.paymentsToMake = paymentsToMake - self.paymentsToReceive = paymentsToReceive - } -} - -// 지급/수령 정보 -public struct PaymentInfo: Equatable, Identifiable { - public let id: String - public let memberId: String - public let memberName: String - public let amount: Double - - public init( - id: String, - memberId: String, - memberName: String, - amount: Double - ) { - self.id = id - self.memberId = memberId - self.memberName = memberName - self.amount = amount - } -} - public protocol CalculateSettlementUseCaseProtocol { func execute( expenses: [Expense], From 7f53d077c1d65d0706db64307a48e3fd75d68783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Thu, 11 Dec 2025 10:46:34 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20SettlementDetailFeature=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Features/Settlement/Project.swift | 3 +- .../Demo/Resources/LaunchScreen.storyboard | 25 +++ .../Sources/SettlementDetailDemoApp.swift | 21 ++ Features/SettlementDetail/Project.swift | 11 ++ .../Components/ExpenseBreakdownSection.swift | 186 ++++++++++++++++++ .../Sources/Components/MemberDetailCard.swift | 170 ++++++++++++++++ .../Sources/SettlementDetailFeature.swift | 58 ++++++ .../Sources/SettlementDetailView.swift | 120 +++++++++++ .../Tests/SettlementDetailFeatureTests.swift | 7 + .../TargetDependency+Modules.swift | 6 +- 10 files changed, 604 insertions(+), 3 deletions(-) create mode 100644 Features/SettlementDetail/Demo/Resources/LaunchScreen.storyboard create mode 100644 Features/SettlementDetail/Demo/Sources/SettlementDetailDemoApp.swift create mode 100644 Features/SettlementDetail/Project.swift create mode 100644 Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift create mode 100644 Features/SettlementDetail/Sources/Components/MemberDetailCard.swift create mode 100644 Features/SettlementDetail/Sources/SettlementDetailFeature.swift create mode 100644 Features/SettlementDetail/Sources/SettlementDetailView.swift create mode 100644 Features/SettlementDetail/Tests/SettlementDetailFeatureTests.swift diff --git a/Features/Settlement/Project.swift b/Features/Settlement/Project.swift index 0e0c87a..057fb2a 100644 --- a/Features/Settlement/Project.swift +++ b/Features/Settlement/Project.swift @@ -6,7 +6,8 @@ let project = Project.makeFeature( dependencies: [ .Features.ExpenseList, .Features.SaveExpense, - .Features.SettlementResult + .Features.SettlementResult, + .Features.SettlementDetail ], hasTests: true ) diff --git a/Features/SettlementDetail/Demo/Resources/LaunchScreen.storyboard b/Features/SettlementDetail/Demo/Resources/LaunchScreen.storyboard new file mode 100644 index 0000000..dc004f9 --- /dev/null +++ b/Features/SettlementDetail/Demo/Resources/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Features/SettlementDetail/Demo/Sources/SettlementDetailDemoApp.swift b/Features/SettlementDetail/Demo/Sources/SettlementDetailDemoApp.swift new file mode 100644 index 0000000..4671b3f --- /dev/null +++ b/Features/SettlementDetail/Demo/Sources/SettlementDetailDemoApp.swift @@ -0,0 +1,21 @@ +// +// SettlementDetailDemoApp.swift +// SseuDam +// +// Created by SseuDam on2025. +// Copyright ©2025 com.testdev. All rights reserved. +// + +import SwiftUI +import SettlementDetailFeature + +@main +struct SettlementDetailDemoApp: App { + var body: some Scene { + WindowGroup { + NavigationView { + SettlementDetailView() + } + } + } +} \ No newline at end of file diff --git a/Features/SettlementDetail/Project.swift b/Features/SettlementDetail/Project.swift new file mode 100644 index 0000000..de645ea --- /dev/null +++ b/Features/SettlementDetail/Project.swift @@ -0,0 +1,11 @@ +import ProjectDescription +import SseuDamPlugin + +let project = Project.makeFeature( + name: .SettlementDetail, + dependencies: [ + .Domain, + .DesignSystem + ], + hasTests: true +) diff --git a/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift b/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift new file mode 100644 index 0000000..692b22f --- /dev/null +++ b/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift @@ -0,0 +1,186 @@ +// +// ExpenseBreakdownSection.swift +// SettlementDetailFeature +// +// Created by SseuDam on 2025. +// + +import SwiftUI +import DesignSystem + +struct ExpenseBreakdownSection: View { + let title: String + let totalAmount: Double + let expenses: [ExpenseDetail] + let showEmpty: Bool + + // 날짜 오름차순 정렬된 지출 목록 + private var sortedExpenses: [ExpenseDetail] { + expenses.sorted { $0.expenseDate < $1.expenseDate } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // 섹션 헤더 + HStack { + Text(title) + .font(.app(.body, weight: .semibold)) + .foregroundStyle(Color.black) + + Spacer() + + Text("₩\(Int(totalAmount).formatted())") + .font(.app(.title3, weight: .semibold)) + .foregroundStyle(Color.primary500) + } + + // 지출 내역 + if expenses.isEmpty && showEmpty { + Text("결제한 내역이 없습니다") + .font(.app(.caption1, weight: .medium)) + .foregroundStyle(Color.gray7) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 12) + } else { + VStack(spacing: 8) { + ForEach(sortedExpenses) { expense in + ExpenseRow(expense: expense) + } + } + } + } + .padding(16) + .background(Color.primary50) + .cornerRadius(12) + } +} + +// MARK: - Expense Row +private struct ExpenseRow: View { + let expense: ExpenseDetail + + var body: some View { + HStack(spacing: 8) { + // 지출 제목 + 날짜 + VStack(alignment: .leading, spacing: 4) { + Text(expense.title) + .font(.app(.body, weight: .medium)) + .foregroundStyle(Color.black) + + Text(formatDate(expense.expenseDate)) + .font(.app(.caption2, weight: .medium)) + .foregroundStyle(Color.gray7) + } + + Spacer() + + // 내 부담 금액 + VStack(alignment: .trailing, spacing: 2) { + Text("₩\(Int(expense.shareAmount).formatted())") + .font(.app(.body, weight: .semibold)) + .foregroundStyle(Color.black) + + Text("(전체 ₩\(Int(expense.amount).formatted()) ÷ \(expense.participantCount))") + .font(.app(.caption2, weight: .medium)) + .foregroundStyle(Color.gray7) + } + } + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy.MM.dd" + return formatter.string(from: date) + } +} + +#Preview("지출이 있는 경우") { + let calendar = Calendar.current + let today = Date() + let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! + let twoDaysAgo = calendar.date(byAdding: .day, value: -2, to: today)! + + return ExpenseBreakdownSection( + title: "결제한 금액", + totalAmount: 280000, + expenses: [ + ExpenseDetail( + id: "1", + title: "호텔", + amount: 120000, + shareAmount: 40000, + participantCount: 3, + expenseDate: twoDaysAgo + ), + ExpenseDetail(id: "2", title: "저녁식사", amount: 60000, shareAmount: 30000, participantCount: 2, expenseDate: yesterday), + ExpenseDetail( + id: "3", + title: "커피", + amount: 100000, + shareAmount: 33333, + participantCount: 3, + expenseDate: today + ) + ], + showEmpty: true + ) + .padding(16) + .background(Color.white) +} + +#Preview("지출이 없는 경우") { + let calendar = Calendar.current + let today = Date() + let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! + let twoDaysAgo = calendar.date(byAdding: .day, value: -2, to: today)! + + return ExpenseBreakdownSection( + title: "결제한 금액", + totalAmount: 0, + expenses: [], + showEmpty: true + ) + .padding(16) + .background(Color.white) +} + +#Preview("부담 금액") { + let calendar = Calendar.current + let today = Date() + let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! + let twoDaysAgo = calendar.date(byAdding: .day, value: -2, to: today)! + + return ExpenseBreakdownSection( + title: "부담 금액", + totalAmount: 226773, + expenses: [ + ExpenseDetail( + id: "1", + title: "호텔", + amount: 120000, + shareAmount: 40000, + participantCount: 3, + expenseDate: twoDaysAgo + ), + ExpenseDetail( + id: "2", + title: "저녁식사", + amount: 60000, + shareAmount: 30000, + participantCount: 2, + expenseDate: yesterday + ), + ExpenseDetail( + id: "3", + title: "커피", + amount: 100000, + shareAmount: 33333, + participantCount: 3, + expenseDate: today + ) + ], + showEmpty: false + ) + .padding(16) + .background(Color.white) +} diff --git a/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift b/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift new file mode 100644 index 0000000..1896f84 --- /dev/null +++ b/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift @@ -0,0 +1,170 @@ +// +// MemberDetailCard.swift +// SettlementDetailFeature +// +// Created by SseuDam on 2025. +// + +import SwiftUI +import DesignSystem + +struct MemberDetailCard: View { + let detail: MemberSettlementDetail + let isExpanded: Bool + let isCurrentUser: Bool + let onToggle: () -> Void + + var body: some View { + VStack(spacing: 0) { + // 헤더 (이름, 순 차액, 펼치기 버튼) + Button(action: onToggle) { + HStack(spacing: 12) { + // 프로필 아이콘 + Image(asset: .profile) + .resizable() + .foregroundStyle(Color.primary500) + .frame(width: 40, height: 40) + + // 이름 + Text(detail.memberName) + .font(.app(.title3, weight: .semibold)) + .foregroundStyle(Color.black) + + Spacer() + + // 순 차액 + VStack(alignment: .trailing, spacing: 4) { + Text(netBalanceLabel) + .font(.app(.caption1, weight: .medium)) + .foregroundStyle(Color.gray7) + + Text(formatAmount(detail.netBalance)) + .font(.app(.title3, weight: .semibold)) + .foregroundStyle(netBalanceColor) + } + + // 펼치기 아이콘 + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.gray7) + } + .padding(16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + // 상세 내용 (펼쳤을 때만 표시) + if isExpanded { + VStack(spacing: 16) { + Divider() + .background(Color.gray2) + + // 결제 금액 + ExpenseBreakdownSection( + title: "결제한 금액", + totalAmount: detail.totalPaid, + expenses: detail.paidExpenses, + showEmpty: true + ) + + // 부담 금액 + ExpenseBreakdownSection( + title: "부담 금액", + totalAmount: detail.totalOwe, + expenses: detail.sharedExpenses, + showEmpty: false + ) + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + .background(Color.white) + .cornerRadius(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.gray1, lineWidth: 1) + ) + } + + private var netBalanceLabel: String { + if detail.netBalance > 0 { + return "받을 돈" + } else if detail.netBalance < 0 { + return "줄 돈" + } else { + return "정산 완료" + } + } + + private var netBalanceColor: Color { + if detail.netBalance > 0 { + return .primary500 + } else if detail.netBalance < 0 { + return .red + } else { + return .black + } + } + + private func formatAmount(_ amount: Double) -> String { + let absAmount = abs(amount) + return "₩\(Int(absAmount).formatted())" + } +} + +#Preview { + let calendar = Calendar.current + let today = Date() + let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! + let twoDaysAgo = calendar.date(byAdding: .day, value: -2, to: today)! + + return VStack(spacing: 16) { + // 받을 돈이 있는 경우 + MemberDetailCard( + detail: MemberSettlementDetail( + id: "1", + memberId: "user1", + memberName: "홍석현", + netBalance: 53227, + totalPaid: 280000, + totalOwe: 226773, + paidExpenses: [ + ExpenseDetail(id: "1", title: "호텔", amount: 120000, shareAmount: 40000, participantCount: 3, expenseDate: twoDaysAgo), + ExpenseDetail(id: "2", title: "저녁식사", amount: 60000, shareAmount: 30000, participantCount: 2, expenseDate: yesterday), + ExpenseDetail(id: "3", title: "커피", amount: 100000, shareAmount: 33333, participantCount: 3, expenseDate: today) + ], + sharedExpenses: [ + ExpenseDetail(id: "1", title: "호텔", amount: 120000, shareAmount: 40000, participantCount: 3, expenseDate: twoDaysAgo), + ExpenseDetail(id: "2", title: "저녁식사", amount: 60000, shareAmount: 30000, participantCount: 2, expenseDate: yesterday), + ExpenseDetail(id: "3", title: "커피", amount: 100000, shareAmount: 33333, participantCount: 3, expenseDate: today) + ] + ), + isExpanded: true, + isCurrentUser: true, + onToggle: {} + ) + + // 줄 돈이 있는 경우 + MemberDetailCard( + detail: MemberSettlementDetail( + id: "2", + memberId: "user2", + memberName: "김철수", + netBalance: -52000, + totalPaid: 0, + totalOwe: 52000, + paidExpenses: [], + sharedExpenses: [ + ExpenseDetail(id: "1", title: "호텔", amount: 120000, shareAmount: 40000, participantCount: 3, expenseDate: twoDaysAgo), + ExpenseDetail(id: "3", title: "커피", amount: 100000, shareAmount: 33333, participantCount: 3, expenseDate: today) + ] + ), + isExpanded: false, + isCurrentUser: false, + onToggle: {} + ) + } + .padding(16) + .background(Color.primary50) +} diff --git a/Features/SettlementDetail/Sources/SettlementDetailFeature.swift b/Features/SettlementDetail/Sources/SettlementDetailFeature.swift new file mode 100644 index 0000000..34c8ca6 --- /dev/null +++ b/Features/SettlementDetail/Sources/SettlementDetailFeature.swift @@ -0,0 +1,58 @@ +// +// SettlementDetailFeature.swift +// SettlementDetailFeature +// +// Created by SseuDam on 2025. +// + +import Foundation +import ComposableArchitecture +import Domain + +@Reducer +public struct SettlementDetailFeature { + @ObservableState + public struct State: Equatable { + var memberDetails: [MemberSettlementDetail] + var currentUserId: String + var expandedMemberIds: Set = [] + + public init( + memberDetails: [MemberSettlementDetail], + currentUserId: String + ) { + self.memberDetails = memberDetails + self.currentUserId = currentUserId + // 내 정보는 기본으로 펼쳐놓기 + self.expandedMemberIds = [currentUserId] + } + + var myDetail: MemberSettlementDetail? { + memberDetails.first { $0.memberId == currentUserId } + } + + var otherMemberDetails: [MemberSettlementDetail] { + memberDetails.filter { $0.memberId != currentUserId } + } + } + + public enum Action: Equatable { + case toggleMemberExpansion(String) + } + + public init() {} + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .toggleMemberExpansion(let memberId): + if state.expandedMemberIds.contains(memberId) { + state.expandedMemberIds.remove(memberId) + } else { + state.expandedMemberIds.insert(memberId) + } + return .none + } + } + } +} diff --git a/Features/SettlementDetail/Sources/SettlementDetailView.swift b/Features/SettlementDetail/Sources/SettlementDetailView.swift new file mode 100644 index 0000000..fb64054 --- /dev/null +++ b/Features/SettlementDetail/Sources/SettlementDetailView.swift @@ -0,0 +1,120 @@ +// +// SettlementDetailView.swift +// SettlementDetailFeature +// +// Created by SseuDam on 2025. +// + +import SwiftUI +import DesignSystem +import ComposableArchitecture + +public struct SettlementDetailView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView { + VStack(spacing: 16) { + // 내 정산 상세 + if let myDetail = store.myDetail { + VStack(alignment: .leading, spacing: 8) { + Text("내 정산 내역") + .font(.app(.title3, weight: .semibold)) + .foregroundStyle(Color.black) + + MemberDetailCard( + detail: myDetail, + isExpanded: store.expandedMemberIds.contains(myDetail.memberId), + isCurrentUser: true, + onToggle: { + store.send(.toggleMemberExpansion(myDetail.memberId)) + } + ) + } + } + + // 다른 멤버들 + if !store.otherMemberDetails.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("다른 멤버") + .font(.app(.title3, weight: .semibold)) + .foregroundStyle(Color.black) + + VStack(spacing: 12) { + ForEach(store.otherMemberDetails) { detail in + MemberDetailCard( + detail: detail, + isExpanded: store.expandedMemberIds.contains(detail.memberId), + isCurrentUser: false, + onToggle: { + store.send(.toggleMemberExpansion(detail.memberId)) + } + ) + } + } + } + } + } + .padding(16) + } + .background(Color.primary50) + .navigationTitle("정산 상세") + .navigationBarTitleDisplayMode(.inline) + } +} + +#Preview { + let calendar = Calendar.current + let today = Date() + let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! + let twoDaysAgo = calendar.date(byAdding: .day, value: -2, to: today)! + + return NavigationView { + SettlementDetailView( + store: Store( + initialState: SettlementDetailFeature.State( + memberDetails: [ + MemberSettlementDetail( + id: "1", + memberId: "user1", + memberName: "홍석현", + netBalance: 53227, + totalPaid: 280000, + totalOwe: 226773, + paidExpenses: [ + ExpenseDetail(id: "1", title: "호텔", amount: 120000, shareAmount: 40000, participantCount: 3, expenseDate: twoDaysAgo), + ExpenseDetail(id: "2", title: "저녁식사", amount: 60000, shareAmount: 30000, participantCount: 2, expenseDate: yesterday), + ExpenseDetail(id: "3", title: "커피", amount: 100000, shareAmount: 33333, participantCount: 3, expenseDate: today) + ], + sharedExpenses: [ + ExpenseDetail(id: "1", title: "호텔", amount: 120000, shareAmount: 40000, participantCount: 3, expenseDate: twoDaysAgo), + ExpenseDetail(id: "2", title: "저녁식사", amount: 60000, shareAmount: 30000, participantCount: 2, expenseDate: yesterday), + ExpenseDetail(id: "3", title: "커피", amount: 100000, shareAmount: 33333, participantCount: 3, expenseDate: today) + ] + ), + MemberSettlementDetail( + id: "2", + memberId: "user2", + memberName: "김철수", + netBalance: -52000, + totalPaid: 0, + totalOwe: 52000, + paidExpenses: [], + sharedExpenses: [ + ExpenseDetail(id: "1", title: "호텔", amount: 120000, shareAmount: 40000, participantCount: 3, expenseDate: twoDaysAgo), + ExpenseDetail(id: "3", title: "커피", amount: 100000, shareAmount: 33333, participantCount: 3, expenseDate: today) + ] + ) + ], + currentUserId: "user1" + ) + ) { + SettlementDetailFeature() + } + ) + } +} \ No newline at end of file diff --git a/Features/SettlementDetail/Tests/SettlementDetailFeatureTests.swift b/Features/SettlementDetail/Tests/SettlementDetailFeatureTests.swift new file mode 100644 index 0000000..eaed7ed --- /dev/null +++ b/Features/SettlementDetail/Tests/SettlementDetailFeatureTests.swift @@ -0,0 +1,7 @@ +import Testing +@testable import SettlementDetailFeature + +@Suite("SettlementDetailFeature Tests") +struct SettlementDetailFeatureTests { + +} diff --git a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/TargetDependency+Modules.swift b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/TargetDependency+Modules.swift index 55e4a09..d77decc 100644 --- a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/TargetDependency+Modules.swift +++ b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/TargetDependency+Modules.swift @@ -16,12 +16,13 @@ public extension TargetDependency { public extension TargetDependency.Features { // MARK: - Feature Modules - static let SaveExpense: TargetDependency = .project(target: "SaveExpenseFeature", path: .relativeToRoot("Features/SaveExpense")) static let ExpenseList: TargetDependency = .project(target: "ExpenseListFeature", path: .relativeToRoot("Features/ExpenseList")) static let Login: TargetDependency = .project(target: "LoginFeature", path: .relativeToRoot("Features/Login")) static let Main: TargetDependency = .project(target: "MainFeature", path: .relativeToRoot("Features/Main")) static let Profile: TargetDependency = .project(target: "ProfileFeature", path: .relativeToRoot("Features/Profile")) + static let SaveExpense: TargetDependency = .project(target: "SaveExpenseFeature", path: .relativeToRoot("Features/SaveExpense")) static let Settlement: TargetDependency = .project(target: "SettlementFeature", path: .relativeToRoot("Features/Settlement")) + static let SettlementDetail: TargetDependency = .project(target: "SettlementDetailFeature", path: .relativeToRoot("Features/SettlementDetail")) static let SettlementResult: TargetDependency = .project(target: "SettlementResultFeature", path: .relativeToRoot("Features/SettlementResult")) static let Splash: TargetDependency = .project(target: "SplashFeature", path: .relativeToRoot("Features/Splash")) static let Travel: TargetDependency = .project(target: "TravelFeature", path: .relativeToRoot("Features/Travel")) @@ -30,12 +31,13 @@ public extension TargetDependency.Features { // MARK: - Feature Names public enum FeatureName: String { - case SaveExpense case ExpenseList case Login case Main case Profile + case SaveExpense case Settlement + case SettlementDetail case SettlementResult case Splash case Travel From 2558fda6504d3b7175c9b27aed8e8f9f81b76f90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Thu, 11 Dec 2025 10:46:51 +0900 Subject: [PATCH 03/11] =?UTF-8?q?chore:=20Makefile=EC=97=90=20generate=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=EC=96=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 60 +++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 233d143..66c59b7 100644 --- a/Makefile +++ b/Makefile @@ -7,25 +7,67 @@ TUIST_PATH := $(shell command -v tuist 2>/dev/null || find /usr/local/bin /opt/h # Create a new feature module feature: - @echo "🚀 새로운 Feature를 생성합니다..." - @echo "💡 Tip: 'Feature' 접미사는 자동으로 추가됩니다 (예: main → MainFeature)" + @echo "=========================================" + @echo "Feature 생성" + @echo "=========================================" + @echo "" + @echo "💡 팁:" + @echo " - 공백이나 하이픈을 사용하면 자동으로 카멜케이스로 변환됩니다" + @echo " - 예: 'settlement detail' → 'SettlementDetail'" + @echo " - 취소하려면 빈 값을 입력하거나 Ctrl+C를 누르세요" + @echo "" @if [ -z "$(TUIST_PATH)" ]; then \ echo "❌ Tuist를 찾을 수 없습니다."; \ echo "다음 명령어로 설치해주세요:"; \ echo "curl -Ls https://install.tuist.io | bash"; \ exit 1; \ fi - @read -p "Feature 이름을 입력하세요: " input && \ - name=$$(echo "$$input" | sed -E 's/[Ff]eature$$//' | awk '{print toupper(substr($$0,1,1)) tolower(substr($$0,2))}') && \ - echo "📝 생성할 Feature: $${name}Feature" && \ + @read -p "Feature 이름을 입력하세요: " input; \ + if [ -z "$$input" ]; then \ + echo ""; \ + echo "❌ 취소되었습니다."; \ + exit 0; \ + fi; \ + name=$$(echo "$$input" | sed -E 's/[-_[:space:]]+/ /g' | awk '{for(i=1;i<=NF;i++) $$i=toupper(substr($$i,1,1)) substr($$i,2)}1' | sed 's/ //g'); \ + if [ "$$input" != "$$name" ]; then \ + echo "✨ 자동 변환: '$$input' → '$$name'"; \ + fi; \ + if ! echo "$$name" | grep -qE '^[A-Za-z][A-Za-z0-9]*$$'; then \ + echo ""; \ + echo "❌ 잘못된 Feature 이름입니다: $$name"; \ + echo " 알파벳으로 시작하고 알파벳과 숫자만 사용할 수 있습니다."; \ + exit 1; \ + fi; \ + echo ""; \ + echo "📦 생성할 Feature: $$name"; \ + echo " 경로: Features/$$name/"; \ + echo ""; \ + read -p "계속하시겠습니까? (y/N): " confirm; \ + if [ "$$confirm" != "y" ] && [ "$$confirm" != "Y" ]; then \ + echo ""; \ + echo "❌ 취소되었습니다."; \ + exit 0; \ + fi; \ + echo ""; \ + echo "🚀 Feature를 생성하는 중..."; \ + echo ""; \ $(TUIST_PATH) scaffold feature --name $$name && \ ./Scripts/update-modules.sh && \ - echo "✅ Feature '$${name}'이 성공적으로 생성되었습니다!" && \ - echo "📦 TargetDependency+Modules.swift가 자동으로 업데이트되었습니다!" && \ + echo "" && \ + echo "=========================================" && \ + echo "✅ Feature '$$name'가 성공적으로 생성되었습니다!" && \ + echo "=========================================" && \ + echo "" && \ + echo "📂 생성된 파일:" && \ + echo " - Features/$$name/Project.swift" && \ + echo " - Features/$$name/Sources/$${name}View.swift" && \ + echo " - Features/$$name/Tests/$${name}FeatureTests.swift" && \ + echo " - Features/$$name/Demo/" && \ echo "" && \ echo "다음 단계:" && \ - echo "1. 'make generate'로 Xcode 프로젝트 업데이트" && \ - echo "2. '$${name}FeatureDemo' 스킴을 선택해서 테스트" + echo " 1. make generate" && \ + echo " 2. Xcode에서 $$name 작업 시작" && \ + echo "" # Generate Xcode project generate: From a851a50aa800b005678237a44092a5169fd37e09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Thu, 11 Dec 2025 10:47:05 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20SettlementDetailFeature=20?= =?UTF-8?q?=EB=A6=AC=EB=93=80=EC=84=9C=20=EC=B4=88=EA=B8=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Features/SettlementDetail/Sources/SettlementDetailFeature.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Features/SettlementDetail/Sources/SettlementDetailFeature.swift b/Features/SettlementDetail/Sources/SettlementDetailFeature.swift index 34c8ca6..fa96159 100644 --- a/Features/SettlementDetail/Sources/SettlementDetailFeature.swift +++ b/Features/SettlementDetail/Sources/SettlementDetailFeature.swift @@ -2,7 +2,7 @@ // SettlementDetailFeature.swift // SettlementDetailFeature // -// Created by SseuDam on 2025. +// Created by 홍석현 on 2025. // import Foundation From 4952df61820192c9b9f70e6a2cf8cb09f1a2b426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Thu, 11 Dec 2025 11:36:53 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20=EC=A0=95=EC=82=B0=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EB=AA=A8=EB=8D=B8=20=ED=99=95=EC=9E=A5=20?= =?UTF-8?q?=EB=B0=8F=20UseCase=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CalculateSettlementUseCaseProtocol.swift | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift diff --git a/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift b/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift deleted file mode 100644 index 19a9135..0000000 --- a/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// CalculateSettlementUseCaseProtocol.swift -// Domain -// -// Created by 홍석현 on 12/10/25. -// - -import Foundation - -public protocol CalculateSettlementUseCaseProtocol { - func execute( - expenses: [Expense], - members: [TravelMember], - currentUserId: String? - ) -> SettlementCalculation -} From 1bdacbf380fb34ed9437abfae55773c9f333d0e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Thu, 11 Dec 2025 11:37:01 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=EB=B3=84=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A0=95=EC=82=B0=20=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settlement/SettlementCalculation.swift | 8 +- .../CalculateSettlementUseCase.swift | 142 +++++++++++++----- .../Sources/SettlementResultFeature.swift | 4 +- 3 files changed, 113 insertions(+), 41 deletions(-) diff --git a/Domain/Sources/Entity/Settlement/SettlementCalculation.swift b/Domain/Sources/Entity/Settlement/SettlementCalculation.swift index 9509f40..983431e 100644 --- a/Domain/Sources/Entity/Settlement/SettlementCalculation.swift +++ b/Domain/Sources/Entity/Settlement/SettlementCalculation.swift @@ -14,9 +14,9 @@ public struct SettlementCalculation: Equatable { public let totalPersonCount: Int // 인원수 public let averagePerPerson: Double // 1인 평균 지출 public let myNetBalance: Double // 내 순 차액 (Pay - Owe) - public let memberBalances: [String: Double] // 각 멤버의 순 차액 public let paymentsToMake: [PaymentInfo] // 지급 예정 금액 public let paymentsToReceive: [PaymentInfo] // 수령 예정 금액 + public let memberDetails: [MemberSettlementDetail] // 멤버별 정산 상세 public init( totalExpenseAmount: Double, @@ -24,18 +24,18 @@ public struct SettlementCalculation: Equatable { totalPersonCount: Int, averagePerPerson: Double, myNetBalance: Double, - memberBalances: [String: Double], paymentsToMake: [PaymentInfo], - paymentsToReceive: [PaymentInfo] + paymentsToReceive: [PaymentInfo], + memberDetails: [MemberSettlementDetail] ) { self.totalExpenseAmount = totalExpenseAmount self.myShareAmount = myShareAmount self.totalPersonCount = totalPersonCount self.averagePerPerson = averagePerPerson self.myNetBalance = myNetBalance - self.memberBalances = memberBalances self.paymentsToMake = paymentsToMake self.paymentsToReceive = paymentsToReceive + self.memberDetails = memberDetails } } diff --git a/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCase.swift b/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCase.swift index 0099b40..e986ec0 100644 --- a/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCase.swift +++ b/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCase.swift @@ -8,19 +8,27 @@ import Foundation import ComposableArchitecture -public struct CalculateSettlementUseCase: CalculateSettlementUseCaseProtocol { +public protocol CalculateSettlementUseCaseProtocol { + func execute( + expenses: [Expense], + members: [TravelMember], + currentUserId: String? + ) -> SettlementCalculation +} +public struct CalculateSettlementUseCase: CalculateSettlementUseCaseProtocol { + public init() {} - + public func execute( expenses: [Expense], members: [TravelMember], currentUserId: String? ) -> SettlementCalculation { - + // 1. 총 지출 금액 let totalExpenseAmount = expenses.reduce(0) { $0 + $1.convertedAmount } - + // 2. 내가 부담해야 할 금액 (내가 참여한 지출들의 분담금 합계) let myShareAmount: Double if let userId = currentUserId { @@ -36,40 +44,40 @@ public struct CalculateSettlementUseCase: CalculateSettlementUseCaseProtocol { } else { myShareAmount = 0 } - + // 3. 인원수 let totalPersonCount = members.count - + // 4. 1인 평균 지출 let averagePerPerson = totalPersonCount > 0 ? totalExpenseAmount / Double(totalPersonCount) : 0 - + // 5. 각 멤버의 순 차액 계산 (Net Balance = Pay - Owe) var memberBalances: [String: Double] = [:] - + // 모든 멤버 초기화 for member in members { memberBalances[member.id] = 0.0 } - + // 각 지출에 대해 계산 for expense in expenses { let participantCount = Double(expense.participants.count) guard participantCount > 0 else { continue } - + let amountPerPerson = expense.convertedAmount / participantCount - + // 결제자는 전체 금액을 지불한 것으로 (+) memberBalances[expense.payerId, default: 0] += expense.convertedAmount - + // 참여자들은 각자 분담금을 빚진 것으로 (-) for participant in expense.participants { memberBalances[participant.id, default: 0] -= amountPerPerson } } - + // 6. 내 순 차액 let myNetBalance = currentUserId.flatMap { memberBalances[$0] } ?? 0 - + // 7. 지급 예정 금액 계산 let paymentsToMake = calculatePaymentsToMake( myBalance: myNetBalance, @@ -77,7 +85,7 @@ public struct CalculateSettlementUseCase: CalculateSettlementUseCaseProtocol { members: members, currentUserId: currentUserId ) - + // 8. 수령 예정 금액 계산 let paymentsToReceive = calculatePaymentsToReceive( myBalance: myNetBalance, @@ -85,21 +93,28 @@ public struct CalculateSettlementUseCase: CalculateSettlementUseCaseProtocol { members: members, currentUserId: currentUserId ) - + + // 9. 멤버별 정산 상세 계산 + let memberDetails = calculateMemberDetails( + expenses: expenses, + members: members, + memberBalances: memberBalances + ) + return SettlementCalculation( totalExpenseAmount: totalExpenseAmount, myShareAmount: myShareAmount, totalPersonCount: totalPersonCount, averagePerPerson: averagePerPerson, myNetBalance: myNetBalance, - memberBalances: memberBalances, paymentsToMake: paymentsToMake, - paymentsToReceive: paymentsToReceive + paymentsToReceive: paymentsToReceive, + memberDetails: memberDetails ) } - + // MARK: - Private Helper Methods - + // 지급 예정 금액 (내가 빚진 사람들에게 갚아야 할 돈) private func calculatePaymentsToMake( myBalance: Double, @@ -109,20 +124,20 @@ public struct CalculateSettlementUseCase: CalculateSettlementUseCaseProtocol { ) -> [PaymentInfo] { guard currentUserId != nil else { return [] } guard myBalance < 0 else { return [] } // 내가 받을 돈이 있으면 지급할 것이 없음 - + // 양수 잔액을 가진 멤버들 (받을 돈이 있는 사람들) let creditors = memberBalances .filter { $0.value > 0 } .sorted { $0.value > $1.value } // 많이 받을 사람부터 - + var payments: [PaymentInfo] = [] var remainingDebt = abs(myBalance) - + for (memberId, creditAmount) in creditors { guard remainingDebt > 0.01 else { break } // 소수점 오차 고려 - + let paymentAmount = min(remainingDebt, creditAmount) - + if let member = members.first(where: { $0.id == memberId }) { payments.append(PaymentInfo( id: UUID().uuidString, @@ -133,10 +148,10 @@ public struct CalculateSettlementUseCase: CalculateSettlementUseCaseProtocol { remainingDebt -= paymentAmount } } - + return payments } - + // 수령 예정 금액 (나에게 빚진 사람들로부터 받을 돈) private func calculatePaymentsToReceive( myBalance: Double, @@ -146,20 +161,20 @@ public struct CalculateSettlementUseCase: CalculateSettlementUseCaseProtocol { ) -> [PaymentInfo] { guard currentUserId != nil else { return [] } guard myBalance > 0 else { return [] } // 내가 빚진 돈이 있으면 받을 것이 없음 - + // 음수 잔액을 가진 멤버들 (빚진 사람들) let debtors = memberBalances .filter { $0.value < 0 } .sorted { $0.value < $1.value } // 많이 빚진 사람부터 - + var receipts: [PaymentInfo] = [] var remainingCredit = myBalance - + for (memberId, debtAmount) in debtors { guard remainingCredit > 0.01 else { break } // 소수점 오차 고려 - + let receiptAmount = min(remainingCredit, abs(debtAmount)) - + if let member = members.first(where: { $0.id == memberId }) { receipts.append(PaymentInfo( id: UUID().uuidString, @@ -170,17 +185,74 @@ public struct CalculateSettlementUseCase: CalculateSettlementUseCaseProtocol { remainingCredit -= receiptAmount } } - + return receipts } + + // 멤버별 정산 상세 계산 + // 시간 복잡도: O(E * P + M) - E: 지출 수, P: 평균 참여자 수, M: 멤버 수 + private func calculateMemberDetails( + expenses: [Expense], + members: [TravelMember], + memberBalances: [String: Double] + ) -> [MemberSettlementDetail] { + // 1. 멤버별로 결제한 지출과 참여한 지출을 그룹화 + // O(E * P) - 지출을 한 번만 순회 + var memberPaidExpenses: [String: [ExpenseDetail]] = [:] + var memberSharedExpenses: [String: [ExpenseDetail]] = [:] + + for expense in expenses { + let participantCount = expense.participants.count + let shareAmount = participantCount > 0 ? expense.convertedAmount / Double(participantCount) : 0 + + let expenseDetail = ExpenseDetail( + id: expense.id, + title: expense.title, + amount: expense.convertedAmount, + shareAmount: shareAmount, + participantCount: participantCount, + expenseDate: expense.expenseDate + ) + + // 결제자에게 추가 + memberPaidExpenses[expense.payerId, default: []].append(expenseDetail) + + // 참여자들에게 추가 + for participant in expense.participants { + memberSharedExpenses[participant.id, default: []].append(expenseDetail) + } + } + + // 2. 각 멤버의 정산 상세 생성 + // O(M) + return members.map { member in + let paidExpenses = memberPaidExpenses[member.id] ?? [] + let sharedExpenses = memberSharedExpenses[member.id] ?? [] + + let totalPaid = paidExpenses.reduce(0) { $0 + $1.amount } + let totalOwe = sharedExpenses.reduce(0) { $0 + $1.shareAmount } + let netBalance = memberBalances[member.id] ?? 0 + + return MemberSettlementDetail( + id: member.id, + memberId: member.id, + memberName: member.name, + netBalance: netBalance, + totalPaid: totalPaid, + totalOwe: totalOwe, + paidExpenses: paidExpenses, + sharedExpenses: sharedExpenses + ) + } + } } // MARK: - DependencyKey public enum CalculateSettlementUseCaseDependencyKey: DependencyKey { public static var liveValue: any CalculateSettlementUseCaseProtocol = CalculateSettlementUseCase() - + public static var testValue: any CalculateSettlementUseCaseProtocol = CalculateSettlementUseCase() - + public static var previewValue: any CalculateSettlementUseCaseProtocol = CalculateSettlementUseCase() } diff --git a/Features/SettlementResult/Sources/SettlementResultFeature.swift b/Features/SettlementResult/Sources/SettlementResultFeature.swift index 2543f2e..8ca5885 100644 --- a/Features/SettlementResult/Sources/SettlementResultFeature.swift +++ b/Features/SettlementResult/Sources/SettlementResultFeature.swift @@ -32,9 +32,9 @@ public struct SettlementResultFeature { totalPersonCount: 0, averagePerPerson: 0, myNetBalance: 0, - memberBalances: [:], paymentsToMake: [], - paymentsToReceive: [] + paymentsToReceive: [], + memberDetails: [] ) // 총 지출 금액 From ad05819a33594d355e0d917a9ad1d6fc3a1c2996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Thu, 11 Dec 2025 11:37:07 +0900 Subject: [PATCH 07/11] =?UTF-8?q?test:=20=ED=99=95=EC=9E=A5=EB=90=9C=20?= =?UTF-8?q?=EC=A0=95=EC=82=B0=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CalculateSettlementUseCaseTests.swift | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/Domain/Tests/UseCase/Settlement/CalculateSettlementUseCaseTests.swift b/Domain/Tests/UseCase/Settlement/CalculateSettlementUseCaseTests.swift index 7d5dea1..0ca43e2 100644 --- a/Domain/Tests/UseCase/Settlement/CalculateSettlementUseCaseTests.swift +++ b/Domain/Tests/UseCase/Settlement/CalculateSettlementUseCaseTests.swift @@ -43,6 +43,16 @@ struct CalculateSettlementUseCaseTests { #expect(result.myNetBalance == 0) #expect(result.paymentsToMake.isEmpty) #expect(result.paymentsToReceive.isEmpty) + + // memberDetails 검증 + #expect(result.memberDetails.count == 3) + for detail in result.memberDetails { + #expect(detail.totalPaid == 0) + #expect(detail.totalOwe == 0) + #expect(detail.netBalance == 0) + #expect(detail.paidExpenses.isEmpty) + #expect(detail.sharedExpenses.isEmpty) + } } @Test("총 지출 금액 계산 - 단일 지출") @@ -410,6 +420,33 @@ struct CalculateSettlementUseCaseTests { // 줄 돈 없음 #expect(result.paymentsToMake.isEmpty) + + // memberDetails 검증 + #expect(result.memberDetails.count == 3) + + // 나 (홍석현) - 모든 지출을 결제함 + let myDetail = try #require(result.memberDetails.first(where: { $0.memberId == "user1" })) + #expect(Int(myDetail.totalPaid) == 90_000) + #expect(Int(myDetail.totalOwe) == 개인당지불해야할돈) + #expect(Int(myDetail.netBalance) == 90_000 - 개인당지불해야할돈) + #expect(myDetail.paidExpenses.count == 2) // 호텔, 식사 + #expect(myDetail.sharedExpenses.count == 2) // 모든 지출에 참여 + + // 철수 - 아무것도 결제 안함 + let 철수Detail = try #require(result.memberDetails.first(where: { $0.memberId == "user2" })) + #expect(철수Detail.totalPaid == 0) + #expect(Int(철수Detail.totalOwe) == 개인당지불해야할돈) + #expect(Int(철수Detail.netBalance) == -개인당지불해야할돈) + #expect(철수Detail.paidExpenses.isEmpty) + #expect(철수Detail.sharedExpenses.count == 2) + + // 영희 - 아무것도 결제 안함 + let 영희Detail = try #require(result.memberDetails.first(where: { $0.memberId == "user3" })) + #expect(영희Detail.totalPaid == 0) + #expect(Int(영희Detail.totalOwe) == 개인당지불해야할돈) + #expect(Int(영희Detail.netBalance) == -개인당지불해야할돈) + #expect(영희Detail.paidExpenses.isEmpty) + #expect(영희Detail.sharedExpenses.count == 2) } @Test("일부 지출에만 참여 - 참여하지 않은 지출은 제외") @@ -467,6 +504,33 @@ struct CalculateSettlementUseCaseTests { #expect(Int(영희에게받을돈) == 60_000) #expect(result.paymentsToMake.isEmpty) + + // memberDetails 검증 + #expect(result.memberDetails.count == 3) + + // 나 (홍석현) - 호텔만 결제, 호텔만 참여 + let myDetail = try #require(result.memberDetails.first(where: { $0.memberId == "user1" })) + #expect(Int(myDetail.totalPaid) == 90_000) + #expect(Int(myDetail.totalOwe) == 30_000) // 90,000 / 3 + #expect(Int(myDetail.netBalance) == 60_000) + #expect(myDetail.paidExpenses.count == 1) // 호텔만 + #expect(myDetail.sharedExpenses.count == 1) // 호텔만 참여 + + // 철수 - 술집 결제, 호텔+술집 참여 + let 철수Detail = try #require(result.memberDetails.first(where: { $0.memberId == "user2" })) + #expect(Int(철수Detail.totalPaid) == 60_000) + #expect(Int(철수Detail.totalOwe) == 60_000) // 30,000 + 30,000 + #expect(Int(철수Detail.netBalance) == 0) + #expect(철수Detail.paidExpenses.count == 1) // 술집만 + #expect(철수Detail.sharedExpenses.count == 2) // 호텔+술집 + + // 영희 - 아무것도 결제 안함, 호텔+술집 참여 + let 영희Detail = try #require(result.memberDetails.first(where: { $0.memberId == "user3" })) + #expect(영희Detail.totalPaid == 0) + #expect(Int(영희Detail.totalOwe) == 60_000) // 30,000 + 30,000 + #expect(Int(영희Detail.netBalance) == -60_000) + #expect(영희Detail.paidExpenses.isEmpty) + #expect(영희Detail.sharedExpenses.count == 2) // 호텔+술집 } @Test("결제자가 여러 번 바뀌는 복잡한 시나리오") From 9e31a8e52a8b3e196c3087dbe247251d308143a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Thu, 11 Dec 2025 14:15:50 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20SettlementDetailView=20=EB=B0=8F?= =?UTF-8?q?=20=ED=95=98=EC=9C=84=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/ExpenseBreakdownSection.swift | 1 + .../Sources/Components/MemberDetailCard.swift | 16 +++++++++++++++- .../Sources/SettlementDetailView.swift | 5 +++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift b/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift index 692b22f..ba8b287 100644 --- a/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift +++ b/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift @@ -7,6 +7,7 @@ import SwiftUI import DesignSystem +import Domain struct ExpenseBreakdownSection: View { let title: String diff --git a/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift b/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift index 1896f84..b9870d3 100644 --- a/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift +++ b/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift @@ -7,6 +7,7 @@ import SwiftUI import DesignSystem +import Domain struct MemberDetailCard: View { let detail: MemberSettlementDetail @@ -17,7 +18,11 @@ struct MemberDetailCard: View { var body: some View { VStack(spacing: 0) { // 헤더 (이름, 순 차액, 펼치기 버튼) - Button(action: onToggle) { + Button(action: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + onToggle() + } + }) { HStack(spacing: 12) { // 프로필 아이콘 Image(asset: .profile) @@ -47,6 +52,7 @@ struct MemberDetailCard: View { Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.gray7) + .rotationEffect(.degrees(isExpanded ? 180 : 0)) } .padding(16) .contentShape(Rectangle()) @@ -66,6 +72,8 @@ struct MemberDetailCard: View { expenses: detail.paidExpenses, showEmpty: true ) + .opacity(isExpanded ? 1 : 0) + .scaleEffect(isExpanded ? 1 : 0.95, anchor: .top) // 부담 금액 ExpenseBreakdownSection( @@ -74,9 +82,15 @@ struct MemberDetailCard: View { expenses: detail.sharedExpenses, showEmpty: false ) + .opacity(isExpanded ? 1 : 0) + .scaleEffect(isExpanded ? 1 : 0.95, anchor: .top) } .padding(.horizontal, 16) .padding(.bottom, 16) + .transition(.asymmetric( + insertion: .scale(scale: 0.95, anchor: .top).combined(with: .opacity), + removal: .scale(scale: 0.95, anchor: .top).combined(with: .opacity) + )) } } .background(Color.white) diff --git a/Features/SettlementDetail/Sources/SettlementDetailView.swift b/Features/SettlementDetail/Sources/SettlementDetailView.swift index fb64054..3df12ec 100644 --- a/Features/SettlementDetail/Sources/SettlementDetailView.swift +++ b/Features/SettlementDetail/Sources/SettlementDetailView.swift @@ -8,9 +8,10 @@ import SwiftUI import DesignSystem import ComposableArchitecture +import Domain public struct SettlementDetailView: View { - @Bindable var store: StoreOf + private let store: StoreOf public init(store: StoreOf) { self.store = store @@ -117,4 +118,4 @@ public struct SettlementDetailView: View { } ) } -} \ No newline at end of file +} From c0cce934e34aedd0ccf2963d40e4b4861f0d2bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Thu, 11 Dec 2025 14:16:05 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20SettlementResult=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Features/Settlement/Project.swift | 3 +- Features/SettlementResult/Project.swift | 3 +- .../Sources/SettlementResultFeature.swift | 16 ++++++++ .../Sources/SettlementResultView.swift | 38 ++++++++++++++++--- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/Features/Settlement/Project.swift b/Features/Settlement/Project.swift index 057fb2a..0e0c87a 100644 --- a/Features/Settlement/Project.swift +++ b/Features/Settlement/Project.swift @@ -6,8 +6,7 @@ let project = Project.makeFeature( dependencies: [ .Features.ExpenseList, .Features.SaveExpense, - .Features.SettlementResult, - .Features.SettlementDetail + .Features.SettlementResult ], hasTests: true ) diff --git a/Features/SettlementResult/Project.swift b/Features/SettlementResult/Project.swift index b96d4d1..46cdbbf 100644 --- a/Features/SettlementResult/Project.swift +++ b/Features/SettlementResult/Project.swift @@ -4,8 +4,7 @@ import SseuDamPlugin let project = Project.makeFeature( name: .SettlementResult, dependencies: [ - .Domain, - .DesignSystem + .Features.SettlementDetail ], hasTests: true ) diff --git a/Features/SettlementResult/Sources/SettlementResultFeature.swift b/Features/SettlementResult/Sources/SettlementResultFeature.swift index 8ca5885..3ef4b58 100644 --- a/Features/SettlementResult/Sources/SettlementResultFeature.swift +++ b/Features/SettlementResult/Sources/SettlementResultFeature.swift @@ -8,6 +8,7 @@ import Foundation import Domain import ComposableArchitecture +import SettlementDetailFeature @Reducer public struct SettlementResultFeature { @@ -24,6 +25,7 @@ public struct SettlementResultFeature { public var currentUserId: String? @Presents public var alert: AlertState? + @Presents public var settlementDetail: SettlementDetailFeature.State? // 정산 계산 결과 public var settlementCalculation: SettlementCalculation = SettlementCalculation( @@ -83,11 +85,13 @@ public struct SettlementResultFeature { public enum ViewAction { case onAppear case backButtonTapped + case detailButtonTapped } @CasePathable public enum ScopeAction { case alert(PresentationAction) + case settlementDetail(PresentationAction) } @CasePathable @@ -116,10 +120,22 @@ public struct SettlementResultFeature { case .view(.backButtonTapped): return .none + case .view(.detailButtonTapped): + // 상세보기 sheet 열기 + guard let currentUserId = state.currentUserId else { return .none } + state.settlementDetail = SettlementDetailFeature.State( + memberDetails: state.settlementCalculation.memberDetails, + currentUserId: currentUserId + ) + return .none + case .scope, .binding: return .none } } .ifLet(\.$alert, action: \.scope.alert) + .ifLet(\.$settlementDetail, action: \.scope.settlementDetail) { + SettlementDetailFeature() + } } } diff --git a/Features/SettlementResult/Sources/SettlementResultView.swift b/Features/SettlementResult/Sources/SettlementResultView.swift index 5aba462..000426d 100644 --- a/Features/SettlementResult/Sources/SettlementResultView.swift +++ b/Features/SettlementResult/Sources/SettlementResultView.swift @@ -8,14 +8,16 @@ import SwiftUI import DesignSystem import ComposableArchitecture +import SettlementDetailFeature +@ViewAction(for: SettlementResultFeature.self) public struct SettlementResultView: View { - @Bindable var store: StoreOf - + @Bindable public var store: StoreOf + public init(store: StoreOf) { self.store = store } - + public var body: some View { VStack(spacing: 0) { // 헤더 (총 지출, 통계) @@ -24,7 +26,7 @@ public struct SettlementResultView: View { myExpenseAmount: store.myExpenseAmount, totalPersonCount: store.totalPersonCount ) - + if !store.paymentsToMake.isEmpty || !store.paymentsToReceive.isEmpty { // 지급/수령 예정 금액 섹션 ScrollView { @@ -50,6 +52,27 @@ public struct SettlementResultView: View { } ) } + + // 상세보기 버튼 + Button { + send(.detailButtonTapped) + } label: { + HStack { + Text("멤버별 정산 상세보기") + .font(.app(.body, weight: .semibold)) + .foregroundStyle(Color.primary500) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.primary500) + } + .padding(16) + .background(Color.white) + .cornerRadius(12) + } + .padding(.top, 8) } } } else { @@ -67,9 +90,14 @@ public struct SettlementResultView: View { .background(Color.primary50) .scrollIndicators(.hidden) .onAppear { - store.send(.view(.onAppear)) + send(.onAppear) } .alert($store.scope(state: \.alert, action: \.scope.alert)) + .sheet(item: $store.scope(state: \.settlementDetail, action: \.scope.settlementDetail)) { store in + NavigationView { + SettlementDetailView(store: store) + } + } } } From a9a1597eda966f89ae4ef2f83e67290754757242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Thu, 11 Dec 2025 14:20:50 +0900 Subject: [PATCH 10/11] =?UTF-8?q?style:=20=EC=A7=80=EC=B6=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=B6=94=EA=B0=80/?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Features/ExpenseList/Sources/ExpenseListView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Features/ExpenseList/Sources/ExpenseListView.swift b/Features/ExpenseList/Sources/ExpenseListView.swift index ecca162..1d47aa6 100644 --- a/Features/ExpenseList/Sources/ExpenseListView.swift +++ b/Features/ExpenseList/Sources/ExpenseListView.swift @@ -38,9 +38,17 @@ public struct ExpenseListView: View { .onTapGesture { send(.onTapExpense(expense)) } + .transition(.asymmetric( + insertion: .scale(scale: 0.95, anchor: .top) + .combined(with: .opacity) + .combined(with: .move(edge: .top)), + removal: .scale(scale: 0.95, anchor: .top) + .combined(with: .opacity) + )) } } .padding(.vertical, 10) + .animation(.spring(response: 0.35, dampingFraction: 0.75), value: store.currentExpense.count) } .scrollIndicators(.hidden) } else { From 7f9d742e9e8fb222b37dad01d2fa0bbf04821072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Thu, 11 Dec 2025 14:56:28 +0900 Subject: [PATCH 11/11] =?UTF-8?q?chore:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/ExpenseBreakdownSection.swift | 39 ------------ .../Sources/Components/ExpenseRow.swift | 59 +++++++++++++++++++ .../Sources/Components/MemberDetailCard.swift | 2 +- .../Sources/SettlementDetailView.swift | 1 + 4 files changed, 61 insertions(+), 40 deletions(-) create mode 100644 Features/SettlementDetail/Sources/Components/ExpenseRow.swift diff --git a/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift b/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift index ba8b287..6e93bba 100644 --- a/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift +++ b/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift @@ -56,45 +56,6 @@ struct ExpenseBreakdownSection: View { } } -// MARK: - Expense Row -private struct ExpenseRow: View { - let expense: ExpenseDetail - - var body: some View { - HStack(spacing: 8) { - // 지출 제목 + 날짜 - VStack(alignment: .leading, spacing: 4) { - Text(expense.title) - .font(.app(.body, weight: .medium)) - .foregroundStyle(Color.black) - - Text(formatDate(expense.expenseDate)) - .font(.app(.caption2, weight: .medium)) - .foregroundStyle(Color.gray7) - } - - Spacer() - - // 내 부담 금액 - VStack(alignment: .trailing, spacing: 2) { - Text("₩\(Int(expense.shareAmount).formatted())") - .font(.app(.body, weight: .semibold)) - .foregroundStyle(Color.black) - - Text("(전체 ₩\(Int(expense.amount).formatted()) ÷ \(expense.participantCount))") - .font(.app(.caption2, weight: .medium)) - .foregroundStyle(Color.gray7) - } - } - } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy.MM.dd" - return formatter.string(from: date) - } -} - #Preview("지출이 있는 경우") { let calendar = Calendar.current let today = Date() diff --git a/Features/SettlementDetail/Sources/Components/ExpenseRow.swift b/Features/SettlementDetail/Sources/Components/ExpenseRow.swift new file mode 100644 index 0000000..8532043 --- /dev/null +++ b/Features/SettlementDetail/Sources/Components/ExpenseRow.swift @@ -0,0 +1,59 @@ +// +// ExpenseRow.swift +// SettlementDetailFeature +// +// Created by 홍석현 on 12/11/25. +// + +import SwiftUI +import Domain + +struct ExpenseRow: View { + let expense: ExpenseDetail + + var body: some View { + HStack(spacing: 8) { + // 지출 제목 + 날짜 + VStack(alignment: .leading, spacing: 4) { + Text(expense.title) + .font(.app(.body, weight: .medium)) + .foregroundStyle(Color.black) + + Text(formatDate(expense.expenseDate)) + .font(.app(.caption2, weight: .medium)) + .foregroundStyle(Color.gray7) + } + + Spacer() + + // 내 부담 금액 + VStack(alignment: .trailing, spacing: 2) { + Text("₩\(Int(expense.shareAmount).formatted())") + .font(.app(.body, weight: .semibold)) + .foregroundStyle(Color.black) + + Text("(전체 ₩\(Int(expense.amount).formatted()) ÷ \(expense.participantCount))") + .font(.app(.caption2, weight: .medium)) + .foregroundStyle(Color.gray7) + } + } + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy.MM.dd" + return formatter.string(from: date) + } +} + + +#Preview { + ExpenseRow(expense: ExpenseDetail( + id: "3", + title: "커피", + amount: 100000, + shareAmount: 33333, + participantCount: 3, + expenseDate: .now + )) +} diff --git a/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift b/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift index b9870d3..41853ce 100644 --- a/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift +++ b/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift @@ -49,7 +49,7 @@ struct MemberDetailCard: View { } // 펼치기 아이콘 - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + Image(systemName: "chevron.up") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.gray7) .rotationEffect(.degrees(isExpanded ? 180 : 0)) diff --git a/Features/SettlementDetail/Sources/SettlementDetailView.swift b/Features/SettlementDetail/Sources/SettlementDetailView.swift index 3df12ec..c98bc34 100644 --- a/Features/SettlementDetail/Sources/SettlementDetailView.swift +++ b/Features/SettlementDetail/Sources/SettlementDetailView.swift @@ -62,6 +62,7 @@ public struct SettlementDetailView: View { } .padding(16) } + .scrollIndicators(.hidden) .background(Color.primary50) .navigationTitle("정산 상세") .navigationBarTitleDisplayMode(.inline)