diff --git a/DesignSystem/Resources/Assets.xcassets/Images/profile.imageset/Contents.json b/DesignSystem/Resources/Assets.xcassets/Images/profile.imageset/Contents.json index adcf60d6..257a53bb 100644 --- a/DesignSystem/Resources/Assets.xcassets/Images/profile.imageset/Contents.json +++ b/DesignSystem/Resources/Assets.xcassets/Images/profile.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "profile.pdf", + "filename" : "profile.svg", "idiom" : "universal" } ], @@ -10,6 +10,7 @@ "version" : 1 }, "properties" : { - "template-rendering-intent" : "template" + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" } } diff --git a/DesignSystem/Resources/Assets.xcassets/Images/profile.imageset/profile.pdf b/DesignSystem/Resources/Assets.xcassets/Images/profile.imageset/profile.pdf deleted file mode 100644 index 5534c82d..00000000 Binary files a/DesignSystem/Resources/Assets.xcassets/Images/profile.imageset/profile.pdf and /dev/null differ diff --git a/DesignSystem/Resources/Assets.xcassets/Images/profile.imageset/profile.svg b/DesignSystem/Resources/Assets.xcassets/Images/profile.imageset/profile.svg new file mode 100644 index 00000000..098204d1 --- /dev/null +++ b/DesignSystem/Resources/Assets.xcassets/Images/profile.imageset/profile.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Features/SaveExpense/Sources/Components/ParticipantSelector/ParticipantSelectorView.swift b/Features/SaveExpense/Sources/Components/ParticipantSelector/ParticipantSelectorView.swift index 6b4e988f..86ace07b 100644 --- a/Features/SaveExpense/Sources/Components/ParticipantSelector/ParticipantSelectorView.swift +++ b/Features/SaveExpense/Sources/Components/ParticipantSelector/ParticipantSelectorView.swift @@ -87,7 +87,7 @@ private struct ParticipantRowView: View { // Avatar Image(asset: .profile) .resizable() - .foregroundStyle(Color.primary500) + .aspectRatio(contentMode: .fit) .frame(width: 40, height: 40) Text(participant.name) diff --git a/Features/SettlementDetail/Sources/Components/DateHeaderRow.swift b/Features/SettlementDetail/Sources/Components/DateHeaderRow.swift new file mode 100644 index 00000000..fb9e8970 --- /dev/null +++ b/Features/SettlementDetail/Sources/Components/DateHeaderRow.swift @@ -0,0 +1,35 @@ +// +// DateHeaderRow.swift +// SettlementDetailFeature +// +// Created by SseuDam on 2025. +// + +import SwiftUI +import DesignSystem + +struct DateHeaderRow: View { + let date: Date + + var body: some View { + Text(formatDate(date)) + .font(.app(.caption1, weight: .medium)) + .foregroundStyle(Color.gray6) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy.MM.dd" + return formatter.string(from: date) + } +} + +#Preview { + VStack(spacing: 0) { + DateHeaderRow(date: Date()) + DateHeaderRow(date: Calendar.current.date(byAdding: .day, value: -1, to: Date())!) + } + .padding(16) + .background(Color.white) +} diff --git a/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift b/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift deleted file mode 100644 index 6e93bba9..00000000 --- a/Features/SettlementDetail/Sources/Components/ExpenseBreakdownSection.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// 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 index 85320439..cd771845 100644 --- a/Features/SettlementDetail/Sources/Components/ExpenseRow.swift +++ b/Features/SettlementDetail/Sources/Components/ExpenseRow.swift @@ -12,38 +12,26 @@ 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) - } + HStack(alignment: .top) { + // 지출 제목 + Text(expense.title) + .font(.app(.body, weight: .medium)) + .foregroundStyle(Color.black) Spacer() - // 내 부담 금액 - VStack(alignment: .trailing, spacing: 2) { + // 내 부담 금액 + 계산식 + VStack(alignment: .trailing, spacing: 4) { Text("₩\(Int(expense.shareAmount).formatted())") - .font(.app(.body, weight: .semibold)) + .font(.app(.body, weight: .medium)) .foregroundStyle(Color.black) - Text("(전체 ₩\(Int(expense.amount).formatted()) ÷ \(expense.participantCount))") + Text("₩\(Int(expense.amount).formatted()) ÷ \(expense.participantCount)") .font(.app(.caption2, weight: .medium)) - .foregroundStyle(Color.gray7) + .foregroundStyle(Color.gray6) } } } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy.MM.dd" - return formatter.string(from: date) - } } diff --git a/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift b/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift index 41853ceb..2a3a1b6e 100644 --- a/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift +++ b/Features/SettlementDetail/Sources/Components/MemberDetailCard.swift @@ -15,9 +15,19 @@ struct MemberDetailCard: View { let isCurrentUser: Bool let onToggle: () -> Void + // 날짜별로 그룹핑된 결제 항목 + private var groupedPaidExpenses: [ExpensesByDate] { + detail.paidExpenses.groupedByDate() + } + + // 날짜별로 그룹핑된 부담 항목 + private var groupedSharedExpenses: [ExpensesByDate] { + detail.sharedExpenses.groupedByDate() + } + var body: some View { VStack(spacing: 0) { - // 헤더 (이름, 순 차액, 펼치기 버튼) + // 헤더 (이름, 총 금액, 펼치기 버튼) Button(action: { withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { onToggle() @@ -27,7 +37,7 @@ struct MemberDetailCard: View { // 프로필 아이콘 Image(asset: .profile) .resizable() - .foregroundStyle(Color.primary500) + .aspectRatio(contentMode: .fit) .frame(width: 40, height: 40) // 이름 @@ -37,16 +47,10 @@ struct MemberDetailCard: View { 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) - } + // 총 금액 (결제한 금액 + 부담 금액 합산) + Text("₩\(Int(detail.totalPaid + detail.totalOwe).formatted())") + .font(.app(.title3, weight: .semibold)) + .foregroundStyle(Color.black) // 펼치기 아이콘 Image(systemName: "chevron.up") @@ -61,32 +65,84 @@ struct MemberDetailCard: View { // 상세 내용 (펼쳤을 때만 표시) if isExpanded { - VStack(spacing: 16) { + VStack(spacing: 0) { 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) + // 플랫 리스트 + VStack(spacing: 0) { + // 1. 결제한 금액 섹션 + SectionHeaderRow(title: "결제한 금액", amount: detail.totalPaid) + .padding(.vertical, 16) + + if detail.paidExpenses.isEmpty { + Text("결제한 내역이 없습니다") + .font(.app(.caption1, weight: .medium)) + .foregroundStyle(Color.gray7) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } else { + ForEach(groupedPaidExpenses) { dateGroup in + VStack(spacing: 0) { + DateHeaderRow(date: dateGroup.date) + + VStack(spacing: 12) { + ForEach(dateGroup.expenses) { expense in + ExpenseRow(expense: expense) + } + } + .padding(.top, 8) + } + .padding(.bottom, 16) + } + } + + // 2. 부담 금액 섹션 + SectionHeaderRow(title: "부담 금액", amount: detail.totalOwe) + .padding(.top, 8) + .padding(.vertical, 16) + + if detail.sharedExpenses.isEmpty { + Text("부담할 금액이 없습니다") + .font(.app(.caption1, weight: .medium)) + .foregroundStyle(Color.gray7) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } else { + ForEach(groupedSharedExpenses) { dateGroup in + VStack(spacing: 0) { + DateHeaderRow(date: dateGroup.date) + + VStack(spacing: 12) { + ForEach(dateGroup.expenses) { expense in + ExpenseRow(expense: expense) + } + } + .padding(.top, 8) + } + .padding(.bottom, 16) + } + } + + Divider() + .background(Color.gray2) + + // 3. 받을 돈 / 줄 돈 최종 결과 + HStack { + Text(netBalanceLabel) + .font(.app(.body, weight: .medium)) + .foregroundStyle(Color.black) + + Spacer() + + Text(formatAmount(detail.netBalance)) + .font(.app(.body, weight: .medium)) + .foregroundStyle(netBalanceColor) + } + .padding(.vertical, 16) + } } .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) @@ -94,10 +150,10 @@ struct MemberDetailCard: View { } } .background(Color.white) - .cornerRadius(16) + .cornerRadius(8) .overlay( - RoundedRectangle(cornerRadius: 16) - .stroke(Color.gray1, lineWidth: 1) + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray2, lineWidth: 1) ) } @@ -115,9 +171,9 @@ struct MemberDetailCard: View { if detail.netBalance > 0 { return .primary500 } else if detail.netBalance < 0 { - return .red + return Color.error } else { - return .black + return Color.primary500 } } diff --git a/Features/SettlementDetail/Sources/Components/SectionHeaderRow.swift b/Features/SettlementDetail/Sources/Components/SectionHeaderRow.swift new file mode 100644 index 00000000..33542265 --- /dev/null +++ b/Features/SettlementDetail/Sources/Components/SectionHeaderRow.swift @@ -0,0 +1,37 @@ +// +// SectionHeaderRow.swift +// SettlementDetailFeature +// +// Created by SseuDam on 2025. +// + +import SwiftUI +import DesignSystem + +struct SectionHeaderRow: View { + let title: String + let amount: Double + + var body: some View { + HStack { + Text(title) + .font(.app(.caption1, weight: .semibold)) + .foregroundStyle(Color.black) + + Spacer() + + Text("₩\(Int(amount).formatted())") + .font(.app(.body, weight: .semibold)) + .foregroundStyle(Color.primary500) + } + } +} + +#Preview { + VStack(spacing: 12) { + SectionHeaderRow(title: "결제한 금액", amount: 400000) + SectionHeaderRow(title: "부담 금액", amount: 120000) + } + .padding(16) + .background(Color.white) +} diff --git a/Features/SettlementDetail/Sources/Helpers/ExpenseGrouping.swift b/Features/SettlementDetail/Sources/Helpers/ExpenseGrouping.swift new file mode 100644 index 00000000..131e5ea3 --- /dev/null +++ b/Features/SettlementDetail/Sources/Helpers/ExpenseGrouping.swift @@ -0,0 +1,34 @@ +// +// ExpenseGrouping.swift +// SettlementDetailFeature +// +// Created by SseuDam on 2025. +// + +import Foundation +import Domain + +struct ExpensesByDate: Identifiable { + let id = UUID() + let date: Date + let expenses: [ExpenseDetail] +} + +extension Array where Element == ExpenseDetail { + func groupedByDate() -> [ExpensesByDate] { + let calendar = Calendar.current + + // 날짜별로 그룹핑 + let grouped = Dictionary(grouping: self) { expense in + calendar.startOfDay(for: expense.expenseDate) + } + + // 날짜 오름차순으로 정렬, 같은 날짜 내에서는 금액 높은 순 + return grouped + .sorted { $0.key < $1.key } + .map { dateKey, expenses in + let sortedExpenses = expenses.sorted { $0.shareAmount > $1.shareAmount } + return ExpensesByDate(date: dateKey, expenses: sortedExpenses) + } + } +} diff --git a/Features/SettlementDetail/Sources/SettlementDetailView.swift b/Features/SettlementDetail/Sources/SettlementDetailView.swift index c98bc346..a7e604dc 100644 --- a/Features/SettlementDetail/Sources/SettlementDetailView.swift +++ b/Features/SettlementDetail/Sources/SettlementDetailView.swift @@ -18,54 +18,65 @@ public struct SettlementDetailView: View { } 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) + VStack(spacing: 0) { + Capsule() + .fill(Color.gray3) + .frame(width: 36, height: 5) + .padding(.top, 8) - MemberDetailCard( - detail: myDetail, - isExpanded: store.expandedMemberIds.contains(myDetail.memberId), - isCurrentUser: true, - onToggle: { - store.send(.toggleMemberExpansion(myDetail.memberId)) - } - ) + Text("정산 내역") + .font(.app(.title3, weight: .medium)) + .foregroundStyle(Color.black) + .frame(maxWidth: .infinity, alignment: .center) + .padding(16) + + 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)) - } - ) + + // 다른 멤버들 + 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) } - .padding(16) + .scrollIndicators(.hidden) } - .scrollIndicators(.hidden) .background(Color.primary50) - .navigationTitle("정산 상세") - .navigationBarTitleDisplayMode(.inline) } } diff --git a/Features/SettlementResult/Sources/Components/PaymentSectionView.swift b/Features/SettlementResult/Sources/Components/PaymentSectionView.swift index b038e11e..f3c46d7c 100644 --- a/Features/SettlementResult/Sources/Components/PaymentSectionView.swift +++ b/Features/SettlementResult/Sources/Components/PaymentSectionView.swift @@ -56,11 +56,10 @@ private struct PaymentRowView: View { var body: some View { HStack(spacing: 12) { - Image(systemName: "person.fill") + Image(asset: .profile) .resizable() - .foregroundStyle(Color.primary500) .aspectRatio(contentMode: .fit) - .frame(width: 24, height: 24) + .frame(width: 32, height: 32) Text(payment.name) .font(.app(.title3, weight: .medium)) diff --git a/Features/SettlementResult/Sources/SettlementResultView.swift b/Features/SettlementResult/Sources/SettlementResultView.swift index 000426d1..0e7304db 100644 --- a/Features/SettlementResult/Sources/SettlementResultView.swift +++ b/Features/SettlementResult/Sources/SettlementResultView.swift @@ -52,27 +52,6 @@ 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 { @@ -85,6 +64,18 @@ public struct SettlementResultView: View { } .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) @@ -94,9 +85,7 @@ public struct SettlementResultView: View { } .alert($store.scope(state: \.alert, action: \.scope.alert)) .sheet(item: $store.scope(state: \.settlementDetail, action: \.scope.settlementDetail)) { store in - NavigationView { - SettlementDetailView(store: store) - } + SettlementDetailView(store: store) } } }