diff --git "a/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/ChatGPT Image 2025\353\205\204 12\354\233\224 12\354\235\274 \354\230\244\355\233\204 02_55_30 1.png" "b/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/ChatGPT Image 2025\353\205\204 12\354\233\224 12\354\235\274 \354\230\244\355\233\204 02_55_30 1.png" deleted file mode 100644 index 9b416ba5..00000000 Binary files "a/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/ChatGPT Image 2025\353\205\204 12\354\233\224 12\354\235\274 \354\230\244\355\233\204 02_55_30 1.png" and /dev/null differ diff --git a/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/Contents.json b/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/Contents.json index 22114761..415e8627 100644 --- a/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/Contents.json +++ b/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "ChatGPT Image 2025년 12월 12일 오후 02_55_30 1.png", + "filename" : "travelList.png", "idiom" : "universal", "scale" : "1x" }, diff --git a/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/travelList.png b/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/travelList.png new file mode 100644 index 00000000..71d3b5f5 Binary files /dev/null and b/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/travelList.png differ diff --git a/DesignSystem/Resources/Assets.xcassets/Images/copy.imageset/Contents.json b/DesignSystem/Resources/Assets.xcassets/Images/copy.imageset/Contents.json new file mode 100644 index 00000000..745fe18c --- /dev/null +++ b/DesignSystem/Resources/Assets.xcassets/Images/copy.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "lucide_files.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "lucide_files@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "lucide_files@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DesignSystem/Resources/Assets.xcassets/Images/copy.imageset/lucide_files.png b/DesignSystem/Resources/Assets.xcassets/Images/copy.imageset/lucide_files.png new file mode 100644 index 00000000..4e7cd4e4 Binary files /dev/null and b/DesignSystem/Resources/Assets.xcassets/Images/copy.imageset/lucide_files.png differ diff --git a/DesignSystem/Resources/Assets.xcassets/Images/copy.imageset/lucide_files@2x.png b/DesignSystem/Resources/Assets.xcassets/Images/copy.imageset/lucide_files@2x.png new file mode 100644 index 00000000..19d47f8f Binary files /dev/null and b/DesignSystem/Resources/Assets.xcassets/Images/copy.imageset/lucide_files@2x.png differ diff --git a/DesignSystem/Resources/Assets.xcassets/Images/copy.imageset/lucide_files@3x.png b/DesignSystem/Resources/Assets.xcassets/Images/copy.imageset/lucide_files@3x.png new file mode 100644 index 00000000..b7614962 Binary files /dev/null and b/DesignSystem/Resources/Assets.xcassets/Images/copy.imageset/lucide_files@3x.png differ diff --git a/DesignSystem/Resources/Assets.xcassets/Images/upload.imageset/Contents.json b/DesignSystem/Resources/Assets.xcassets/Images/upload.imageset/Contents.json new file mode 100644 index 00000000..aeb42010 --- /dev/null +++ b/DesignSystem/Resources/Assets.xcassets/Images/upload.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "lucide_upload.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "lucide_upload@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "lucide_upload@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DesignSystem/Resources/Assets.xcassets/Images/upload.imageset/lucide_upload.png b/DesignSystem/Resources/Assets.xcassets/Images/upload.imageset/lucide_upload.png new file mode 100644 index 00000000..b021513e Binary files /dev/null and b/DesignSystem/Resources/Assets.xcassets/Images/upload.imageset/lucide_upload.png differ diff --git a/DesignSystem/Resources/Assets.xcassets/Images/upload.imageset/lucide_upload@2x.png b/DesignSystem/Resources/Assets.xcassets/Images/upload.imageset/lucide_upload@2x.png new file mode 100644 index 00000000..a5a2011b Binary files /dev/null and b/DesignSystem/Resources/Assets.xcassets/Images/upload.imageset/lucide_upload@2x.png differ diff --git a/DesignSystem/Resources/Assets.xcassets/Images/upload.imageset/lucide_upload@3x.png b/DesignSystem/Resources/Assets.xcassets/Images/upload.imageset/lucide_upload@3x.png new file mode 100644 index 00000000..55c1e880 Binary files /dev/null and b/DesignSystem/Resources/Assets.xcassets/Images/upload.imageset/lucide_upload@3x.png differ diff --git a/DesignSystem/Sources/Image/ImageAsset.swift b/DesignSystem/Sources/Image/ImageAsset.swift index 9e9fa01d..687c0913 100644 --- a/DesignSystem/Sources/Image/ImageAsset.swift +++ b/DesignSystem/Sources/Image/ImageAsset.swift @@ -34,4 +34,6 @@ public enum ImageAsset: String { case travelDetailMock case supportMaIl case settlementEmpty + case copy + case upload } diff --git a/DesignSystem/Sources/Utilities/InviteCodeHelper.swift b/DesignSystem/Sources/Utilities/InviteCodeHelper.swift new file mode 100644 index 00000000..4733e278 --- /dev/null +++ b/DesignSystem/Sources/Utilities/InviteCodeHelper.swift @@ -0,0 +1,34 @@ +// +// InviteCodeHelper.swift +// DesignSystem +// +// Created by Claude on 12/17/24. +// + +import UIKit + +/// 여행 초대 코드 복사 및 공유 기능을 제공하는 헬퍼 +@MainActor +public enum InviteCodeHelper { + + /// 초대 코드를 클립보드에 복사 + /// - Parameter code: 복사할 초대 코드 + public static func copyToClipboard(_ code: String) { + UIPasteboard.general.string = code + ToastManager.shared.showSuccess("클립보드에 복사되었습니다.") + } + + /// 딥링크를 시스템 공유 시트를 통해 공유 + /// - Parameter deepLink: 공유할 딥링크 URL + public static func shareDeepLink(_ deepLink: URL) { + let activityViewController = UIActivityViewController( + activityItems: [deepLink], + applicationActivities: nil + ) + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first { + window.rootViewController?.present(activityViewController, animated: true) + } + } +} diff --git a/DesignSystem/Sources/View/EmptyCaseView.swift b/DesignSystem/Sources/View/EmptyCaseView.swift new file mode 100644 index 00000000..38ea567f --- /dev/null +++ b/DesignSystem/Sources/View/EmptyCaseView.swift @@ -0,0 +1,34 @@ +// +// EmptyCaseView.swift +// DesignSystem +// +// Created by 홍석현 on 12/16/25. +// + +import SwiftUI + +public struct EmptyCaseView: View { + private let image: ImageAsset + private let message: String + + public init(image: ImageAsset, message: String) { + self.image = image + self.message = message + } + + public var body: some View { + VStack(spacing: 16) { + Image(asset: image) + .resizable() + .frame(width: 167, height: 167) + Text(message) + .font(.app(.title3, weight: .medium)) + .foregroundStyle(Color.black) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +#Preview { + EmptyCaseView(image: .expenseEmpty, message: "아직 지출이 없어요") +} diff --git a/Domain/Sources/Router/DeeplinkRouter.swift b/Domain/Sources/Router/DeeplinkRouter.swift index bb6d6bbc..86b231e3 100644 --- a/Domain/Sources/Router/DeeplinkRouter.swift +++ b/Domain/Sources/Router/DeeplinkRouter.swift @@ -6,7 +6,7 @@ // import Foundation -import ComposableArchitecture +import Dependencies import LogMacro public struct DeeplinkRouter: Sendable { diff --git a/Features/ExpenseList/Sources/Components/DateRangePicker.swift b/Features/ExpenseList/Sources/Components/DateRangePicker.swift new file mode 100644 index 00000000..ee92578c --- /dev/null +++ b/Features/ExpenseList/Sources/Components/DateRangePicker.swift @@ -0,0 +1,215 @@ +// +// DateRangePicker.swift +// ExpenseListFeature +// +// Created by Claude on 12/16/24. +// + +import SwiftUI +import UIKit + +// MARK: - DateRangePicker (SwiftUI Wrapper) + +struct DateRangePicker: View { + let startDate: Date + let endDate: Date + @Binding var selectedRange: ClosedRange? + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + CalendarView( + startDate: startDate, + endDate: endDate, + selectedRange: $selectedRange + ) + .navigationTitle("기간 선택") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("닫기") { + dismiss() + } + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } +} + +// MARK: - CalendarView (UIViewRepresentable) + +struct CalendarView: UIViewRepresentable { + let startDate: Date + let endDate: Date + @Binding var selectedRange: ClosedRange? + + func makeCoordinator() -> Coordinator { + Coordinator(selectedRange: $selectedRange) + } + + func makeUIView(context: Context) -> UICalendarView { + let calendarView = UICalendarView() + calendarView.calendar = Calendar.current + calendarView.locale = Locale.current + calendarView.fontDesign = .rounded + + // 날짜 범위 제한 + let dateInterval = DateInterval(start: startDate, end: endDate) + calendarView.availableDateRange = dateInterval + + // MultiDate selection 설정 + let selection = UICalendarSelectionMultiDate(delegate: context.coordinator) + calendarView.selectionBehavior = selection + context.coordinator.selection = selection + + // 초기 선택 설정 + if let range = selectedRange { + context.coordinator.loadInitialRange(range) + } + + return calendarView + } + + func updateUIView(_ uiView: UICalendarView, context: Context) { + // selectedRange가 외부에서 변경되면 업데이트 + context.coordinator.externalRangeUpdate(selectedRange) + } + + // MARK: - Coordinator + + class Coordinator: NSObject, UICalendarSelectionMultiDateDelegate { + @Binding var selectedRange: ClosedRange? + var selection: UICalendarSelectionMultiDate? + + private var rangeStart: Date? + private var rangeEnd: Date? + private let calendar = Calendar.current + + init(selectedRange: Binding?>) { + self._selectedRange = selectedRange + } + + // 초기 범위 로드 + func loadInitialRange(_ range: ClosedRange) { + let start = calendar.startOfDay(for: range.lowerBound) + let end = calendar.startOfDay(for: range.upperBound) + + rangeStart = start + rangeEnd = end + + // 범위 내 모든 날짜 선택 + var dates: [DateComponents] = [] + var current = start + while current <= end { + let components = calendar.dateComponents([.year, .month, .day], from: current) + dates.append(components) + guard let next = calendar.date(byAdding: .day, value: 1, to: current) else { break } + current = next + } + + selection?.setSelectedDates(dates, animated: false) + } + + // 외부에서 selectedRange 변경 시 + func externalRangeUpdate(_ newRange: ClosedRange?) { + guard let range = newRange else { + rangeStart = nil + rangeEnd = nil + selection?.setSelectedDates([], animated: false) + return + } + + // 현재 내부 상태와 다를 때만 업데이트 + if rangeStart != range.lowerBound || rangeEnd != range.upperBound { + loadInitialRange(range) + } + } + + // 날짜 선택 가능 여부 + func multiDateSelection(_ selection: UICalendarSelectionMultiDate, canSelectDate dateComponents: DateComponents) -> Bool { + return true + } + + // 날짜 해제 가능 여부 + func multiDateSelection(_ selection: UICalendarSelectionMultiDate, canDeselectDate dateComponents: DateComponents) -> Bool { + return true + } + + // 날짜가 선택/해제되었을 때 + func multiDateSelection(_ selection: UICalendarSelectionMultiDate, didSelectDate dateComponents: DateComponents) { + guard let selectedDate = calendar.date(from: dateComponents) else { return } + let date = calendar.startOfDay(for: selectedDate) + + print("🔵 날짜 선택: \(date)") + + // 첫 번째 선택 (start) + if rangeStart == nil { + print(" 📍 첫 번째 선택 (start)") + rangeStart = date + rangeEnd = date + selection.setSelectedDates([dateComponents], animated: false) + updateBinding() + } + // 두 번째 선택 (end) + else if let start = rangeStart, calendar.isDate(start, inSameDayAs: rangeEnd ?? start) { + print(" 📍 두 번째 선택 (end)") + if date < start { + rangeStart = date + rangeEnd = start + } else { + rangeEnd = date + } + fillRange() + updateBinding() + } + // 세 번째 이후 선택 (리셋) + else { + print(" 🔄 세 번째 이후 선택 (리셋)") + rangeStart = date + rangeEnd = date + selection.setSelectedDates([dateComponents], animated: false) + updateBinding() + } + } + + func multiDateSelection(_ selection: UICalendarSelectionMultiDate, didDeselectDate dateComponents: DateComponents) { + print("❌ 날짜 해제") + // 날짜 해제 시 전체 리셋 + rangeStart = nil + rangeEnd = nil + selection.setSelectedDates([], animated: false) + selectedRange = nil + } + + // 범위 채우기 + private func fillRange() { + guard let start = rangeStart, let end = rangeEnd else { return } + + var dates: [DateComponents] = [] + var current = start + while current <= end { + let components = calendar.dateComponents([.year, .month, .day], from: current) + dates.append(components) + guard let next = calendar.date(byAdding: .day, value: 1, to: current) else { break } + current = next + } + + selection?.setSelectedDates(dates, animated: false) + print(" 🟢 범위 채우기 완료: \(dates.count)개 날짜") + } + + // Binding 업데이트 + private func updateBinding() { + guard let start = rangeStart, let end = rangeEnd else { + selectedRange = nil + print(" 🔴 selectedRange -> nil") + return + } + + selectedRange = start...end + print(" 🟢 selectedRange 업데이트: \(start) ~ \(end)") + } + } +} diff --git a/Features/ExpenseList/Sources/Components/ExpenseCardView.swift b/Features/ExpenseList/Sources/Components/ExpenseCardView.swift index 4f957c23..26ce9c0c 100644 --- a/Features/ExpenseList/Sources/Components/ExpenseCardView.swift +++ b/Features/ExpenseList/Sources/Components/ExpenseCardView.swift @@ -71,14 +71,13 @@ public struct ExpenseCardView: View { .foregroundStyle(Color.gray7) } .padding(16) - .background(Color.white) + .background(Color.primary50) .clipShape(RoundedRectangle(cornerRadius: 16)) - .shadow(color: .black.opacity(0.25), radius: 5, x: 0, y: 4) .overlay( RoundedRectangle(cornerRadius: 16) - .stroke(Color.gray.opacity(0.1), lineWidth: 1) + .stroke(Color.gray1, lineWidth: 1) ) - .padding(.horizontal, 16) + .padding(.horizontal, 20) } } diff --git a/Features/ExpenseList/Sources/Components/ExpenseChartView.swift b/Features/ExpenseList/Sources/Components/ExpenseChartView.swift index a596f190..a123b26e 100644 --- a/Features/ExpenseList/Sources/Components/ExpenseChartView.swift +++ b/Features/ExpenseList/Sources/Components/ExpenseChartView.swift @@ -18,7 +18,6 @@ struct ExpenseChartView: View { @Binding var currentPage: Int private let barWidth: CGFloat = 18 - @State private var dragStartDateString: String? private static let mmddFormatter: DateFormatter = { let formatter = DateFormatter() @@ -102,6 +101,15 @@ struct ExpenseChartView: View { } } .frame(height: 126) + .onChange(of: selectedDateString) { _, newValue in + guard let dateStr = newValue, + let date = Expense.parseDate(dateStr) else { return } + + // 탭한 날짜만 선택 (단일 날짜 범위) + let calendar = Calendar.current + let selected = calendar.startOfDay(for: date) + selectedDateRange = selected...selected + } } private func chartView(for chunk: [String]) -> some View { @@ -130,91 +138,35 @@ struct ExpenseChartView: View { } } } - .chartXSelection(value: currentSelectionBinding) .chartGesture { proxy in - DragGesture(minimumDistance: 0) - .onChanged { value in - handleDragChange(value: value, proxy: proxy) - } + SpatialTapGesture() .onEnded { value in - handleDragEnd(value: value, proxy: proxy) + // 정확한 타입 추론을 위해 as: String.self 명시 + if let dateString = proxy.value(atX: value.location.x, as: String.self) { + selectedDateString = dateString + } } } .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) - } - } - } + @State private var selectedDateString: String? - 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 { + private func barColor(for dateString: String) -> Color { + guard let date = Expense.parseDate(dateString) else { return .primary100 } - let dragDistance = value.translation.width + // 선택된 범위가 있으면 범위 체크 + if let range = selectedDateRange { + let calendar = Calendar.current + let dateDay = calendar.startOfDay(for: date) + let rangeStart = calendar.startOfDay(for: range.lowerBound) + let rangeEnd = calendar.startOfDay(for: range.upperBound) - // 드래그가 거의 없었으면 단일 선택 (탭) - if abs(dragDistance) < 10 { - // 단일 날짜 선택 - if let date = Expense.parseDate(startDateStr) { - selectedDateRange = date...date - } - } else { - // 범위 선택 - updateRangeSelection(from: startDateStr, to: endDateStr) - } + return (dateDay >= rangeStart && dateDay <= rangeEnd) ? .primary500 : .primary100 } - // 드래그 상태 초기화 - 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 + // 선택된 범위가 없으면 기본 색상 + return .primary100 } } diff --git a/Features/ExpenseList/Sources/Components/InvitationCodeView.swift b/Features/ExpenseList/Sources/Components/InvitationCodeView.swift new file mode 100644 index 00000000..cb88e792 --- /dev/null +++ b/Features/ExpenseList/Sources/Components/InvitationCodeView.swift @@ -0,0 +1,97 @@ +// +// InvitationCodeView.swift +// ExpenseListFeature +// +// Created by 홍석현 on 12/17/25. +// + +import SwiftUI +import DesignSystem + +struct InvitationCodeView: View { + @State private var isExpanded: Bool = false + private let invitationCode: String + private let deepLinkURL: URL + + init( + invitationCode: String, + deepLinkURL: URL + ) { + self.invitationCode = invitationCode + self.deepLinkURL = deepLinkURL + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("여행 초대코드") + .font(.app(.caption1, weight: .medium)) + .foregroundStyle(Color.gray5) + + Spacer() + + Button(action: { + withAnimation(.spring(response: 0.15, dampingFraction: 0.75)) { + isExpanded.toggle() + } + }, label: { + HStack(spacing: 2) { + Text(isExpanded ? "접기" : "펼치기") + .font(.app(.caption2, weight: .regular)) + + Image(systemName: "chevron.down") + .resizable() + .frame(width: 6, height: 3) + .rotationEffect(.degrees(isExpanded ? 180 : 0)) + } + .foregroundStyle(Color.gray4) + }) + } + + if isExpanded { + VStack(spacing: 4) { + Text("여행 코드를 공유해 여행 멤버를 초대하세요!") + .font(.app(.caption2, weight: .regular)) + .foregroundStyle(Color.gray4) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 10) { + Text(invitationCode) + .font(.app(.title2, weight: .medium)) + .foregroundStyle(Color.gray8) + + Spacer() + + Button(action: { + InviteCodeHelper.copyToClipboard(invitationCode) + }, label: { + Image(asset: .copy) + .resizable() + .frame(width: 32, height: 32) + }) + + Button(action: { + InviteCodeHelper.shareDeepLink(deepLinkURL) + }, label: { + Image(asset: .upload) + .resizable() + .frame(width: 32, height: 32) + }) + } + } + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + )) + } + } + .padding(20) + } +} + +#Preview { + InvitationCodeView( + invitationCode: "1A1A1A", + deepLinkURL: URL(string: "")! + ) +} diff --git a/Features/ExpenseList/Sources/Components/SettlementHeaderView.swift b/Features/ExpenseList/Sources/Components/SettlementHeaderView.swift index 5d02d6b3..6dc9fe1f 100644 --- a/Features/ExpenseList/Sources/Components/SettlementHeaderView.swift +++ b/Features/ExpenseList/Sources/Components/SettlementHeaderView.swift @@ -18,6 +18,8 @@ public struct SettlementHeaderView: View { @Binding var selectedDateRange: ClosedRange? @Binding var currentPage: Int + @State private var showDatePicker = false + public init( totalAmount: String, startDate: Date, @@ -35,38 +37,13 @@ public struct SettlementHeaderView: View { self._selectedDateRange = selectedDateRange self._currentPage = currentPage } - + public var body: some View { VStack(spacing: 0) { VStack(spacing: 8) { - // 날짜 선택 (드롭다운 느낌) - Menu { - Button { - selectedDateRange = nil - } label: { - HStack { - Text("전체") - if selectedDateRange == nil { - Image(systemName: "checkmark") - } - } - } - - ForEach(datesRange, id: \.self) { date in - Button { - // 단일 날짜 선택 (같은 날짜의 범위) - selectedDateRange = date...date - } label: { - HStack { - Text(dateFormatter.string(from: date)) - if let range = selectedDateRange, - Calendar.current.isDate(range.lowerBound, inSameDayAs: date), - Calendar.current.isDate(range.upperBound, inSameDayAs: date) { - Image(systemName: "checkmark") - } - } - } - } + // 날짜 선택 버튼 + Button { + showDatePicker = true } label: { HStack(spacing: 4) { Text(selectedDateLabel) @@ -77,6 +54,13 @@ public struct SettlementHeaderView: View { .foregroundStyle(Color.gray5) } } + .sheet(isPresented: $showDatePicker) { + DateRangePicker( + startDate: startDate, + endDate: endDate, + selectedRange: $selectedDateRange + ) + } // 총 지출 금액 @@ -95,7 +79,7 @@ public struct SettlementHeaderView: View { selectedDateRange: $selectedDateRange, currentPage: $currentPage ) - .padding(.horizontal, 16) + .padding(.horizontal, 20) } } @@ -116,25 +100,6 @@ public struct SettlementHeaderView: View { } } - 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) - if let nextDate = calendar.date(byAdding: .day, value: 1, to: currentDate) { - currentDate = nextDate - } else { - break - } - } - return dates - } - private var dateFormatter: DateFormatter { let formatter = DateFormatter() formatter.dateFormat = "yyyy.MM.dd" diff --git a/Features/ExpenseList/Sources/ExpenseListFeature.swift b/Features/ExpenseList/Sources/ExpenseListFeature.swift index ddc32bf7..2abedc6f 100644 --- a/Features/ExpenseList/Sources/ExpenseListFeature.swift +++ b/Features/ExpenseList/Sources/ExpenseListFeature.swift @@ -26,15 +26,14 @@ public struct ExpenseListFeature { public var endDate: Date { return travel?.endDate ?? Date() } - public var selectedDateRange: ClosedRange? = nil + public var selectedDateRange: ClosedRange? 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 formattedTotalAmount: String { // 날짜 범위가 선택되지 않았을 때(전체 기간)는 페이지네이션과 관계없이 전체 지출의 합계 표시 // 단, 카테고리 필터는 적용해야 함 @@ -138,11 +137,6 @@ public struct ExpenseListFeature { } } return .none - case .binding(\.currentPage): - // 페이지 변경 시 선택된 날짜 초기화 및 해당 페이지 데이터로 필터링 - state.selectedDateRange = nil - applyFilters(&state) - return .none case .binding(\.selectedCategory): // 카테고리 변경 시 필터링 applyFilters(&state) diff --git a/Features/ExpenseList/Sources/ExpenseListView.swift b/Features/ExpenseList/Sources/ExpenseListView.swift index a4e8f4f0..77ca2019 100644 --- a/Features/ExpenseList/Sources/ExpenseListView.swift +++ b/Features/ExpenseList/Sources/ExpenseListView.swift @@ -26,49 +26,81 @@ public struct ExpenseListView: View { totalAmount: store.formattedTotalAmount, startDate: store.startDate, endDate: store.endDate, - myExpenseAmount: store.formattedTotalAmount, // 임시로 동일 + myExpenseAmount: store.formattedTotalAmount, expenses: store.allExpenses, selectedDateRange: $store.selectedDateRange, currentPage: $store.currentPage ) - // 카테고리 필터 - CategoryFilterView( - selectedCategory: $store.selectedCategory - ) - .padding(.horizontal, 16) - .padding(.bottom, 16) + if let inviteCode = store.travel?.inviteCode, + let url = store.travel?.deepLink, + let deepLinkURL = URL(string: url) { + InvitationCodeView( + invitationCode: inviteCode, + deepLinkURL: deepLinkURL + ) + } - // 지출 내역 리스트 - ScrollView { - LazyVStack(spacing: 16) { - ForEach(store.currentExpense) { expense in - ExpenseCardView(expense: expense) - .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) - )) + VStack(spacing: 0) { + // 카테고리 필터 + CategoryFilterView( + selectedCategory: $store.selectedCategory + ) + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 8) + + // 지출 내역 리스트 + ScrollView { + LazyVStack(spacing: 16) { + ForEach(store.currentExpense) { expense in + ExpenseCardView(expense: expense) + .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(.bottom, 16 + 54) + .animation(.spring(response: 0.35, dampingFraction: 0.75), value: store.currentExpense.count) + } + .scrollIndicators(.hidden) + .overlay { + if store.currentExpense.isEmpty { + EmptyCaseView(image: .expenseEmpty, message: "아직 지출이 없어요") } } - .padding(.vertical, 10) - .animation(.spring(response: 0.35, dampingFraction: 0.75), value: store.currentExpense.count) } - .scrollIndicators(.hidden) + .background(Color.primary50) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 20, + topTrailingRadius: 20 + ) + ) + .overlay( + UnevenRoundedRectangle( + topLeadingRadius: 20, + topTrailingRadius: 20 + ) + .stroke(Color.gray1, lineWidth: 1) + ) } else { - VStack { - Image(asset: .expenseEmpty) - .resizable() - .frame(width: 167, height: 167) - Text("아직 지출이 없어요") - .font(.app(.title3, weight: .medium)) + if let inviteCode = store.travel?.inviteCode, + let url = store.travel?.deepLink, + let deepLinkURL = URL(string: url) { + InvitationCodeView( + invitationCode: inviteCode, + deepLinkURL: deepLinkURL + ) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + EmptyCaseView(image: .expenseEmpty, message: "아직 지출이 없어요") } } .overlay { @@ -82,9 +114,10 @@ public struct ExpenseListView: View { send(.addExpenseButtonTapped) } .padding(.trailing, 20) - .padding(.bottom, 20) + .padding(.bottom, 54) } } + .ignoresSafeArea() .onAppear { send(.onAppear) } diff --git a/Features/Main/Sources/MainCoordinator.swift b/Features/Main/Sources/MainCoordinator.swift index e29d1a67..4f4ded41 100644 --- a/Features/Main/Sources/MainCoordinator.swift +++ b/Features/Main/Sources/MainCoordinator.swift @@ -108,18 +108,6 @@ extension MainCoordinator { state.routes.push(.memberManage(.init(travelId: travelId))) return .none - -// case .routeAction(_, .travelSetting(.delegate(.navigateToTravelDetail(_)))): -// return .routeWithDelaysIfUnsupported(state.routes, action: \.router) { -// $0.goBackTo(\.travelList) -// } - -// case let .routeAction(_, .travelSetting(.delegate(.navigateToTravelDetail(travelId)))): -// return .routeWithDelaysIfUnsupported(state.routes, action: \.router) { -// $0.goBackTo(\.travelList) -// } - - case .routeAction(_, .memberManage(.delegate(.back))): state.routes.goBack() return .none diff --git a/Features/Settlement/Sources/SettlementFeature.swift b/Features/Settlement/Sources/SettlementFeature.swift index 3929863f..3f3e4ff4 100644 --- a/Features/Settlement/Sources/SettlementFeature.swift +++ b/Features/Settlement/Sources/SettlementFeature.swift @@ -164,13 +164,26 @@ extension SettlementFeature { state.$travel.withLock { $0 = travel } + // 초기 날짜 범위를 전체 여행 기간으로 설정 + if state.expenseList.selectedDateRange == nil { + let calendar = Calendar.current + let start = calendar.startOfDay(for: travel.startDate) + let end = calendar.startOfDay(for: travel.endDate) + state.expenseList.selectedDateRange = start...end + } return .none case let .travelDetailResponse(.success(travel)): state.$travel.withLock { $0 = travel } - + // 초기 날짜 범위를 전체 여행 기간으로 설정 + if state.expenseList.selectedDateRange == nil { + let calendar = Calendar.current + let start = calendar.startOfDay(for: travel.startDate) + let end = calendar.startOfDay(for: travel.endDate) + state.expenseList.selectedDateRange = start...end + } return .none case let .travelDetailResponse(.failure(error)): diff --git a/Features/Settlement/Sources/SettlementView.swift b/Features/Settlement/Sources/SettlementView.swift index 66b9230a..9d957b31 100644 --- a/Features/Settlement/Sources/SettlementView.swift +++ b/Features/Settlement/Sources/SettlementView.swift @@ -33,7 +33,7 @@ public struct SettlementView: View { send(.settingsButtonTapped) } } - .padding(.horizontal, 16) + .padding(.horizontal, 20) // 탭 바 (Custom Segmented Control) HStack(spacing: 0) { TabButton(title: "지출 내역", isSelected: store.selectedTab == 0) { @@ -43,7 +43,7 @@ public struct SettlementView: View { send(.tabSelected(1)) } } - .padding(.horizontal, 16) + .padding(.horizontal, 20) // 컨텐츠 영역 if store.selectedTab == 0 { diff --git a/Features/SettlementResult/Sources/Components/SettlementResultHeaderView.swift b/Features/SettlementResult/Sources/Components/SettlementResultHeaderView.swift index 4afdda24..ef303251 100644 --- a/Features/SettlementResult/Sources/Components/SettlementResultHeaderView.swift +++ b/Features/SettlementResult/Sources/Components/SettlementResultHeaderView.swift @@ -10,71 +10,23 @@ import DesignSystem struct SettlementResultHeaderView: View { let totalExpenseAmount: String - let myExpenseAmount: String - let totalPersonCount: Int - let averageExpensePerPerson: String - - var body: some View { - VStack(spacing: 16) { - // 총 지출 - VStack(spacing: 8) { - Text("총 지출") - .font(.app(.body, weight: .medium)) - .foregroundStyle(Color.gray7) - - Text(totalExpenseAmount) - .font(.app(.title1, weight: .semibold)) - .foregroundStyle(Color.black) - } - .padding(12) - - // 통계 정보 - HStack(spacing: 0) { - StatItemView( - label: "내 지출", - value: myExpenseAmount - ) - - StatItemView( - label: "인원 수", - value: "\(totalPersonCount)명" - ) - - StatItemView( - label: "1인 평균", - value: averageExpensePerPerson - ) - } - .padding(.vertical, 20) - .frame(maxWidth: .infinity) - } - } -} - -// MARK: - Stat Item View -private struct StatItemView: View { - let label: String - let value: String var body: some View { VStack(spacing: 8) { - Text(label) - .font(.app(.caption1, weight: .semibold)) + Text("총 지출") + .font(.app(.body, weight: .medium)) .foregroundStyle(Color.gray7) - Text(value) - .font(.app(.title3, weight: .semibold)) + Text(totalExpenseAmount) + .font(.app(.title1, weight: .semibold)) .foregroundStyle(Color.black) } - .frame(maxWidth: .infinity) + .padding(.vertical, 12) } } #Preview { SettlementResultHeaderView( - totalExpenseAmount: "124만 5,000원", - myExpenseAmount: "52만원", - totalPersonCount: 5, - averageExpensePerPerson: "24만 9,000원" + totalExpenseAmount: "124만 5,000원" ) } diff --git a/Features/SettlementResult/Sources/Components/StatItemView.swift b/Features/SettlementResult/Sources/Components/StatItemView.swift new file mode 100644 index 00000000..5eddaf6d --- /dev/null +++ b/Features/SettlementResult/Sources/Components/StatItemView.swift @@ -0,0 +1,36 @@ +// +// StatItemView.swift +// SettlementResultFeature +// +// Created by 홍석현 on 12/16/25. +// + +import SwiftUI +import DesignSystem + +struct StatItemView: View { + let label: String + let value: String + + var body: some View { + VStack(spacing: 8) { + Text(label) + .font(.app(.caption1, weight: .semibold)) + .foregroundStyle(Color.gray7) + + Text(value) + .font(.app(.title3, weight: .semibold)) + .foregroundStyle(Color.black) + } + .frame(maxWidth: .infinity) + } +} + +#Preview { + HStack(spacing: 0) { + StatItemView(label: "내 지출", value: "52만원") + StatItemView(label: "인원 수", value: "5명") + StatItemView(label: "1인 평균", value: "24만 9,000원") + } + .padding() +} diff --git a/Features/SettlementResult/Sources/SettlementResultView.swift b/Features/SettlementResult/Sources/SettlementResultView.swift index 32045749..3e76c7bf 100644 --- a/Features/SettlementResult/Sources/SettlementResultView.swift +++ b/Features/SettlementResult/Sources/SettlementResultView.swift @@ -23,80 +23,115 @@ public struct SettlementResultView: View { VStack(spacing: 0) { // 헤더 (총 지출, 통계) 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: CurrencyFormatter.formatKoreanCurrency( - store.paymentsToMake.reduce(0.0) { $0 + $1.amount } - ), - amountColor: .red, - payments: store.paymentsToMake.map { - PaymentItem( - id: $0.id, - name: $0.memberName, - amount: CurrencyFormatter.formatKoreanCurrency($0.amount) - ) - } - ) - } - - if !store.paymentsToReceive.isEmpty { - PaymentSectionView( - title: "수령 예정 금액", - 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: CurrencyFormatter.formatKoreanCurrency($0.amount) - ) - } - ) + // 총 지출 (별도) + VStack(spacing: 8) { + Text("총 지출") + .font(.app(.body, weight: .medium)) + .foregroundStyle(Color.gray7) + + Text(store.formattedTotalExpenseAmount) + .font(.app(.title1, weight: .semibold)) + .foregroundStyle(Color.black) + } + .padding(.vertical, 24) + + VStack(spacing: 0) { + // 통계 정보 (내 지출, 인원수, 1인 평균) + HStack(spacing: 0) { + StatItemView( + label: "내 지출", + value: store.formattedMyExpenseAmount + ) + + StatItemView( + label: "인원 수", + value: "\(store.totalPersonCount)명" + ) + + StatItemView( + label: "1인 평균", + value: store.formattedAveragePerPerson + ) + } + .padding(.vertical, 20) + .frame(maxWidth: .infinity) + + // 지급/수령 예정 금액 섹션 + ScrollView { + VStack(spacing: 8) { + if !store.paymentsToMake.isEmpty { + PaymentSectionView( + title: "지급 예정 금액", + 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: CurrencyFormatter.formatKoreanCurrency($0.amount) + ) + } + ) + } + + if !store.paymentsToReceive.isEmpty { + PaymentSectionView( + title: "수령 예정 금액", + 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: CurrencyFormatter.formatKoreanCurrency($0.amount) + ) + } + ) + } } + .padding(.bottom, 16) } + .scrollIndicators(.hidden) + + // 상세보기 버튼 + Button { + send(.detailButtonTapped) + } label: { + Text("정산 내역 보기") + .font(.app(.body, weight: .medium)) + .foregroundStyle(Color.gray9) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 16) + .safeAreaPadding(34) } - .scrollIndicators(.hidden) - - // 상세보기 버튼 - Button { - send(.detailButtonTapped) - } label: { - Text("정산 내역 보기") - .font(.app(.body, weight: .medium)) - .foregroundStyle(Color.gray9) - } - .frame(maxWidth: .infinity, alignment: .center) - .padding(16) + .padding(.horizontal, 20) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 20, + topTrailingRadius: 20 + ) + ) + .overlay( + UnevenRoundedRectangle( + topLeadingRadius: 20, + topTrailingRadius: 20 + ) + .stroke(Color.gray1, lineWidth: 1) + ) } else { - VStack { - Image(asset: .settlementEmpty) - .resizable() - .frame(width: 167, height: 167) - Text("정산 내역이 없습니다") - .font(.app(.title3, weight: .medium)) - } - .frame(maxHeight: .infinity) + EmptyCaseView(image: .settlementEmpty, message: "정산 내역이 없습니다") } } - .padding(.horizontal, 16) .background(Color.primary50) .onAppear { send(.onAppear) } + .ignoresSafeArea() .alert($store.scope(state: \.alert, action: \.scope.alert)) .sheet( item: $store.scope( diff --git a/Features/Travel/Sources/View/TravelSetting/Components/BasicSettingView.swift b/Features/Travel/Sources/View/TravelSetting/Components/BasicSettingView.swift index 718242d7..a838a9b1 100644 --- a/Features/Travel/Sources/View/TravelSetting/Components/BasicSettingView.swift +++ b/Features/Travel/Sources/View/TravelSetting/Components/BasicSettingView.swift @@ -158,8 +158,7 @@ struct BasicSettingView: View { private var inviteSection: some View { Button { guard let inviteCode = store.travel.inviteCode else { return } - UIPasteboard.general.string = inviteCode - ToastManager.shared.showSuccess("클립보드에 복사되었습니다.") + InviteCodeHelper.copyToClipboard(inviteCode) } label: { VStack(alignment: .leading, spacing: 8) { Text("초대 코드") @@ -180,16 +179,8 @@ struct BasicSettingView: View { Spacer() Button { - if let deepLink = store.travel.deepLink { - let activityViewController = UIActivityViewController( - activityItems: [deepLink], - applicationActivities: nil - ) - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first { - window.rootViewController?.present(activityViewController, animated: true) - } + if let deepLink = store.travel.deepLink, let url = URL(string: deepLink) { + InviteCodeHelper.shareDeepLink(url) } } label: { Image(systemName: "square.and.arrow.up")