diff --git a/CALENDAR.md b/CALENDAR.md new file mode 100644 index 0000000..ea0e2b3 --- /dev/null +++ b/CALENDAR.md @@ -0,0 +1,73 @@ +## Calendar Feature 구조와 흐름 +CalendarView -> dori가 있는 날을 터치시 바텀시트가 올라옴 +바텀시트: DayDetailSheet + + +## API +GET /dori/list +diection은 DoriSegmentControl 로 결정 됨 +judori -> Out, baddori -> In + +- [query]: +direction: String (IN/OUT) +year: String +month: String + +- [header] +Authorization: Bearer JWT - AuthInterceptor에게 위임된 작업 + +- 응답 +SuccessResponse 작성할 것 +- 달력의 year/month에 따른 응답 값 inDoriDayList를 달력에 점으로 표시하면 됨 +- inDoriList, outDoriList 로 바텀시트에 row로 사용하면 됨 +- DoriSegmentControl direction에 따른 inDoriTotalAmount, outDoriTotalAmount가 상단 총 주도리에 표시됨 +{ + "success": true, + "data": { + "userId": 1, + "year": 2024, + "month": 5, + "inDoriTotalAmount": 100000, + "inDoriDayList": [ + 1, + 2 + ], + "inDoriList": [ + { + "doriId": 1, + "userId": 1, + "partnerId": 1, + "direction": "IN", + "partnerName": "홍길동", + "relationship": "지인", + "eventType": "생일", + "amount": 50000, + "eventDate": "2024-05-01", + "isVisited": true, + "memo": "메모", + "createdAt": "2026-02-17T09:27:50.658160145" + } + ], + "outDoriTotalAmount": 50000, + "outDoriDayList": [ + 3 + ], + "outDoriList": [ + { + "doriId": 2, + "userId": 1, + "partnerId": 2, + "direction": "OUT", + "partnerName": "김영희", + "relationship": "동료", + "eventType": "결혼", + "amount": 50000, + "eventDate": "2024-05-03", + "isVisited": false, + "memo": null, + "createdAt": "2026-02-17T09:27:50.658193668" + } + ] + }, + "error": null +} diff --git a/Projects/App/Sources/DoriApp.swift b/Projects/App/Sources/DoriApp.swift index b5f2929..622c535 100644 --- a/Projects/App/Sources/DoriApp.swift +++ b/Projects/App/Sources/DoriApp.swift @@ -14,6 +14,7 @@ import FeatureMyPage import FeatureOnboarding import FeatureAddDori import FeatureHistory +import FeatureCalendar import PlatformKakaoAuth import PlatformKeychain @@ -47,9 +48,8 @@ struct DoriApp: App { ) $0.addDoriAPIClient = .live(networkService: networkService) - + $0.calendarClient = .live(networkService: networkService) $0.historyAPIClient = .live(networkService: networkService) - $0.myPageAPIClient = .live( networkService: networkService, tokenStore: tokenStore diff --git a/Projects/Feature/AddDori/Sources/Modifier/AddDoriModifiers.swift b/Projects/Feature/AddDori/Sources/Modifier/AddDoriModifiers.swift deleted file mode 100644 index 96265d7..0000000 --- a/Projects/Feature/AddDori/Sources/Modifier/AddDoriModifiers.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// AddDoriModifiers.swift -// DoriFeature -// -// Created by 강동영 on 2/19/26. -// Copyright © 2026 com.arex. All rights reserved. -// - -import SwiftUI -import DoriDesignSystem - diff --git a/Projects/Feature/Calendar/Sources/CalendarClient.swift b/Projects/Feature/Calendar/Sources/CalendarClient.swift new file mode 100644 index 0000000..5b31e3b --- /dev/null +++ b/Projects/Feature/Calendar/Sources/CalendarClient.swift @@ -0,0 +1,256 @@ +// +// CalendarClient.swift +// Dori-iOS +// +// Created by 강동영 on 2/17/26. +// + +import Foundation +import ComposableArchitecture +import DoriCore +import DoriNetwork + +public struct CalendarDori: Identifiable, Sendable, Codable, Hashable { + public let id: Int64 + public let type: TransactionType + public let partnerName: String + public let relationship: String + public let eventType: String + public let amount: Int + public let eventDate: Date + public let isVisited: Bool + public let memo: String? + + public init( + id: Int64, + type: TransactionType, + partnerName: String, + relationship: String, + eventType: String, + amount: Int, + eventDate: Date, + isVisited: Bool, + memo: String? + ) { + self.id = id + self.type = type + self.partnerName = partnerName + self.relationship = relationship + self.eventType = eventType + self.amount = amount + self.eventDate = eventDate + self.isVisited = isVisited + self.memo = memo + } +} + +public struct CalendarMonthlyData: Sendable, Equatable { + public let year: Int + public let month: Int + public let inDoriTotalAmount: Int + public let inDoriDayList: [Int] + public let inDoriList: [CalendarDori] + public let outDoriTotalAmount: Int + public let outDoriDayList: [Int] + public let outDoriList: [CalendarDori] + + public init( + year: Int, + month: Int, + inDoriTotalAmount: Int, + inDoriDayList: [Int], + inDoriList: [CalendarDori], + outDoriTotalAmount: Int, + outDoriDayList: [Int], + outDoriList: [CalendarDori] + ) { + self.year = year + self.month = month + self.inDoriTotalAmount = inDoriTotalAmount + self.inDoriDayList = inDoriDayList + self.inDoriList = inDoriList + self.outDoriTotalAmount = outDoriTotalAmount + self.outDoriDayList = outDoriDayList + self.outDoriList = outDoriList + } + + public static func empty(for month: Date) -> Self { + Self( + year: month.year, + month: month.month, + inDoriTotalAmount: 0, + inDoriDayList: [], + inDoriList: [], + outDoriTotalAmount: 0, + outDoriDayList: [], + outDoriList: [] + ) + } +} + +@DependencyClient +public struct CalendarClient: Sendable { + public var fetchMonthlyData: @Sendable ( + _ month: Date, + _ type: TransactionType + ) async throws -> CalendarMonthlyData +} + +private enum CalendarClientError: LocalizedError { + case unconfigured + case invalidResponse + case backendError(String) + case invalidEventDate(String) + + var errorDescription: String? { + switch self { + case .unconfigured: + return "CalendarClient가 구성되지 않았습니다." + case .invalidResponse: + return "서버 응답이 올바르지 않습니다." + case .backendError(let message): + return message + case .invalidEventDate(let value): + return "올바르지 않은 날짜 형식입니다: \(value)" + } + } +} + +extension CalendarClient: DependencyKey { + public static let liveValue = Self( + fetchMonthlyData: { _, _ in throw CalendarClientError.unconfigured } + ) + + public static let previewValue = Self( + fetchMonthlyData: { month, _ in + let calendar = Calendar(identifier: .gregorian) + + func makeDate(day: Int) -> Date { + calendar.date(from: DateComponents(year: month.year, month: month.month, day: day)) ?? month + } + + return CalendarMonthlyData( + year: month.year, + month: month.month, + inDoriTotalAmount: 100_000, + inDoriDayList: [1, 2, 8], + inDoriList: [ + CalendarDori( + id: 1, + type: .baddori, + partnerName: "홍길동", + relationship: "지인", + eventType: "생일", + amount: 50_000, + eventDate: makeDate(day: 1), + isVisited: true, + memo: "메모" + ) + ], + outDoriTotalAmount: 50_000, + outDoriDayList: [3, 5], + outDoriList: [ + CalendarDori( + id: 2, + type: .judori, + partnerName: "김영희", + relationship: "동료", + eventType: "결혼", + amount: 50_000, + eventDate: makeDate(day: 3), + isVisited: false, + memo: nil + ) + ] + ) + } + ) + + public static let testValue = Self() +} + +public extension CalendarClient { + static func live(networkService: any NetworkService) -> Self { + Self( + fetchMonthlyData: { month, type in + let request = DoriListRequest( + direction: type.calendarDirection, + year: String(month.year), + month: String(month.month) + ) + let endpoint = DoriListEndpoint(request: request) + let response = try await networkService.request( + endpoint, + responseType: SuccessResponse.self + ) + + if let apiError = response.error { + throw CalendarClientError.backendError(apiError.message ?? apiError.code) + } + + guard response.success, let data = response.data else { + throw CalendarClientError.invalidResponse + } + + return CalendarMonthlyData( + year: data.year, + month: data.month, + inDoriTotalAmount: data.inDoriTotalAmount, + inDoriDayList: data.inDoriDayList, + inDoriList: try data.inDoriList.map { try $0.toDomain(type: .baddori) }, + outDoriTotalAmount: data.outDoriTotalAmount, + outDoriDayList: data.outDoriDayList, + outDoriList: try data.outDoriList.map { try $0.toDomain(type: .judori) } + ) + } + ) + } +} + +public extension DependencyValues { + var calendarClient: CalendarClient { + get { self[CalendarClient.self] } + set { self[CalendarClient.self] = newValue } + } +} + +private extension TransactionType { + var calendarDirection: String { + switch self { + case .judori: + return "OUT" + case .baddori: + return "IN" + } + } +} + +private extension DoriListItemDTO { + func toDomain(type: TransactionType) throws -> CalendarDori { + guard let parsedEventDate = eventDate.calendarEventDate else { + throw CalendarClientError.invalidEventDate(eventDate) + } + + return CalendarDori( + id: doriId, + type: type, + partnerName: partnerName, + relationship: relationship, + eventType: eventType, + amount: amount, + eventDate: parsedEventDate, + isVisited: isVisited, + memo: memo + ) + } +} + +private extension String { + var calendarEventDate: Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: self) + } +} diff --git a/Projects/Feature/Calendar/Sources/CalendarFeature.swift b/Projects/Feature/Calendar/Sources/CalendarFeature.swift index 723f0b0..e5aa911 100644 --- a/Projects/Feature/Calendar/Sources/CalendarFeature.swift +++ b/Projects/Feature/Calendar/Sources/CalendarFeature.swift @@ -6,9 +6,9 @@ // import ComposableArchitecture -import SwiftUI -import DoriDesignSystem +import Foundation import FeatureAddDori +import DoriCore @Reducer public struct CalendarFeature { @@ -18,73 +18,225 @@ public struct CalendarFeature { public struct State: Equatable, Sendable { @Presents public var addDori: AddDoriFeature.State? - public init() {} + public var currentMonth: Date + public var selectedType: TransactionType = .judori + public var calendarData: CalendarMonthlyData + public var calendarDays: [CalendarDay] = [] + public var selectedDay: CalendarDay? + public var dayDoris: [CalendarDori] = [] + public var errorMessage: String? + + public var totalAmount: Int { + switch selectedType { + case .judori: + return calendarData.outDoriTotalAmount + case .baddori: + return calendarData.inDoriTotalAmount + } + } + + public init(currentMonth: Date = Date()) { + self.currentMonth = currentMonth + self.calendarData = .empty(for: currentMonth) + } } public enum Action: Equatable, Sendable { case onAppear case fabTapped case addDori(PresentationAction) + case goToPreviousMonth + case goToNextMonth + case selectedTypeChanged(TransactionType) + case calendarDataResponse(CalendarMonthlyData) + case calendarDataFailed(String) + case dayTapped(CalendarDay) + case sheetDismissed } + @Dependency(\.calendarClient) var calendarClient + public var body: some ReducerOf { Reduce { state, action in switch action { case .onAppear: - return .none - + state.errorMessage = nil + return fetchMonthlyData(month: state.currentMonth, type: state.selectedType) + case .fabTapped: state.addDori = AddDoriFeature.State() return .none - + case .addDori(.presented(.delegate(.doriCreated))): state.addDori = nil return .none - + case .addDori(.presented(.delegate(.dismissed))): state.addDori = nil return .none - + case .addDori: return .none + + case .goToPreviousMonth: + state.currentMonth = state.currentMonth.previousMonth + state.selectedDay = nil + state.dayDoris = [] + return fetchMonthlyData(month: state.currentMonth, type: state.selectedType) + + case .goToNextMonth: + state.currentMonth = state.currentMonth.nextMonth + state.selectedDay = nil + state.dayDoris = [] + return fetchMonthlyData(month: state.currentMonth, type: state.selectedType) + + case let .selectedTypeChanged(type): + state.selectedType = type + state.selectedDay = nil + state.dayDoris = [] + state.errorMessage = nil + return fetchMonthlyData(month: state.currentMonth, type: state.selectedType) + + case let .calendarDataResponse(data): + state.calendarData = data + state.errorMessage = nil + buildCalendarDays(state: &state) + updateSelectedDayDoris(state: &state) + return .none + + case let .calendarDataFailed(message): + state.calendarData = .empty(for: state.currentMonth) + state.errorMessage = message + state.selectedDay = nil + state.dayDoris = [] + buildCalendarDays(state: &state) + return .none + + case let .dayTapped(day): + guard day.isCurrentMonth, let date = day.date else { return .none } + state.selectedDay = day + state.dayDoris = currentTypeDoris(state: state).filter { $0.eventDate.isSameDay(as: date) } + return .none + + case .sheetDismissed: + state.selectedDay = nil + state.dayDoris = [] + return .none } } .ifLet(\.$addDori, action: \.addDori) { AddDoriFeature() } } -} - -public struct CalendarView: View { - @Bindable var store: StoreOf - public init(store: StoreOf) { - self.store = store + private func fetchMonthlyData(month: Date, type: TransactionType) -> Effect { + let client = calendarClient + return .run { send in + do { + let data = try await client.fetchMonthlyData(month, type) + await send(.calendarDataResponse(data)) + } catch { + await send(.calendarDataFailed(error.localizedDescription)) + } + } } - public var body: some View { - NavigationStack { - ZStack(alignment: .bottomTrailing) { - Text("캘린더") - .frame( - maxWidth: .infinity, - maxHeight: .infinity - ) - - FloatingActionButton { - store.send(.fabTapped) - } - .padding(20) - } - .onAppear { store.send(.onAppear) } - .navigationDestination( - item: $store.scope( - state: \.addDori, - action: \.addDori + private func buildCalendarDays(state: inout State) { + let currentMonth = state.currentMonth + let transactionDaySet = Set(currentTypeDayList(state: state)) + + let calendar = Calendar.current + let startOfMonth = currentMonth.startOfMonth + let firstWeekday = currentMonth.firstWeekdayOfMonth // 일=1, 월=2, ... + let daysInMonth = currentMonth.daysInMonth + let prefixCount = firstWeekday - 1 + + // 이전달 정보 + let previousMonth = currentMonth.previousMonth + let daysInPreviousMonth = previousMonth.daysInMonth + + var days: [CalendarDay] = [] + + // 이전달 날짜 채우기 + for i in 0.. [Int] { + switch state.selectedType { + case .judori: + return state.calendarData.outDoriDayList + case .baddori: + return state.calendarData.inDoriDayList + } + } + + private func currentTypeDoris(state: State) -> [CalendarDori] { + switch state.selectedType { + case .judori: + return state.calendarData.outDoriList + case .baddori: + return state.calendarData.inDoriList } } } diff --git a/Projects/Feature/Calendar/Sources/CalendarView.swift b/Projects/Feature/Calendar/Sources/CalendarView.swift new file mode 100644 index 0000000..23a0644 --- /dev/null +++ b/Projects/Feature/Calendar/Sources/CalendarView.swift @@ -0,0 +1,96 @@ +// +// CalendarView.swift +// Dori-iOS +// +// Created by 강동영 on 2/5/26. +// + +import SwiftUI +import ComposableArchitecture +import DoriDesignSystem + + +public struct CalendarView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + NavigationStack { + ZStack { + UIAsset.Colors.doriWhite.color + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 20) { + // 총 금액 표시 + CalendarTotalAmountView(store.selectedType, totalAmount: store.totalAmount) + + HStack(spacing: 0) { + // 월 선택기 + MonthSelectorView( + currentMonth: store.currentMonth, + onPrevious: { store.send(.goToPreviousMonth) }, + onNext: { store.send(.goToNextMonth) } + ) + + Spacer() + + // 세그먼트 컨트롤 + DoriSegmentControl( + selectedType: $store.selectedType.sending(\.selectedTypeChanged) + ) + .frame(maxWidth: 120) + .frame(height: 32) + } + + // 캘린더 그리드 + CalendarGridView( + days: store.calendarDays, + selectedType: store.selectedType, + onDayTapped: { day in + store.send(.dayTapped(day)) + } + ) + + if let errorMessage = store.errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + .padding(.horizontal) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 80) + } + } + .navigationTitle("캘린더") + .toolbarTitleDisplayMode(.inline) + .onAppear { store.send(.onAppear) } + .sheet( + isPresented: Binding( + get: { store.selectedDay != nil }, + set: { if !$0 { store.send(.sheetDismissed) } } + ) + ) { + if let selectedDay = store.selectedDay, let date = selectedDay.date { + DayDetailSheet( + date: date, + doris: store.dayDoris + ) + .presentationDetents([.medium, .large]) + } + } + } + } +} + +#Preview { + CalendarView( + store: Store(initialState: CalendarFeature.State()) { + CalendarFeature() + } + ) +} diff --git a/Projects/Feature/Calendar/Sources/Components/CalendarGridView.swift b/Projects/Feature/Calendar/Sources/Components/CalendarGridView.swift new file mode 100644 index 0000000..1f4a045 --- /dev/null +++ b/Projects/Feature/Calendar/Sources/Components/CalendarGridView.swift @@ -0,0 +1,144 @@ +// +// CalendarGridView.swift +// Dori-iOS +// +// Created by 강동영 on 2/5/26. +// + +import SwiftUI +import DoriDesignSystem +import DoriCore + +public struct CalendarGridView: View { + let days: [CalendarDay] + let selectedType: TransactionType + let onDayTapped: (CalendarDay) -> Void + + private let columns = Array(repeating: GridItem(.flexible(), spacing: 0), count: 7) + private let weekdays = ["일", "월", "화", "수", "목", "금", "토"] + + public init( + days: [CalendarDay], + selectedType: TransactionType, + onDayTapped: @escaping (CalendarDay) -> Void + ) { + self.days = days + self.selectedType = selectedType + self.onDayTapped = onDayTapped + } + + public var body: some View { + VStack(spacing: 0) { + // 요일 헤더 + LazyVGrid(columns: columns, spacing: 8) { + ForEach(weekdays, id: \.self) { weekday in + Text(weekday) + .pretendard(.regular(.r13)) + .foregroundStyle(.grey400) + .padding(.bottom, 10) + } + } + + // 날짜 그리드 (5줄 고정) + LazyVGrid(columns: columns, spacing: 8) { + ForEach(days) { calendarDay in + CalendarDayCell( + day: calendarDay, + selectedType: selectedType + ) + .onTapGesture { + onDayTapped(calendarDay) + } + } + } + } + .padding() + .background(.doriWhite) + .cornerRadius(10) + } +} + +struct CalendarDayCell: View { + let day: CalendarDay + let selectedType: TransactionType + + + var textColor: UIAsset.Colors { + if day.isCurrentMonth { + if isToday { return .doriWhite } + return .doriBlack + } else { + return .grey400 + } + } + + var isTodayCircleColor: Color { + isToday ? UIAsset.Colors.secondary.color : .clear + } + + var dotColor: UIAsset.Colors { + return selectedType == .judori ? .secondary : .grey600 + } + + var isToday: Bool { + guard let day = day.date else {return false } + return Date().isSameDay(as: day) + } + + var body: some View { + VStack(spacing: 7) { + textContent + + dotContent + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .padding(.top, 8) + .padding(.bottom, 26) + .background(alignment: .top, content: { + Rectangle() + .frame(width: 50, height: 0.5) + .foregroundStyle(.grey300) + }) + } + + var textContent: some View { + Text("\(day.day)") + .pretendard(.medium(.m15)) + .foregroundStyle(textColor) + .padding(8) + .background( + Circle() + .fill(isTodayCircleColor) + ) + } + @ViewBuilder + var dotContent: some View { + if day.isCurrentMonth && day.hasTransaction { + Circle() + .fill(dotColor) + .frame(width: 6, height: 6) + } else { + Circle() + .fill(.clear) + .frame(width: 6, height: 6) + } + } +} + +#Preview { + CalendarGridView( + days: (0..<35).map { index in + CalendarDay( + id: index, + day: index < 2 ? 29 + index : (index - 1 > 28 ? index - 29 : index - 1), + hasTransaction: [5, 10, 15].contains(index), + isCurrentMonth: index >= 2 && index <= 29 + ) + }, + selectedType: .judori, + onDayTapped: { _ in } + ) + .padding() +} + diff --git a/Projects/Feature/Calendar/Sources/Components/DayDetailSheet.swift b/Projects/Feature/Calendar/Sources/Components/DayDetailSheet.swift new file mode 100644 index 0000000..bd357a5 --- /dev/null +++ b/Projects/Feature/Calendar/Sources/Components/DayDetailSheet.swift @@ -0,0 +1,211 @@ +// +// DayDetailSheet.swift +// Dori-iOS +// +// Created by 강동영 on 2/17/26. +// + +import SwiftUI +import DoriDesignSystem +import DoriCore + +struct DayDetailSheet: View { + let date: Date + let doris: [CalendarDori] + + var body: some View { + VStack(spacing: 24) { + HStack { + Text(date.koreanDateWithWeekday) + .pretendard(.title(.t1)) + Spacer() + } + .padding(.horizontal, 4) + .padding(.bottom, 4) + + ForEach(doris, id: \.self) { dori in + CalendarDoriRow(dori: dori) + } + + Spacer() + + } + .padding(.horizontal, 16) + .padding(.vertical, 30) + .background(.doriWhite) + } +} + +#Preview { + DayDetailSheet( + date: Date(), + doris: [ + CalendarDori( + id: 1, + type: .judori, + partnerName: "김철수", + relationship: "친구", + eventType: "결혼", + amount: 50000, + eventDate: Date(), + isVisited: true, + memo: "축하합니다" + ), + CalendarDori( + id: 2, + type: .baddori, + partnerName: "이영희", + relationship: "동료", + eventType: "생일", + amount: 30000, + eventDate: Date(), + isVisited: false, + memo: nil + ), + ] + ) +} + +private struct CalendarDoriRow: View { + private let dori: CalendarDori + private let isChevronHidden: Bool + + public init( + dori: CalendarDori, + isChevronHidden: Bool = false + ) { + self.dori = dori + self.isChevronHidden = isChevronHidden + } + + private var isJudori: Bool { + dori.type == .judori + } + + private var eventIcon: UIAsset.Icons { + let eventType = EventType(rawValue: dori.eventType) + if isJudori { + switch eventType { + case .wedding: return .judoriWedding + case .funeral: return .judoriFuneral + case .firstBirthday: return .judoriFirstBirthday + case .housewarming: return .judoriHousewarming + case .birthday: return .judoriBirthday + default: return .judoriEtc + } + } else { + switch eventType { + case .wedding: return .baddoriWedding + case .funeral: return .baddoriFuneral + case .firstBirthday: return .baddoriFirstBirthday + case .housewarming: return .baddoriHousewarming + case .birthday: return .baddoriBirthday + default: return .baddoriEtc + } + } + } + + public var body: some View { + HStack { + eventIcon.image + .resizable() + .frame(width: 34, height: 34) + + VStack(spacing: 4) { + HStack(spacing: 2) { + Text(dori.partnerName) + .pretendard(.body(.b4)) + .foregroundStyle(.doriBlack) + + Text(dori.relationship) + .pretendard(.caption(.m2)) + .foregroundStyle(.grey600) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 5) + .foregroundStyle(.grey100) + ) + + Spacer() + } + + HStack(spacing: 4) { + Text(dori.eventType) + + if !(dori.memo ?? "").isEmpty { + Rectangle() + .frame(width: 1, height: 10) + Text((dori.memo ?? "")) + } + + Spacer() + } + .pretendard(.body(.r6)) + .foregroundStyle(.grey600) + } + + + Spacer() + + HStack(spacing: 8) { + AmountLabel(Int(dori.amount)) + .pretendard(.body(.sb2)) + .foregroundStyle(.doriBlack) + } + } + .padding(.vertical, 8) + } + +} + +// MARK: - Preview + +//#Preview { +// VStack { +// CalendarDoriRow( +// dori: Dori( +// doriId: 1, +// userId: 1, +// partnerId: 1, +// direction: .judori, +// partnerName: "홍길동", +// relationship: "친구", +// eventType: "설날 세뱃돈", +// amount: 100_000, +// eventDate: "2026-01-01", +// isVisited: true, +// memo: "조카에게", +// createdAt: "2026-01-01" +// ) +// ) +// +// CalendarDoriRow( +// dori: Dori( +// doriId: 2, +// userId: 1, +// partnerId: 1, +// direction: .baddori, +// partnerName: "홍길동", +// relationship: "친구", +// eventType: "생일 선물", +// amount: 50_000, +// eventDate: "2026-02-01", +// isVisited: true, +// memo: "", +// createdAt: "2026-02-01" +// ) +// ) +// } +// .padding() +//} + +import ComposableArchitecture + +#Preview { + CalendarView( + store: Store(initialState: CalendarFeature.State()) { + CalendarFeature() + } + ) +} diff --git a/Projects/Feature/Calendar/Sources/Components/DoriSegmentControl.swift b/Projects/Feature/Calendar/Sources/Components/DoriSegmentControl.swift new file mode 100644 index 0000000..fc03174 --- /dev/null +++ b/Projects/Feature/Calendar/Sources/Components/DoriSegmentControl.swift @@ -0,0 +1,76 @@ +import SwiftUI +import DoriCore + +public struct DoriSegmentControl: View { + @Binding var selectedType: TransactionType + private let items: [TransactionType] + + @Namespace private var indicatorNS + + public init( + selectedType: Binding, + items: [TransactionType] = TransactionType.allCases + ) { + self._selectedType = selectedType + self.items = items + } + + public var body: some View { + HStack(spacing: 0) { + ForEach(items, id: \.self) { item in + segmentButton(item) + } + } + .padding(4) // 바깥 캡슐과 선택 캡슐 사이 여백 + .background( + RoundedRectangle(cornerRadius: 10) + .fill(.grey100) + ) + } + + private func segmentButton(_ item: TransactionType) -> some View { + Button { + withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { + selectedType = item + } + } label: { + ZStack { + // 선택 인디케이터: "하나"만 존재하고 matchedGeometryEffect로 이동 + if selectedType == item { + RoundedRectangle(cornerRadius: 10) + .fill(selectedType == .judori ? .secondary : .grey600) + .matchedGeometryEffect(id: "dori.segment.indicator", in: indicatorNS) + } + + Text(item.displayName) + .pretendard(selectedType == item ? .caption(.b1) : .body(.m5)) + .foregroundStyle(selectedType == item ? .doriWhite : .doriBlack) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .padding(.horizontal, 10) + .contentShape(Rectangle()) + } + } + .accessibilityLabel(Text(item.rawValue)) + .accessibilityAddTraits(selectedType == item ? .isSelected : []) + } +} + +#Preview { + struct PreviewWrapper: View { + @State private var selectedType: TransactionType = .judori + + var body: some View { + VStack { + Spacer() + DoriSegmentControl(selectedType: $selectedType) + .frame(width: 130,height: 32) + + Text("선택됨: \(selectedType.rawValue)") + Spacer() + } + } + } + + return PreviewWrapper() +} diff --git a/Projects/Feature/Calendar/Sources/Components/MonthSelectorView.swift b/Projects/Feature/Calendar/Sources/Components/MonthSelectorView.swift new file mode 100644 index 0000000..255bf40 --- /dev/null +++ b/Projects/Feature/Calendar/Sources/Components/MonthSelectorView.swift @@ -0,0 +1,48 @@ +import SwiftUI +import DoriCore + +public struct MonthSelectorView: View { + let currentMonth: Date + let onPrevious: () -> Void + let onNext: () -> Void + + public init(currentMonth: Date, onPrevious: @escaping () -> Void, onNext: @escaping () -> Void) { + self.currentMonth = currentMonth + self.onPrevious = onPrevious + self.onNext = onNext + } + + public var body: some View { + HStack(spacing: 20) { + Button { + onPrevious() + } label: { + Image(systemName: "chevron.left") + .font(.title3) + .foregroundColor(.primary) + } + + Text(currentMonth.koreanMonth) + .font(.title2) + .fontWeight(.semibold) + + Button { + onNext() + } label: { + Image(systemName: "chevron.right") + .font(.title3) + .foregroundColor(.primary) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } +} + +#Preview { + MonthSelectorView( + currentMonth: Date(), + onPrevious: {}, + onNext: {} + ) +} diff --git a/Projects/Feature/Calendar/Sources/Components/TotalAmountView.swift b/Projects/Feature/Calendar/Sources/Components/TotalAmountView.swift new file mode 100644 index 0000000..5cc7efc --- /dev/null +++ b/Projects/Feature/Calendar/Sources/Components/TotalAmountView.swift @@ -0,0 +1,50 @@ +// +// TotalAmountView.swift +// DoriFeature +// +// Created by 강동영 on 2/24/26. +// Copyright © 2026 com.arex. All rights reserved. +// + +import SwiftUI +import DoriCore +import DoriDesignSystem + +struct CalendarTotalAmountView: View { + private let selectedType: TransactionType + private let totalAmount: Int + + init( + _ selectedType: TransactionType, + totalAmount: Int + ) { + self.selectedType = selectedType + self.totalAmount = totalAmount + } + + var body: some View { + HStack(spacing: 0) { + Text("총 \(selectedType.displayName)") + .pretendard(.body(.m3)) + + Spacer() + + AmountLabel(totalAmount) + .pretendard(.body(.sb3)) + } + .padding(.horizontal, 16) + .padding(.vertical, 11) + .frame(maxWidth: .infinity) + .frame(height: 46) + .foregroundStyle(selectedType == .judori ? .doriWhite : .grey600) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(selectedType == .judori ? .secondary : .grey100) + ) + } +} + +#Preview { + CalendarTotalAmountView(.judori, totalAmount: 2_500_000) + CalendarTotalAmountView(.baddori, totalAmount: 2_500_000) +} diff --git a/Projects/Feature/Calendar/Sources/Domain/CalendarDay.swift b/Projects/Feature/Calendar/Sources/Domain/CalendarDay.swift new file mode 100644 index 0000000..bc947f1 --- /dev/null +++ b/Projects/Feature/Calendar/Sources/Domain/CalendarDay.swift @@ -0,0 +1,33 @@ +// +// CalendarDay.swift +// Dori-iOS +// +// Created by 강동영 on 2/5/26. +// + +import Foundation + +public struct CalendarDay: Identifiable, Equatable, Sendable { + public let id: Int + public let day: Int + public let date: Date? + public let amount: Int? + public let hasTransaction: Bool + public let isCurrentMonth: Bool + + public init( + id: Int, + day: Int, + date: Date? = nil, + amount: Int? = nil, + hasTransaction: Bool = false, + isCurrentMonth: Bool = true, + ) { + self.id = id + self.day = day + self.date = date + self.amount = amount + self.hasTransaction = hasTransaction + self.isCurrentMonth = isCurrentMonth + } +} diff --git a/Projects/Feature/Calendar/Sources/Domain/Person.swift b/Projects/Feature/Calendar/Sources/Domain/Person.swift new file mode 100644 index 0000000..df4b446 --- /dev/null +++ b/Projects/Feature/Calendar/Sources/Domain/Person.swift @@ -0,0 +1,14 @@ +import Foundation + +public struct Person: Identifiable, Codable, Equatable, Sendable { + public let id: UUID + public var name: String + + public init( + id: UUID = UUID(), + name: String + ) { + self.id = id + self.name = name + } +} diff --git a/Projects/Feature/History/Sources/Components/TransactionRowView.swift b/Projects/Feature/History/Sources/Components/TransactionRowView.swift index 12ba069..8f457c5 100644 --- a/Projects/Feature/History/Sources/Components/TransactionRowView.swift +++ b/Projects/Feature/History/Sources/Components/TransactionRowView.swift @@ -60,8 +60,8 @@ public struct TransactionRowView: View { .pretendard(.semiBold(.sb14)) .foregroundStyle(.doriBlack) - if !(dori.memo ?? "").isEmpty { - Text(dori.memo ?? "") + if !dori.memo.isEmpty { + Text(dori.memo) .pretendard(.regular(.r12)) .foregroundStyle(.grey600) } diff --git a/Projects/Feature/Project.swift b/Projects/Feature/Project.swift index 3e038cb..3e9bdb9 100644 --- a/Projects/Feature/Project.swift +++ b/Projects/Feature/Project.swift @@ -36,6 +36,7 @@ let project = Project.dori( DoriModules.addDori.module.targetDependency, DoriModules.designSystem.module.projectDependency, DoriModules.core.module.projectDependency, + DoriModules.network.module.projectDependency, .external(.composableArchitecture) ] ), diff --git a/Projects/Infra/DoriNetwork/Sources/Endpoints/DoriEndpoints.swift b/Projects/Infra/DoriNetwork/Sources/Endpoints/DoriEndpoints.swift index 6cf2d63..eafb7b5 100644 --- a/Projects/Infra/DoriNetwork/Sources/Endpoints/DoriEndpoints.swift +++ b/Projects/Infra/DoriNetwork/Sources/Endpoints/DoriEndpoints.swift @@ -49,3 +49,21 @@ public struct UpdateDoriEndpoint: Endpoint { self.body = try? JSONEncoder().encode(request) } } + +public struct DoriListEndpoint: Endpoint { + public let baseURL: String + public let path: String = "/dori/list" + public let method: HTTPMethod = .GET + public let headers: [String: String] = [:] + public let queryParameters: [String: String] + public let body: Data? = nil + + public init(request: DoriListRequest, baseURL: String = NetworkConfig.baseURL) { + self.baseURL = baseURL + self.queryParameters = [ + "direction": request.direction, + "year": request.year, + "month": request.month + ] + } +} diff --git a/Projects/Infra/DoriNetwork/Sources/Requests/DoriRequests.swift b/Projects/Infra/DoriNetwork/Sources/Requests/DoriRequests.swift index 36e98f2..e832047 100644 --- a/Projects/Infra/DoriNetwork/Sources/Requests/DoriRequests.swift +++ b/Projects/Infra/DoriNetwork/Sources/Requests/DoriRequests.swift @@ -66,3 +66,19 @@ public struct DoriUpdateRequest: Codable, Equatable, Sendable { self.memo = memo } } + +public struct DoriListRequest: Codable, Equatable, Sendable { + public let direction: String + public let year: String + public let month: String + + public init( + direction: String, + year: String, + month: String + ) { + self.direction = direction + self.year = year + self.month = month + } +} diff --git a/Projects/Infra/DoriNetwork/Sources/Responses/DoriResponses.swift b/Projects/Infra/DoriNetwork/Sources/Responses/DoriResponses.swift index 972363a..b63f4e0 100644 --- a/Projects/Infra/DoriNetwork/Sources/Responses/DoriResponses.swift +++ b/Projects/Infra/DoriNetwork/Sources/Responses/DoriResponses.swift @@ -52,4 +52,80 @@ public struct DoriResponsesDTO: Codable, Equatable, Sendable { } } +public struct DoriListResponseDTO: Codable, Equatable, Sendable { + public let userId: Int64 + public let year: Int + public let month: Int + public let inDoriTotalAmount: Int + public let inDoriDayList: [Int] + public let inDoriList: [DoriListItemDTO] + public let outDoriTotalAmount: Int + public let outDoriDayList: [Int] + public let outDoriList: [DoriListItemDTO] + + public init( + userId: Int64, + year: Int, + month: Int, + inDoriTotalAmount: Int, + inDoriDayList: [Int], + inDoriList: [DoriListItemDTO], + outDoriTotalAmount: Int, + outDoriDayList: [Int], + outDoriList: [DoriListItemDTO] + ) { + self.userId = userId + self.year = year + self.month = month + self.inDoriTotalAmount = inDoriTotalAmount + self.inDoriDayList = inDoriDayList + self.inDoriList = inDoriList + self.outDoriTotalAmount = outDoriTotalAmount + self.outDoriDayList = outDoriDayList + self.outDoriList = outDoriList + } +} + +public struct DoriListItemDTO: Codable, Equatable, Sendable { + public let doriId: Int64 + public let userId: Int64 + public let partnerId: Int64 + public let direction: String + public let partnerName: String + public let relationship: String + public let eventType: String + public let amount: Int + public let eventDate: String + public let isVisited: Bool + public let memo: String? + public let createdAt: String + + public init( + doriId: Int64, + userId: Int64, + partnerId: Int64, + direction: String, + partnerName: String, + relationship: String, + eventType: String, + amount: Int, + eventDate: String, + isVisited: Bool, + memo: String?, + createdAt: String + ) { + self.doriId = doriId + self.userId = userId + self.partnerId = partnerId + self.direction = direction + self.partnerName = partnerName + self.relationship = relationship + self.eventType = eventType + self.amount = amount + self.eventDate = eventDate + self.isVisited = isVisited + self.memo = memo + self.createdAt = createdAt + } +}