diff --git a/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/Contents.json b/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/Contents.json index 76ad227..73bc061 100644 --- a/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/Contents.json +++ b/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "expense_emtpy.png", + "filename" : "빈 지갑 1.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "expense_emtpy@2x.png", + "filename" : "빈 지갑 1@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "expense_emtpy@3x.png", + "filename" : "빈 지갑 1@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/expense_emtpy.png b/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/expense_emtpy.png deleted file mode 100644 index ecba6e0..0000000 Binary files a/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/expense_emtpy.png and /dev/null differ diff --git a/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/expense_emtpy@2x.png b/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/expense_emtpy@2x.png deleted file mode 100644 index 97f2605..0000000 Binary files a/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/expense_emtpy@2x.png and /dev/null differ diff --git a/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/expense_emtpy@3x.png b/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/expense_emtpy@3x.png deleted file mode 100644 index c3f95eb..0000000 Binary files a/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/expense_emtpy@3x.png and /dev/null differ diff --git "a/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/\353\271\210 \354\247\200\352\260\221 1.png" "b/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/\353\271\210 \354\247\200\352\260\221 1.png" new file mode 100644 index 0000000..6099bb9 Binary files /dev/null and "b/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/\353\271\210 \354\247\200\352\260\221 1.png" differ diff --git "a/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/\353\271\210 \354\247\200\352\260\221 1@2x.png" "b/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/\353\271\210 \354\247\200\352\260\221 1@2x.png" new file mode 100644 index 0000000..7da5e3e Binary files /dev/null and "b/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/\353\271\210 \354\247\200\352\260\221 1@2x.png" differ diff --git "a/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/\353\271\210 \354\247\200\352\260\221 1@3x.png" "b/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/\353\271\210 \354\247\200\352\260\221 1@3x.png" new file mode 100644 index 0000000..3f323dc Binary files /dev/null and "b/DesignSystem/Resources/Assets.xcassets/Images/expenseEmpty.imageset/\353\271\210 \354\247\200\352\260\221 1@3x.png" differ diff --git a/DesignSystem/Resources/Assets.xcassets/Images/settlementEmpty.imageset/Contents.json b/DesignSystem/Resources/Assets.xcassets/Images/settlementEmpty.imageset/Contents.json new file mode 100644 index 0000000..d99346a --- /dev/null +++ b/DesignSystem/Resources/Assets.xcassets/Images/settlementEmpty.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "지출 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "지출 1@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "지출 1@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git "a/DesignSystem/Resources/Assets.xcassets/Images/settlementEmpty.imageset/\354\247\200\354\266\234 1.png" "b/DesignSystem/Resources/Assets.xcassets/Images/settlementEmpty.imageset/\354\247\200\354\266\234 1.png" new file mode 100644 index 0000000..14fb626 Binary files /dev/null and "b/DesignSystem/Resources/Assets.xcassets/Images/settlementEmpty.imageset/\354\247\200\354\266\234 1.png" differ diff --git "a/DesignSystem/Resources/Assets.xcassets/Images/settlementEmpty.imageset/\354\247\200\354\266\234 1@2x.png" "b/DesignSystem/Resources/Assets.xcassets/Images/settlementEmpty.imageset/\354\247\200\354\266\234 1@2x.png" new file mode 100644 index 0000000..8cfe8af Binary files /dev/null and "b/DesignSystem/Resources/Assets.xcassets/Images/settlementEmpty.imageset/\354\247\200\354\266\234 1@2x.png" differ diff --git "a/DesignSystem/Resources/Assets.xcassets/Images/settlementEmpty.imageset/\354\247\200\354\266\234 1@3x.png" "b/DesignSystem/Resources/Assets.xcassets/Images/settlementEmpty.imageset/\354\247\200\354\266\234 1@3x.png" new file mode 100644 index 0000000..141093f Binary files /dev/null and "b/DesignSystem/Resources/Assets.xcassets/Images/settlementEmpty.imageset/\354\247\200\354\266\234 1@3x.png" differ diff --git a/DesignSystem/Sources/Image/ImageAsset.swift b/DesignSystem/Sources/Image/ImageAsset.swift index 92aa3b0..b7cf0c4 100644 --- a/DesignSystem/Sources/Image/ImageAsset.swift +++ b/DesignSystem/Sources/Image/ImageAsset.swift @@ -29,4 +29,5 @@ public enum ImageAsset: String { case profile case check case person + case settlementEmpty } diff --git a/Domain/Sources/Entity/Expense/Expense.swift b/Domain/Sources/Entity/Expense/Expense.swift index bfbc67e..aef1fd3 100644 --- a/Domain/Sources/Entity/Expense/Expense.swift +++ b/Domain/Sources/Entity/Expense/Expense.swift @@ -67,12 +67,34 @@ extension Expense { // MARK: - Helper extension Expense { + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + public func formatExpenseDate() -> String { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" - dateFormatter.calendar = Calendar(identifier: .gregorian) - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - return dateFormatter.string(from: expenseDate) + Self.dateFormatter.string(from: expenseDate) + } + + public static func formatDate(_ date: Date) -> String { + dateFormatter.string(from: date) + } + + public static func parseDate(_ dateString: String) -> Date? { + dateFormatter.date(from: dateString) + } + + /// convertedAmount를 포맷팅 (소수점 제거) + public func formattedConvertedAmount() -> String { + convertedAmount.formatted(.number.precision(.fractionLength(0))) + } + + /// amount를 포맷팅 (소수점 제거) + public func formattedAmount() -> String { + amount.formatted(.number.precision(.fractionLength(0))) } } diff --git a/Domain/Sources/Utility/CurrencyFormatter.swift b/Domain/Sources/Utility/CurrencyFormatter.swift new file mode 100644 index 0000000..a4fa2a9 --- /dev/null +++ b/Domain/Sources/Utility/CurrencyFormatter.swift @@ -0,0 +1,42 @@ +// +// CurrencyFormatter.swift +// Domain +// +// Created by 홍석현 on 12/15/25. +// + +import Foundation + +public enum CurrencyFormatter { + /// 한국식 통화 포맷 (억/만/원 단위) + public static func formatKoreanCurrency(_ amount: Double) -> String { + let rounded = amount.rounded() + + // Double로 계산하고 포맷팅 + let eokDouble = (rounded / 100_000_000).rounded(.towardZero) // 억 + let remainder1 = rounded.truncatingRemainder(dividingBy: 100_000_000) + let manDouble = (remainder1 / 10_000).rounded(.towardZero) // 만 + let wonDouble = remainder1.truncatingRemainder(dividingBy: 10_000) // 원 + + var result = "" + + if eokDouble > 0 { + result += "\(eokDouble.formatted(.number.precision(.fractionLength(0))))억" + if manDouble > 0 { + result += " \(manDouble.formatted(.number.precision(.fractionLength(0))))만" + } + if wonDouble > 0 { + result += " \(wonDouble.formatted(.number.precision(.fractionLength(0))))원" + } + } else if manDouble > 0 { + result += "\(manDouble.formatted(.number.precision(.fractionLength(0))))만" + if wonDouble > 0 { + result += " \(wonDouble.formatted(.number.precision(.fractionLength(0))))원" + } + } else { + result = "\(wonDouble.formatted(.number.precision(.fractionLength(0))))원" + } + + return result + } +} diff --git a/Features/ExpenseList/Sources/Components/CategoryFilterView.swift b/Features/ExpenseList/Sources/Components/CategoryFilterView.swift new file mode 100644 index 0000000..efaa859 --- /dev/null +++ b/Features/ExpenseList/Sources/Components/CategoryFilterView.swift @@ -0,0 +1,58 @@ +// +// CategoryFilterView.swift +// ExpenseListFeature +// +// Created by 홍석현 on 12/15/25. +// + +import SwiftUI +import DesignSystem +import Domain + +struct CategoryFilterView: View { + @Binding var selectedCategory: ExpenseCategory? + + var body: some View { + HStack { + Spacer() + Menu { + Button { + selectedCategory = nil + } label: { + HStack { + Text("전체") + if selectedCategory == nil { + Image(systemName: "checkmark") + } + } + } + + ForEach(ExpenseCategory.allCases, id: \.self) { category in + Button { + selectedCategory = category + } label: { + HStack { + Text(category.displayName) + if selectedCategory == category { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack(spacing: 4) { + Text(selectedCategory?.displayName ?? "전체") + .font(.app(.caption1, weight: .semibold)) + .foregroundStyle(Color.gray7) + Image(systemName: "chevron.down") + .font(.caption) + .foregroundStyle(Color.gray5) + } + } + } + } +} + +#Preview { + CategoryFilterView(selectedCategory: .constant(nil)) +} diff --git a/Features/ExpenseList/Sources/Components/ExpenseCardView.swift b/Features/ExpenseList/Sources/Components/ExpenseCardView.swift index d25a6fd..4f957c2 100644 --- a/Features/ExpenseList/Sources/Components/ExpenseCardView.swift +++ b/Features/ExpenseList/Sources/Components/ExpenseCardView.swift @@ -31,12 +31,14 @@ public struct ExpenseCardView: View { // 금액 정보 VStack(alignment: .trailing, spacing: 4) { - Text("₩\(Int(expense.convertedAmount).formatted())") + Text("₩\(expense.formattedConvertedAmount())") .font(.app(.title3, weight: .semibold)) + .lineLimit(1) .foregroundStyle(.black) - Text("\(expense.currency) \(expense.amount.formatted())") + Text("\(expense.currency) \(expense.formattedAmount())") .font(.app(.caption1, weight: .medium)) + .lineLimit(1) } } @@ -50,6 +52,7 @@ public struct ExpenseCardView: View { .resizable() .frame(width: 16, height: 16) Text(expense.payer.name) + .lineLimit(1) } // 구분선 (|) diff --git a/Features/ExpenseList/Sources/Components/ExpenseChartView.swift b/Features/ExpenseList/Sources/Components/ExpenseChartView.swift new file mode 100644 index 0000000..a596f19 --- /dev/null +++ b/Features/ExpenseList/Sources/Components/ExpenseChartView.swift @@ -0,0 +1,349 @@ +// +// ExpenseChartView.swift +// ExpenseListFeature +// +// Created by 홍석현 on 12/11/25. +// + +import SwiftUI +import Domain +import DesignSystem +import Charts + +struct ExpenseChartView: View { + private let expense: [Expense] + private let startDate: Date + private let endDate: Date + @Binding var selectedDateRange: ClosedRange? + @Binding var currentPage: Int + + private let barWidth: CGFloat = 18 + @State private var dragStartDateString: String? + + private static let mmddFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "MM.dd" + return formatter + }() + + private var allDayStrings: [String] { + guard let start = Expense.parseDate(Expense.formatDate(startDate)), + let end = Expense.parseDate(Expense.formatDate(endDate)) else { + return [] + } + + var dateStrings: [String] = [] + var current = start + let calendar = Calendar(identifier: .gregorian) + + while current <= end { + dateStrings.append(Expense.formatDate(current)) + guard let next = calendar.date(byAdding: .day, value: 1, to: current) else { break } + current = next + } + + return dateStrings + } + + private var dayChunks: [[String]] { + let days = allDayStrings + return stride(from: 0, to: days.count, by: 7).map { + Array(days[$0..?>, + currentPage: Binding + ) { + self.expense = expense + self.startDate = startDate + self.endDate = endDate + self._selectedDateRange = selectedDateRange + self._currentPage = currentPage + } + + var body: some View { + VStack { + if dayChunks.isEmpty { + ContentUnavailableView("기간이 설정되지 않았습니다.", systemImage: "calendar") + } else { + TabView(selection: $currentPage) { + ForEach(Array(dayChunks.enumerated()), id: \.offset) { index, chunk in + chartView(for: chunk) + .tag(index) + .padding(.bottom, 16) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .overlay(alignment: .bottom) { + if dayChunks.count > 1 { + HStack(spacing: 8) { + ForEach(0.. some View { + Chart { + ForEach(chunk, id: \.self) { dateString in + let total = dailyExpenseMap[dateString] ?? 0 + BarMark( + x: .value("Day", dateString), + y: .value("Total", total), + width: MarkDimension(floatLiteral: barWidth) + ) + .foregroundStyle(barColor(for: dateString)) + .cornerRadius(barWidth / 2) + } + } + .chartYAxis(.hidden) + .chartXAxis { + AxisMarks(values: .automatic) { value in + if let dateStr = value.as(String.self), + let date = Expense.parseDate(dateStr) { + AxisValueLabel(centered: true) { + Text(Self.mmddFormatter.string(from: date)) + .font(.app(.caption2, weight: .medium)) + .foregroundStyle(Color.black) + } + } + } + } + .chartXSelection(value: currentSelectionBinding) + .chartGesture { proxy in + DragGesture(minimumDistance: 0) + .onChanged { value in + handleDragChange(value: value, proxy: proxy) + } + .onEnded { value in + handleDragEnd(value: value, proxy: proxy) + } + } + .frame(height: 94) + } + + // 현재 선택 중인 날짜 (드래그 중일 수도 있음) + private var currentSelectionBinding: Binding { + Binding( + get: { + if let dragStart = dragStartDateString { + return dragStart + } + if let range = selectedDateRange { + return Expense.formatDate(range.lowerBound) + } + return nil + }, + set: { _ in } + ) + } + + private func handleDragChange(value: DragGesture.Value, proxy: ChartProxy) { + let location = value.location + if let dateStr: String = proxy.value(atX: location.x, as: String.self) { + if dragStartDateString == nil { + // 드래그 시작 + dragStartDateString = dateStr + } else { + // 드래그 중 - 범위 업데이트 + updateRangeSelection(from: dragStartDateString!, to: dateStr) + } + } + } + + private func handleDragEnd(value: DragGesture.Value, proxy: ChartProxy) { + let location = value.location + if let endDateStr: String = proxy.value(atX: location.x, as: String.self), + let startDateStr = dragStartDateString { + + let dragDistance = value.translation.width + + // 드래그가 거의 없었으면 단일 선택 (탭) + if abs(dragDistance) < 10 { + // 단일 날짜 선택 + if let date = Expense.parseDate(startDateStr) { + selectedDateRange = date...date + } + } else { + // 범위 선택 + updateRangeSelection(from: startDateStr, to: endDateStr) + } + } + // 드래그 상태 초기화 + dragStartDateString = nil + } + + private func updateRangeSelection(from startStr: String, to endStr: String) { + guard let startDate = Expense.parseDate(startStr), + let endDate = Expense.parseDate(endStr) else { return } + + // 시작과 끝을 정렬 + let lower = min(startDate, endDate) + let upper = max(startDate, endDate) + selectedDateRange = lower...upper + } + + private func barColor(for dateString: String) -> Color { + guard let range = selectedDateRange else { return .primary500 } + guard let date = Expense.parseDate(dateString) else { return .primary500 } + + let calendar = Calendar.current + let dateDay = calendar.startOfDay(for: date) + let rangeStart = calendar.startOfDay(for: range.lowerBound) + let rangeEnd = calendar.startOfDay(for: range.upperBound) + + // 범위 내에 있으면 primary500, 아니면 primary100 + return (dateDay >= rangeStart && dateDay <= rangeEnd) ? .primary500 : .primary100 + } +} + +// MARK: - Previews + +#Preview("기본 - 여러 날짜") { + @Previewable @State var selectedDateRange: ClosedRange? = nil + let calendar = Calendar.current + + let startDate = calendar.date(byAdding: .day, value: -2, to: Date())! + let endDate = Date() + + let expenses = [ + Expense.mock1.withDate(calendar.date(byAdding: .day, value: -2, to: Date())!), + Expense.mock2.withDate(calendar.date(byAdding: .day, value: -1, to: Date())!), + Expense.mock3.withDate(Date()) + ] + + ExpenseChartView( + expense: expenses, + startDate: startDate, + endDate: endDate, + selectedDateRange: $selectedDateRange, + currentPage: .constant(0) + ) + .padding() +} + +#Preview("2일 여행") { + @Previewable @State var selectedDateRange: ClosedRange? = nil + let calendar = Calendar.current + + let startDate = calendar.date(byAdding: .day, value: -5, to: Date())! + let endDate = calendar.date(byAdding: .day, value: -1, to: Date())! + + let expenses = [ + Expense.mock1.withDate(startDate), + Expense.mock2.withDate(endDate) + ] + + ExpenseChartView( + expense: expenses, + startDate: startDate, + endDate: endDate, + selectedDateRange: $selectedDateRange, + currentPage: .constant(0) + ) + .padding() +} + +#Preview("긴 여행 - 화면 벗어남") { + @Previewable @State var selectedDateRange: ClosedRange? = nil + let calendar = Calendar.current + + let startDate = calendar.date(byAdding: .day, value: -13, to: Date())! + let endDate = Date() + + let expenses = [ + Expense.mock1.withDate(calendar.date(byAdding: .day, value: -13, to: Date())!), + Expense.mock2.withDate(calendar.date(byAdding: .day, value: -11, to: Date())!), + Expense.mock3.withDate(calendar.date(byAdding: .day, value: -9, to: Date())!), + Expense.mock4.withDate(calendar.date(byAdding: .day, value: -7, to: Date())!), + Expense.mock5.withDate(calendar.date(byAdding: .day, value: -4, to: Date())!), + Expense.mock6.withDate(Date()) + ] + + ExpenseChartView( + expense: expenses, + startDate: startDate, + endDate: endDate, + selectedDateRange: $selectedDateRange, + currentPage: .constant(0) + ) + .padding() +} + +#Preview("빈 데이터 - 지출 없음") { + @Previewable @State var selectedDateRange: ClosedRange? = nil + let calendar = Calendar.current + + let startDate = calendar.date(byAdding: .day, value: -4, to: Date())! + let endDate = Date() + + ExpenseChartView( + expense: [], + startDate: startDate, + endDate: endDate, + selectedDateRange: $selectedDateRange, + currentPage: .constant(0) + ) + .padding() +} + +#Preview("일부 날짜만 지출") { + @Previewable @State var selectedDateRange: ClosedRange? = nil + let calendar = Calendar.current + + let startDate = calendar.date(byAdding: .day, value: -6, to: Date())! + let endDate = Date() + + let expenses = [ + Expense.mock1.withDate(calendar.date(byAdding: .day, value: -6, to: Date())!), + Expense.mock2.withDate(calendar.date(byAdding: .day, value: -3, to: Date())!), + Expense.mock3.withDate(Date()) + ] + + ExpenseChartView( + expense: expenses, + startDate: startDate, + endDate: endDate, + selectedDateRange: $selectedDateRange, + currentPage: .constant(0) + ) + .padding() +} + +// MARK: - Helper Extension +extension Expense { + fileprivate func withDate(_ date: Date) -> Expense { + Expense( + id: id, + title: title, + amount: amount, + currency: currency, + convertedAmount: convertedAmount, + expenseDate: date, + category: category, + payer: payer, + participants: participants + ) + } +} diff --git a/Features/ExpenseList/Sources/Components/SettlementHeaderView.swift b/Features/ExpenseList/Sources/Components/SettlementHeaderView.swift index 9e3ffea..5d02d6b 100644 --- a/Features/ExpenseList/Sources/Components/SettlementHeaderView.swift +++ b/Features/ExpenseList/Sources/Components/SettlementHeaderView.swift @@ -7,26 +7,33 @@ import SwiftUI import DesignSystem +import Domain public struct SettlementHeaderView: View { - let totalAmount: Int + let totalAmount: String let startDate: Date let endDate: Date - let myExpenseAmount: Int - @Binding var selectedDate: Date - + let myExpenseAmount: String + let expenses: [Expense] + @Binding var selectedDateRange: ClosedRange? + @Binding var currentPage: Int + public init( - totalAmount: Int, + totalAmount: String, startDate: Date, endDate: Date, - myExpenseAmount: Int, - selectedDate: Binding + myExpenseAmount: String, + expenses: [Expense], + selectedDateRange: Binding?>, + currentPage: Binding ) { self.totalAmount = totalAmount self.startDate = startDate self.endDate = endDate self.myExpenseAmount = myExpenseAmount - self._selectedDate = selectedDate + self.expenses = expenses + self._selectedDateRange = selectedDateRange + self._currentPage = currentPage } public var body: some View { @@ -34,13 +41,27 @@ public struct SettlementHeaderView: View { VStack(spacing: 8) { // 날짜 선택 (드롭다운 느낌) Menu { + Button { + selectedDateRange = nil + } label: { + HStack { + Text("전체") + if selectedDateRange == nil { + Image(systemName: "checkmark") + } + } + } + ForEach(datesRange, id: \.self) { date in Button { - selectedDate = date + // 단일 날짜 선택 (같은 날짜의 범위) + selectedDateRange = date...date } label: { HStack { Text(dateFormatter.string(from: date)) - if Calendar.current.isDate(selectedDate, inSameDayAs: date) { + if let range = selectedDateRange, + Calendar.current.isDate(range.lowerBound, inSameDayAs: date), + Calendar.current.isDate(range.upperBound, inSameDayAs: date) { Image(systemName: "checkmark") } } @@ -48,7 +69,7 @@ public struct SettlementHeaderView: View { } } label: { HStack(spacing: 4) { - Text(dateFormatter.string(from: selectedDate)) + Text(selectedDateLabel) .font(.app(.body, weight: .medium)) .foregroundStyle(Color.gray7) Image(systemName: "chevron.down") @@ -56,50 +77,52 @@ public struct SettlementHeaderView: View { .foregroundStyle(Color.gray5) } } - - + + // 총 지출 금액 - Text("₩\(totalAmount.formatted())") + Text("₩\(totalAmount)") .font(.app(.title1, weight: .semibold)) .foregroundStyle(.black) + .lineLimit(1) } .padding(.vertical, 12) - - // 하단 정보 (여행 기간 / 내 지출) - HStack { - VStack(alignment: .center, spacing: 8) { - Text("여행 기간") - .font(.app(.caption1, weight: .semibold)) - .foregroundStyle(Color.gray7) - Text("\(dateFormatter.string(from: startDate)) -\n\(dateFormatter.string(from: endDate))") - .font(.app(.title3, weight: .semibold)) - .foregroundStyle(.black) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity, alignment: .center) - VStack(alignment: .center, spacing: 8) { - Text("내 지출") - .font(.app(.caption1, weight: .semibold)) - .foregroundStyle(Color.gray7) - Text("₩\(myExpenseAmount.formatted())") - .font(.app(.title3, weight: .semibold)) - .foregroundStyle(.black) - } - .frame(maxWidth: .infinity, alignment: .center) + // 차트 + ExpenseChartView( + expense: expenses, + startDate: startDate, + endDate: endDate, + selectedDateRange: $selectedDateRange, + currentPage: $currentPage + ) + .padding(.horizontal, 16) + } + } + + private var selectedDateLabel: String { + if let range = selectedDateRange { + let calendar = Calendar.current + // 단일 날짜인지 확인 (시작과 끝이 같은 날) + if calendar.isDate(range.lowerBound, inSameDayAs: range.upperBound) { + // 단일 날짜: "yyyy.MM.dd" + return dateFormatter.string(from: range.lowerBound) + } else { + // 날짜 범위: "yyyy.MM.dd ~ yyyy.MM.dd" + return "\(dateFormatter.string(from: range.lowerBound)) ~ \(dateFormatter.string(from: range.upperBound))" } - .padding(.top, 10) - .padding(.bottom, 10) + } else { + // 전체 선택된 경우: "yyyy.MM.dd ~ yyyy.MM.dd" + return "\(dateFormatter.string(from: startDate)) ~ \(dateFormatter.string(from: endDate))" } } - + private var datesRange: [Date] { var dates: [Date] = [] let calendar = Calendar.current // 시작일의 00:00:00으로 정규화 let start = calendar.startOfDay(for: startDate) let end = calendar.startOfDay(for: endDate) - + var currentDate = start while currentDate <= end { dates.append(currentDate) @@ -111,7 +134,7 @@ public struct SettlementHeaderView: View { } return dates } - + private var dateFormatter: DateFormatter { let formatter = DateFormatter() formatter.dateFormat = "yyyy.MM.dd" @@ -121,10 +144,12 @@ public struct SettlementHeaderView: View { #Preview { SettlementHeaderView( - totalAmount: 255450, - startDate: Date(), - endDate: Date().addingTimeInterval(86400 * 5), - myExpenseAmount: 255450, - selectedDate: .constant(Date()) + totalAmount: "255,450", + startDate: Date().addingTimeInterval(-86400 * 2), + endDate: Date(), + myExpenseAmount: "255,450", + expenses: [Expense.mock1, Expense.mock2, Expense.mock3], + selectedDateRange: .constant(nil), + currentPage: .constant(0) ) } diff --git a/Features/ExpenseList/Sources/ExpenseListFeature.swift b/Features/ExpenseList/Sources/ExpenseListFeature.swift index 8275f41..ddc32bf 100644 --- a/Features/ExpenseList/Sources/ExpenseListFeature.swift +++ b/Features/ExpenseList/Sources/ExpenseListFeature.swift @@ -26,25 +26,31 @@ public struct ExpenseListFeature { public var endDate: Date { return travel?.endDate ?? Date() } - var _selectedDate: Date? = nil - public var selectedDate: Date { - get { - return _selectedDate ?? startDate - } set { - _selectedDate = newValue - } - } + public var selectedDateRange: ClosedRange? = nil + public var currentPage: Int = 0 + public var selectedCategory: ExpenseCategory? = nil public let travelId: String public var isLoading: Bool = false @Presents public var alert: AlertState? public var pendingHighlightExpenseId: String? - public var totalAmount: Int { - Int(currentExpense.reduce(0) { $0 + $1.convertedAmount }) - } - - public var myExpenseAmount: Int { - // 임시로 전체 금액과 동일하게 처리 (나중에 내 지출 필터링 로직 추가 필요) - totalAmount + /// 포맷팅된 총 지출 금액 문자열 + /// 포맷팅된 총 지출 금액 문자열 + public var formattedTotalAmount: String { + // 날짜 범위가 선택되지 않았을 때(전체 기간)는 페이지네이션과 관계없이 전체 지출의 합계 표시 + // 단, 카테고리 필터는 적용해야 함 + let expensesToCalculate: [Expense] + if selectedDateRange == nil { + if let category = selectedCategory { + expensesToCalculate = allExpenses.filter { $0.category == category } + } else { + expensesToCalculate = allExpenses + } + } else { + expensesToCalculate = currentExpense + } + + let total = expensesToCalculate.reduce(0.0) { $0 + $1.convertedAmount } + return total.formatted(.number.precision(.fractionLength(0))) } public init( @@ -117,9 +123,29 @@ public struct ExpenseListFeature { return .none case .delegate: return .none - case .binding(\.selectedDate): - // 로컬 캐시에서 필터링 - filterExpensesByDate(&state, date: state.selectedDate) + case .binding(\.selectedDateRange): + // 날짜 범위 변경 시 필터링 + applyFilters(&state) + + // 선택된 날짜에 맞는 페이지로 이동 + if let range = state.selectedDateRange { + let calendar = Calendar.current + let startDay = calendar.startOfDay(for: state.startDate) + let rangeStartDay = calendar.startOfDay(for: range.lowerBound) + + if let days = calendar.dateComponents([.day], from: startDay, to: rangeStartDay).day { + state.currentPage = days / 7 + } + } + return .none + case .binding(\.currentPage): + // 페이지 변경 시 선택된 날짜 초기화 및 해당 페이지 데이터로 필터링 + state.selectedDateRange = nil + applyFilters(&state) + return .none + case .binding(\.selectedCategory): + // 카테고리 변경 시 필터링 + applyFilters(&state) return .none case .binding: return .none @@ -158,8 +184,8 @@ extension ExpenseListFeature { $0 = expenses } applyExpenseHighlight(&state) - // 현재 선택된 날짜로 필터링 - filterExpensesByDate(&state, date: state.selectedDate) + // 현재 선택된 필터 적용 + applyFilters(&state) state.isLoading = false return .none @@ -197,11 +223,43 @@ extension ExpenseListFeature { } // MARK: - Helper Methods - private func filterExpensesByDate(_ state: inout State, date: Date?) { - guard let date = date else { return } + private func applyFilters(_ state: inout State) { let calendar = Calendar.current + state.currentExpense = state.allExpenses.filter { expense in - calendar.isDate(expense.expenseDate, inSameDayAs: date) + // 날짜 범위 필터링 + if let range = state.selectedDateRange { + let rangeStart = calendar.startOfDay(for: range.lowerBound) + let rangeEnd = calendar.startOfDay(for: range.upperBound) + let expenseDay = calendar.startOfDay(for: expense.expenseDate) + + guard expenseDay >= rangeStart && expenseDay <= rangeEnd else { + return false + } + } else { + // 선택된 날짜가 없으면 현재 페이지(7일)에 해당하는지 확인 + // 페이지 시작일 = 여행 시작일 + (currentPage * 7)일 + if let pageStart = calendar.date(byAdding: .day, value: state.currentPage * 7, to: state.startDate) { + let pageStartDay = calendar.startOfDay(for: pageStart) + // 페이지 끝일 = 시작일 + 6일 + let pageEndDay = calendar.date(byAdding: .day, value: 6, to: pageStartDay) ?? Date() + + let expenseDay = calendar.startOfDay(for: expense.expenseDate) + + guard expenseDay >= pageStartDay && expenseDay <= pageEndDay else { + return false + } + } + } + + // 카테고리 필터링 + if let category = state.selectedCategory { + guard expense.category == category else { + return false + } + } + + return true } } @@ -210,8 +268,9 @@ extension ExpenseListFeature { guard let expense = state.allExpenses.first(where: { $0.id == targetId }) else { return } let calendar = Calendar.current let targetDate = calendar.startOfDay(for: expense.expenseDate) - state.selectedDate = targetDate - filterExpensesByDate(&state, date: targetDate) + // 단일 날짜 선택 (같은 날짜의 범위) + state.selectedDateRange = targetDate...targetDate + applyFilters(&state) state.pendingHighlightExpenseId = nil } } diff --git a/Features/ExpenseList/Sources/ExpenseListView.swift b/Features/ExpenseList/Sources/ExpenseListView.swift index 1d47aa6..a4e8f4f 100644 --- a/Features/ExpenseList/Sources/ExpenseListView.swift +++ b/Features/ExpenseList/Sources/ExpenseListView.swift @@ -20,17 +20,26 @@ public struct ExpenseListView: View { public var body: some View { VStack(spacing: 0) { - // 헤더 - SettlementHeaderView( - totalAmount: store.totalAmount, - startDate: store.startDate, - endDate: store.endDate, - myExpenseAmount: store.myExpenseAmount, - selectedDate: $store.selectedDate - ) - - // 지출 내역 리스트 - if !store.currentExpense.isEmpty { + if !store.allExpenses.isEmpty { + // 헤더 + SettlementHeaderView( + totalAmount: store.formattedTotalAmount, + startDate: store.startDate, + endDate: store.endDate, + myExpenseAmount: store.formattedTotalAmount, // 임시로 동일 + expenses: store.allExpenses, + selectedDateRange: $store.selectedDateRange, + currentPage: $store.currentPage + ) + + // 카테고리 필터 + CategoryFilterView( + selectedCategory: $store.selectedCategory + ) + .padding(.horizontal, 16) + .padding(.bottom, 16) + + // 지출 내역 리스트 ScrollView { LazyVStack(spacing: 16) { ForEach(store.currentExpense) { expense in @@ -56,10 +65,10 @@ public struct ExpenseListView: View { Image(asset: .expenseEmpty) .resizable() .frame(width: 167, height: 167) - Text("지출을 추가해보세요!") + Text("아직 지출이 없어요") .font(.app(.title3, weight: .medium)) } - .frame(maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } .overlay { diff --git a/Features/Settlement/Sources/SettlementView.swift b/Features/Settlement/Sources/SettlementView.swift index 0dfbcb2..66b9230 100644 --- a/Features/Settlement/Sources/SettlementView.swift +++ b/Features/Settlement/Sources/SettlementView.swift @@ -54,6 +54,7 @@ public struct SettlementView: View { } .background(Color.primary50) .navigationBarBackButtonHidden(true) + .animation(.spring(response: 0.35, dampingFraction: 0.75), value: store.travel) .task { send(.onAppear) } diff --git a/Features/SettlementResult/Sources/Components/PaymentSectionView.swift b/Features/SettlementResult/Sources/Components/PaymentSectionView.swift index f3c46d7..00d8666 100644 --- a/Features/SettlementResult/Sources/Components/PaymentSectionView.swift +++ b/Features/SettlementResult/Sources/Components/PaymentSectionView.swift @@ -10,7 +10,7 @@ import DesignSystem struct PaymentSectionView: View { let title: String - let totalAmount: Double + let totalAmount: String let amountColor: Color let payments: [PaymentItem] @@ -37,7 +37,7 @@ struct PaymentSectionView: View { Spacer() - Text("₩\(Int(totalAmount).formatted())") + Text(totalAmount) .foregroundStyle(amountColor) } .font(.app(.title3, weight: .semibold)) @@ -67,7 +67,7 @@ private struct PaymentRowView: View { Spacer() - Text("₩\(Int(payment.amount).formatted())") + Text(payment.amount) .font(.app(.body, weight: .medium)) .foregroundStyle(Color.black) } @@ -78,7 +78,7 @@ private struct PaymentRowView: View { struct PaymentItem: Identifiable { let id: String let name: String - let amount: Int + let amount: String } // MARK: - Preview @@ -86,21 +86,21 @@ struct PaymentItem: Identifiable { VStack(spacing: 24) { PaymentSectionView( title: "지급 예정 금액", - totalAmount: 90000, + totalAmount: "90,000", amountColor: Color.red, payments: [ - PaymentItem(id: "1", name: "이영희", amount: 45000), - PaymentItem(id: "2", name: "이영민", amount: 45000) + PaymentItem(id: "1", name: "이영희", amount: "45,000"), + PaymentItem(id: "2", name: "이영민", amount: "45,000") ] ) PaymentSectionView( title: "수령 예정 금액", - totalAmount: 100000, + totalAmount: "100,000", amountColor: Color.primary500, payments: [ - PaymentItem(id: "3", name: "박철수", amount: 50000), - PaymentItem(id: "4", name: "박철", amount: 50000) + PaymentItem(id: "3", name: "박철수", amount: "50,000"), + PaymentItem(id: "4", name: "박철", amount: "50,000") ] ) } diff --git a/Features/SettlementResult/Sources/Components/SettlementResultHeaderView.swift b/Features/SettlementResult/Sources/Components/SettlementResultHeaderView.swift index 98921ff..4afdda2 100644 --- a/Features/SettlementResult/Sources/Components/SettlementResultHeaderView.swift +++ b/Features/SettlementResult/Sources/Components/SettlementResultHeaderView.swift @@ -9,13 +9,10 @@ import SwiftUI import DesignSystem struct SettlementResultHeaderView: View { - let totalExpenseAmount: Int - let myExpenseAmount: Int + let totalExpenseAmount: String + let myExpenseAmount: String let totalPersonCount: Int - - private var averageExpensePerPerson: Int { - totalPersonCount > 0 ? totalExpenseAmount / totalPersonCount : 0 - } + let averageExpensePerPerson: String var body: some View { VStack(spacing: 16) { @@ -25,7 +22,7 @@ struct SettlementResultHeaderView: View { .font(.app(.body, weight: .medium)) .foregroundStyle(Color.gray7) - Text("₩\(totalExpenseAmount.formatted())") + Text(totalExpenseAmount) .font(.app(.title1, weight: .semibold)) .foregroundStyle(Color.black) } @@ -35,7 +32,7 @@ struct SettlementResultHeaderView: View { HStack(spacing: 0) { StatItemView( label: "내 지출", - value: "₩\(myExpenseAmount.formatted())" + value: myExpenseAmount ) StatItemView( @@ -45,7 +42,7 @@ struct SettlementResultHeaderView: View { StatItemView( label: "1인 평균", - value: "₩\(averageExpensePerPerson.formatted())" + value: averageExpensePerPerson ) } .padding(.vertical, 20) @@ -75,8 +72,9 @@ private struct StatItemView: View { #Preview { SettlementResultHeaderView( - totalExpenseAmount: 1245000, - myExpenseAmount: 520000, - totalPersonCount: 5 + totalExpenseAmount: "124만 5,000원", + myExpenseAmount: "52만원", + totalPersonCount: 5, + averageExpensePerPerson: "24만 9,000원" ) } diff --git a/Features/SettlementResult/Sources/SettlementResultFeature.swift b/Features/SettlementResult/Sources/SettlementResultFeature.swift index 17d1bfb..897eb3b 100644 --- a/Features/SettlementResult/Sources/SettlementResultFeature.swift +++ b/Features/SettlementResult/Sources/SettlementResultFeature.swift @@ -39,14 +39,19 @@ public struct SettlementResultFeature { memberDetails: [] ) - // 총 지출 금액 - public var totalExpenseAmount: Int { - Int(settlementCalculation.totalExpenseAmount) + // 포맷팅된 총 지출 금액 (억/만 단위) + public var formattedTotalExpenseAmount: String { + CurrencyFormatter.formatKoreanCurrency(settlementCalculation.totalExpenseAmount) } - // 내 부담 금액 (내가 실제로 부담해야 할 금액) - public var myExpenseAmount: Int { - Int(settlementCalculation.myShareAmount) + // 포맷팅된 내 부담 금액 (억/만 단위) + public var formattedMyExpenseAmount: String { + CurrencyFormatter.formatKoreanCurrency(settlementCalculation.myShareAmount) + } + + // 포맷팅된 1인 평균 금액 (억/만 단위) + public var formattedAveragePerPerson: String { + CurrencyFormatter.formatKoreanCurrency(settlementCalculation.averagePerPerson) } // 인원수 diff --git a/Features/SettlementResult/Sources/SettlementResultView.swift b/Features/SettlementResult/Sources/SettlementResultView.swift index 0e7304d..3204574 100644 --- a/Features/SettlementResult/Sources/SettlementResultView.swift +++ b/Features/SettlementResult/Sources/SettlementResultView.swift @@ -9,82 +9,101 @@ import SwiftUI import DesignSystem import ComposableArchitecture import SettlementDetailFeature +import Domain @ViewAction(for: SettlementResultFeature.self) public struct SettlementResultView: View { @Bindable public var store: StoreOf - + public init(store: StoreOf) { self.store = store } - + public var body: some View { VStack(spacing: 0) { // 헤더 (총 지출, 통계) - SettlementResultHeaderView( - totalExpenseAmount: store.totalExpenseAmount, - myExpenseAmount: store.myExpenseAmount, - totalPersonCount: store.totalPersonCount - ) - if !store.paymentsToMake.isEmpty || !store.paymentsToReceive.isEmpty { + SettlementResultHeaderView( + totalExpenseAmount: store.formattedTotalExpenseAmount, + myExpenseAmount: store.formattedMyExpenseAmount, + totalPersonCount: store.totalPersonCount, + averageExpensePerPerson: store.formattedAveragePerPerson + ) + + // 지급/수령 예정 금액 섹션 ScrollView { VStack(spacing: 8) { if !store.paymentsToMake.isEmpty { PaymentSectionView( title: "지급 예정 금액", - totalAmount: store.paymentsToMake.reduce(0) { $0 + $1.amount }, + totalAmount: CurrencyFormatter.formatKoreanCurrency( + store.paymentsToMake.reduce(0.0) { $0 + $1.amount } + ), amountColor: .red, payments: store.paymentsToMake.map { - PaymentItem(id: $0.id, name: $0.memberName, amount: Int($0.amount)) + PaymentItem( + id: $0.id, + name: $0.memberName, + amount: CurrencyFormatter.formatKoreanCurrency($0.amount) + ) } ) } - + if !store.paymentsToReceive.isEmpty { PaymentSectionView( title: "수령 예정 금액", - totalAmount: store.paymentsToReceive.reduce(0) { $0 + $1.amount }, + totalAmount: CurrencyFormatter.formatKoreanCurrency( + store.paymentsToReceive.reduce(0.0) { $0 + $1.amount } + ), amountColor: .primary500, payments: store.paymentsToReceive.map { - PaymentItem(id: $0.id, name: $0.memberName, amount: Int($0.amount)) + PaymentItem( + id: $0.id, + name: $0.memberName, + amount: CurrencyFormatter.formatKoreanCurrency($0.amount) + ) } ) } } } + .scrollIndicators(.hidden) + + // 상세보기 버튼 + Button { + send(.detailButtonTapped) + } label: { + Text("정산 내역 보기") + .font(.app(.body, weight: .medium)) + .foregroundStyle(Color.gray9) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(16) } else { VStack { - Image(asset: .expenseEmpty) + Image(asset: .settlementEmpty) .resizable() .frame(width: 167, height: 167) - Text("정산 내역이 없습니다.") + Text("정산 내역이 없습니다") .font(.app(.title3, weight: .medium)) } .frame(maxHeight: .infinity) } - - - // 상세보기 버튼 - Button { - send(.detailButtonTapped) - } label: { - Text("정산 내역 보기") - .font(.app(.body, weight: .medium)) - .foregroundStyle(Color.gray9) - } - .frame(maxWidth: .infinity, alignment: .center) - .padding(16) } .padding(.horizontal, 16) .background(Color.primary50) - .scrollIndicators(.hidden) .onAppear { - send(.onAppear) + send(.onAppear) } .alert($store.scope(state: \.alert, action: \.scope.alert)) - .sheet(item: $store.scope(state: \.settlementDetail, action: \.scope.settlementDetail)) { store in + .sheet( + item: $store.scope( + state: \.settlementDetail, + action: \.scope.settlementDetail + ) + ) { store in SettlementDetailView(store: store) } }