diff --git a/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift b/Domain/Sources/Entity/Settlement/SettlementCalculation.swift similarity index 78% rename from Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift rename to Domain/Sources/Entity/Settlement/SettlementCalculation.swift index 80a0fa9c..983431ef 100644 --- a/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCaseProtocol.swift +++ b/Domain/Sources/Entity/Settlement/SettlementCalculation.swift @@ -1,5 +1,5 @@ // -// CalculateSettlementUseCaseProtocol.swift +// SettlementCalculation.swift // Domain // // Created by 홍석현 on 12/10/25. @@ -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 } } @@ -58,11 +58,3 @@ public struct PaymentInfo: Equatable, Identifiable { self.amount = amount } } - -public protocol CalculateSettlementUseCaseProtocol { - func execute( - expenses: [Expense], - members: [TravelMember], - currentUserId: String? - ) -> SettlementCalculation -} diff --git a/Domain/Sources/Entity/Settlement/SettlementDetail.swift b/Domain/Sources/Entity/Settlement/SettlementDetail.swift new file mode 100644 index 00000000..3b862e73 --- /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/CalculateSettlementUseCase.swift b/Domain/Sources/UseCase/Settlement/CalculateSettlementUseCase.swift index 0099b407..e986ec03 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/Domain/Tests/UseCase/Settlement/CalculateSettlementUseCaseTests.swift b/Domain/Tests/UseCase/Settlement/CalculateSettlementUseCaseTests.swift index 7d5dea11..0ca43e27 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("결제자가 여러 번 바뀌는 복잡한 시나리오") diff --git a/Features/ExpenseList/Sources/ExpenseListView.swift b/Features/ExpenseList/Sources/ExpenseListView.swift index ecca1629..1d47aa6e 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 { diff --git a/Features/SettlementDetail/Demo/Resources/LaunchScreen.storyboard b/Features/SettlementDetail/Demo/Resources/LaunchScreen.storyboard new file mode 100644 index 00000000..dc004f91 --- /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 00000000..4671b3fd --- /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 00000000..de645ea4 --- /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 00000000..6e93bba9 --- /dev/null +++ b/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift @@ -0,0 +1,148 @@ +// +// ExpenseBreakdownSection.swift +// SettlementDetailFeature +// +// Created by SseuDam on 2025. +// + +import SwiftUI +import DesignSystem +import Domain + +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) + } +} + +#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/ExpenseRow.swift b/Features/SettlementDetail/Sources/Components/ExpenseRow.swift new file mode 100644 index 00000000..85320439 --- /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 new file mode 100644 index 00000000..41853ceb --- /dev/null +++ b/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift @@ -0,0 +1,184 @@ +// +// MemberDetailCard.swift +// SettlementDetailFeature +// +// Created by SseuDam on 2025. +// + +import SwiftUI +import DesignSystem +import Domain + +struct MemberDetailCard: View { + let detail: MemberSettlementDetail + let isExpanded: Bool + let isCurrentUser: Bool + let onToggle: () -> Void + + var body: some View { + VStack(spacing: 0) { + // 헤더 (이름, 순 차액, 펼치기 버튼) + Button(action: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + 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: "chevron.up") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.gray7) + .rotationEffect(.degrees(isExpanded ? 180 : 0)) + } + .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 + ) + .opacity(isExpanded ? 1 : 0) + .scaleEffect(isExpanded ? 1 : 0.95, anchor: .top) + + // 부담 금액 + ExpenseBreakdownSection( + title: "부담 금액", + totalAmount: detail.totalOwe, + 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) + .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 00000000..fa96159c --- /dev/null +++ b/Features/SettlementDetail/Sources/SettlementDetailFeature.swift @@ -0,0 +1,58 @@ +// +// SettlementDetailFeature.swift +// SettlementDetailFeature +// +// Created by 홍석현 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 00000000..c98bc346 --- /dev/null +++ b/Features/SettlementDetail/Sources/SettlementDetailView.swift @@ -0,0 +1,122 @@ +// +// SettlementDetailView.swift +// SettlementDetailFeature +// +// Created by SseuDam on 2025. +// + +import SwiftUI +import DesignSystem +import ComposableArchitecture +import Domain + +public struct SettlementDetailView: View { + private let 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) + } + .scrollIndicators(.hidden) + .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() + } + ) + } +} diff --git a/Features/SettlementDetail/Tests/SettlementDetailFeatureTests.swift b/Features/SettlementDetail/Tests/SettlementDetailFeatureTests.swift new file mode 100644 index 00000000..eaed7ed1 --- /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/Features/SettlementResult/Project.swift b/Features/SettlementResult/Project.swift index b96d4d11..46cdbbfd 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 2543f2e5..3ef4b585 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( @@ -32,9 +34,9 @@ public struct SettlementResultFeature { totalPersonCount: 0, averagePerPerson: 0, myNetBalance: 0, - memberBalances: [:], paymentsToMake: [], - paymentsToReceive: [] + paymentsToReceive: [], + memberDetails: [] ) // 총 지출 금액 @@ -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 5aba4625..000426d1 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) + } + } } } diff --git a/Makefile b/Makefile index 233d143a..66c59b75 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: diff --git a/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/TargetDependency+Modules.swift b/Tuist/Plugins/SseuDamPlugin/ProjectDescriptionHelpers/TargetDependency+Modules.swift index 55e4a091..d77decc5 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