From 8093f6b7befd6ace7ae5e6d91c53cd95bf72cf87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Wed, 10 Dec 2025 12:04:49 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EC=A7=80=EC=B6=9C=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EB=A1=9C=EC=BB=AC=20=EC=BA=90=EC=8B=B1=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20DataSource=20=EB=B0=8F=20DTO=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DTO/Expense/Local/ExpenseCache.swift | 30 +++++ .../Local/ExpenseLocalDataSource.swift | 104 ++++++++++++++++++ .../Expense/ExpenseRepository.swift | 46 ++++++++ .../Expense/CreateExpenseUseCase.swift | 53 +++++++++ 4 files changed, 233 insertions(+) create mode 100644 Data/Sources/DTO/Expense/Local/ExpenseCache.swift create mode 100644 Data/Sources/DataSource/Local/ExpenseLocalDataSource.swift create mode 100644 Data/Sources/Repository/Expense/ExpenseRepository.swift create mode 100644 Domain/Sources/UseCase/Expense/CreateExpenseUseCase.swift diff --git a/Data/Sources/DTO/Expense/Local/ExpenseCache.swift b/Data/Sources/DTO/Expense/Local/ExpenseCache.swift new file mode 100644 index 00000000..9d0bac99 --- /dev/null +++ b/Data/Sources/DTO/Expense/Local/ExpenseCache.swift @@ -0,0 +1,30 @@ +// +// ExpenseCache.swift +// Data +// +// Created by 홍석현 on 12/9/25. +// + +import Foundation + +public struct ExpenseCache: Codable { + let travelId: String + let expenses: [TravelExpenseResponseDTO.ExpenseDTO] + let cachedAt: Date + let expiredAt: Date + + var isExpired: Bool { + Date() > expiredAt + } + + init( + travelId: String, + expenses: [TravelExpenseResponseDTO.ExpenseDTO], + ) { + self.travelId = travelId + self.expenses = expenses + self.cachedAt = .now + self.expiredAt = .now.addingTimeInterval(3600) + } +} + diff --git a/Data/Sources/DataSource/Local/ExpenseLocalDataSource.swift b/Data/Sources/DataSource/Local/ExpenseLocalDataSource.swift new file mode 100644 index 00000000..5c4d796f --- /dev/null +++ b/Data/Sources/DataSource/Local/ExpenseLocalDataSource.swift @@ -0,0 +1,104 @@ +// +// ExpenseLocalDataSource.swift +// Data +// +// Created by 홍석현 on 12/9/25. +// + +import Foundation +import Domain + +public enum ExpenseCacheError: Error { + case cacheDirectoryUnavailable + case fileNotFound + case dataCorrupted +} + +public protocol ExpenseLocalDataSourceProtocol { + func loadCachedExpenses(_ travelId: String) async throws -> ExpenseCache + func saveCachedExpenses(_ cache: ExpenseCache) async throws +} + +public actor ExpenseLocalDataSource: ExpenseLocalDataSourceProtocol { + private let fileManager = FileManager.default + private lazy var cacheDirectory: URL? = { + guard var cachesURL = fileManager.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first else { + return nil + } + cachesURL.append( + path: "expenses", + directoryHint: .isDirectory + ) + try? fileManager.createDirectory( + at: cachesURL, + withIntermediateDirectories: true + ) + return cachesURL + }() + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + + public init() {} + + private func cacheURL(for travelId: String) throws -> URL { + guard var directory = cacheDirectory else { + throw ExpenseCacheError.cacheDirectoryUnavailable + } + + directory.append(path: "travel_\(travelId).json", directoryHint: .notDirectory) + return directory + } + + public func loadCachedExpenses(_ travelId: String) async throws -> ExpenseCache { + let url = try cacheURL(for: travelId) + + guard fileManager.fileExists(atPath: url.path()) else { + throw ExpenseCacheError.cacheDirectoryUnavailable + } + + do { + let data = try Data(contentsOf: url) + let cache = try decoder.decode(ExpenseCache.self, from: data) + + if cache.isExpired { + clearCache(travelId) + throw ExpenseCacheError.dataCorrupted + } + return cache + } catch { + try? fileManager.removeItem(at: url) + throw ExpenseCacheError.dataCorrupted + } + } + + public func saveCachedExpenses(_ cache: ExpenseCache) async throws { + let url = try cacheURL(for: cache.travelId) + let data = try encoder.encode(cache) + + try data.write(to: url, options: [.atomic]) + } + + private func clearCache(_ travelId: String) { + guard let directory = cacheDirectory else { return } + try? fileManager.removeItem(at: directory) + + // 디렉토리 재생성 + try? fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + } +} diff --git a/Data/Sources/Repository/Expense/ExpenseRepository.swift b/Data/Sources/Repository/Expense/ExpenseRepository.swift new file mode 100644 index 00000000..2c48887d --- /dev/null +++ b/Data/Sources/Repository/Expense/ExpenseRepository.swift @@ -0,0 +1,46 @@ +// +// ExpenseRepository.swift +// Data +// +// Created by 홍석현 on 11/28/25. +// + +import Foundation +import Domain + +public final class ExpenseRepository: ExpenseRepositoryProtocol { + + private let remote: ExpenseRemoteDataSourceProtocol + + public init(remote: ExpenseRemoteDataSourceProtocol) { + self.remote = remote + } + + public func fetchTravelExpenses( + travelId: String, + page: Int, + limit: Int + ) async throws -> [Expense] { + let responseDTO = try await remote.fetchTravelExpenses( + travelId: travelId, + page: page, + limit: limit + ) + + return responseDTO.items.compactMap { $0.toDomain() } + } + + public func save(travelId: String, expense: Expense) async throws { + let requestDTO = expense.toCreateRequestDTO() + try await remote.createExpense(travelId: travelId, body: requestDTO) + } + + public func update(travelId: String, expense: Expense) async throws { + let requestDTO = expense.toUpdateRequestDTO() + try await remote.updateExpense(travelId: travelId, expenseId: expense.id, body: requestDTO) + } + + public func delete(travelId: String, expenseId: String) async throws { + try await remote.deleteExpense(travelId: travelId, expenseId: expenseId) + } +} diff --git a/Domain/Sources/UseCase/Expense/CreateExpenseUseCase.swift b/Domain/Sources/UseCase/Expense/CreateExpenseUseCase.swift new file mode 100644 index 00000000..a624803d --- /dev/null +++ b/Domain/Sources/UseCase/Expense/CreateExpenseUseCase.swift @@ -0,0 +1,53 @@ +// +// CreateExpenseUseCase.swift +// Domain +// +// Created by 홍석현 on 11/30/25. +// + +import Foundation +import Dependencies + +public struct CreateExpenseUseCase { + private let repository: ExpenseRepositoryProtocol + + public init(repository: ExpenseRepositoryProtocol) { + self.repository = repository + } + + public func execute(travelId: String, input: CreateExpenseInput) async throws { + let expense = Expense( + id: UUID().uuidString, + title: input.title, + amount: input.amount, + currency: input.currency, + convertedAmount: input.convertedAmount, + expenseDate: input.expenseDate, + category: input.category, + payerId: input.payerId, + payerName: input.payerName, + participants: input.participants + ) + + try expense.validate() + try await repository.save(travelId: travelId, expense: expense) + } +} + +extension CreateExpenseUseCase: DependencyKey { + public static var liveValue: CreateExpenseUseCase { + @Dependency(\.expenseRepository) var repository + return CreateExpenseUseCase(repository: repository) + } + + public static var testValue: CreateExpenseUseCase { + CreateExpenseUseCase(repository: MockExpenseRepository()) + } +} + +extension DependencyValues { + public var createExpenseUseCase: CreateExpenseUseCase { + get { self[CreateExpenseUseCase.self] } + set { self[CreateExpenseUseCase.self] = newValue } + } +} From fa783573111d9502324ac25302004514aee90802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Wed, 10 Dec 2025 12:05:15 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor:=20=EC=A7=80=EC=B6=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20Repository=20=EB=B0=8F=20UseCase=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20DTO=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Request/CreateExpenseRequestDTO.swift | 10 ++-- .../Request/UpdateExpenseRequestDTO.swift | 8 ++- .../Response/TravelExpenseResponseDTO.swift | 10 ++-- .../Remote/ExpenseRemoteDataSource.swift | 4 +- .../Repository/ExpenseRepository.swift | 46 ---------------- .../ExpenseRepositoryProtocol.swift | 2 +- .../Mock/MockExpenseRepository.swift | 12 +++-- .../UseCase/CreateExpenseUseCase.swift | 53 ------------------- .../Expense/FetchTravelExpenseUseCase.swift | 44 +++++++++------ .../Mock/MockFetchTravelExpenseUseCase.swift | 8 ++- 10 files changed, 64 insertions(+), 133 deletions(-) delete mode 100644 Data/Sources/Repository/ExpenseRepository.swift delete mode 100644 Domain/Sources/UseCase/CreateExpenseUseCase.swift diff --git a/Data/Sources/DTO/Expense/Request/CreateExpenseRequestDTO.swift b/Data/Sources/DTO/Expense/Request/CreateExpenseRequestDTO.swift index 2ea749dd..0bed0b90 100644 --- a/Data/Sources/DTO/Expense/Request/CreateExpenseRequestDTO.swift +++ b/Data/Sources/DTO/Expense/Request/CreateExpenseRequestDTO.swift @@ -19,10 +19,14 @@ public struct CreateExpenseRequestDTO: Encodable { extension Expense { func toCreateRequestDTO() -> CreateExpenseRequestDTO { - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withFullDate] + // yyyy-MM-dd 형식으로 날짜 변환 (사용자가 선택한 날짜 그대로 전송) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + dateFormatter.calendar = Calendar(identifier: .gregorian) + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + // Plain date는 timezone 변환하지 않음 (기본값 = TimeZone.current) let dateString = dateFormatter.string(from: expenseDate) - + return CreateExpenseRequestDTO( title: title, amount: amount, diff --git a/Data/Sources/DTO/Expense/Request/UpdateExpenseRequestDTO.swift b/Data/Sources/DTO/Expense/Request/UpdateExpenseRequestDTO.swift index b2e1d258..033934e1 100644 --- a/Data/Sources/DTO/Expense/Request/UpdateExpenseRequestDTO.swift +++ b/Data/Sources/DTO/Expense/Request/UpdateExpenseRequestDTO.swift @@ -19,8 +19,12 @@ public struct UpdateExpenseRequestDTO: Encodable { extension Expense { func toUpdateRequestDTO() -> UpdateExpenseRequestDTO { - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withFullDate] + // yyyy-MM-dd 형식으로 날짜 변환 (사용자가 선택한 날짜 그대로 전송) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + dateFormatter.calendar = Calendar(identifier: .gregorian) + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + // Plain date는 timezone 변환하지 않음 (기본값 = TimeZone.current) let dateString = dateFormatter.string(from: expenseDate) return UpdateExpenseRequestDTO( diff --git a/Data/Sources/DTO/Expense/Response/TravelExpenseResponseDTO.swift b/Data/Sources/DTO/Expense/Response/TravelExpenseResponseDTO.swift index 4d714340..b3b43767 100644 --- a/Data/Sources/DTO/Expense/Response/TravelExpenseResponseDTO.swift +++ b/Data/Sources/DTO/Expense/Response/TravelExpenseResponseDTO.swift @@ -14,7 +14,7 @@ public struct TravelExpenseResponseDTO: Decodable { let limit: Int let items: [ExpenseDTO] - struct ExpenseDTO: Decodable { + struct ExpenseDTO: Codable { let id: String let title: String let amount: Double @@ -27,7 +27,7 @@ public struct TravelExpenseResponseDTO: Decodable { let authorId: String let participants: [ParticipantDTO] - struct ParticipantDTO: Decodable { + struct ParticipantDTO: Codable { let memberId: String let name: String @@ -41,12 +41,12 @@ public struct TravelExpenseResponseDTO: Decodable { } func toDomain() -> Expense? { - // yyyy-MM-dd 날짜 파싱 + // yyyy-MM-dd 날짜 파싱 (서버에서 받은 날짜 그대로 사용) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" dateFormatter.calendar = Calendar(identifier: .gregorian) - dateFormatter.locale = Locale(identifier: "ko_KR") - dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + // Plain date는 timezone 변환하지 않음 (기본값 = TimeZone.current) guard let date = dateFormatter.date(from: expenseDate) else { return nil diff --git a/Data/Sources/DataSource/Remote/ExpenseRemoteDataSource.swift b/Data/Sources/DataSource/Remote/ExpenseRemoteDataSource.swift index d8c795fd..51baee18 100644 --- a/Data/Sources/DataSource/Remote/ExpenseRemoteDataSource.swift +++ b/Data/Sources/DataSource/Remote/ExpenseRemoteDataSource.swift @@ -21,7 +21,7 @@ public struct ExpenseRemoteDataSource: ExpenseRemoteDataSourceProtocol { private let provider: MoyaProvider - public init(provider: MoyaProvider = MoyaProvider()) { + public init(provider: MoyaProvider = .default) { self.provider = provider } @@ -75,7 +75,7 @@ extension ExpenseAPI: BaseTargetType { return "/\(travelId)/expenses" case .createExpense(let travelId, _): return "/\(travelId)/expenses" - case .updateExpense(let travelId, let expenseId, let _): + case .updateExpense(let travelId, let expenseId, _): return "/\(travelId)/expenses/\(expenseId)" case .deleteExpense(let travelId, let expenseId): return "/\(travelId)/expenses/\(expenseId)" diff --git a/Data/Sources/Repository/ExpenseRepository.swift b/Data/Sources/Repository/ExpenseRepository.swift deleted file mode 100644 index 2c48887d..00000000 --- a/Data/Sources/Repository/ExpenseRepository.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// ExpenseRepository.swift -// Data -// -// Created by 홍석현 on 11/28/25. -// - -import Foundation -import Domain - -public final class ExpenseRepository: ExpenseRepositoryProtocol { - - private let remote: ExpenseRemoteDataSourceProtocol - - public init(remote: ExpenseRemoteDataSourceProtocol) { - self.remote = remote - } - - public func fetchTravelExpenses( - travelId: String, - page: Int, - limit: Int - ) async throws -> [Expense] { - let responseDTO = try await remote.fetchTravelExpenses( - travelId: travelId, - page: page, - limit: limit - ) - - return responseDTO.items.compactMap { $0.toDomain() } - } - - public func save(travelId: String, expense: Expense) async throws { - let requestDTO = expense.toCreateRequestDTO() - try await remote.createExpense(travelId: travelId, body: requestDTO) - } - - public func update(travelId: String, expense: Expense) async throws { - let requestDTO = expense.toUpdateRequestDTO() - try await remote.updateExpense(travelId: travelId, expenseId: expense.id, body: requestDTO) - } - - public func delete(travelId: String, expenseId: String) async throws { - try await remote.deleteExpense(travelId: travelId, expenseId: expenseId) - } -} diff --git a/Domain/Sources/Repository/ExpenseRepositoryProtocol.swift b/Domain/Sources/Repository/ExpenseRepositoryProtocol.swift index 6f79e0db..15047fec 100644 --- a/Domain/Sources/Repository/ExpenseRepositoryProtocol.swift +++ b/Domain/Sources/Repository/ExpenseRepositoryProtocol.swift @@ -10,7 +10,7 @@ import Dependencies public protocol ExpenseRepositoryProtocol { // 여행의 지출 내역 조회 - func fetchTravelExpenses(travelId: String, page: Int, limit: Int) async throws -> [Expense] + func fetchTravelExpenses(travelId: String, page: Int, limit: Int) -> AsyncStream> // 지출 내역 저장 func save(travelId: String, expense: Expense) async throws diff --git a/Domain/Sources/Repository/Mock/MockExpenseRepository.swift b/Domain/Sources/Repository/Mock/MockExpenseRepository.swift index 2d60197b..cdeffb2b 100644 --- a/Domain/Sources/Repository/Mock/MockExpenseRepository.swift +++ b/Domain/Sources/Repository/Mock/MockExpenseRepository.swift @@ -8,6 +8,7 @@ import Foundation final public actor MockExpenseRepository: ExpenseRepositoryProtocol { + private var storage: [String: Expense] = [:] private var shouldFailSave = false private var shouldFailUpdate = false @@ -34,12 +35,17 @@ final public actor MockExpenseRepository: ExpenseRepositoryProtocol { saveErrorReason = nil } - public func fetchTravelExpenses( + nonisolated public func fetchTravelExpenses( travelId: String, page: Int, limit: Int - ) async throws -> [Expense] { - return Expense.mockList + ) -> AsyncStream> { + AsyncStream { continuation in + Task { + continuation.yield(.success(Expense.mockList)) + continuation.finish() + } + } } public func save( diff --git a/Domain/Sources/UseCase/CreateExpenseUseCase.swift b/Domain/Sources/UseCase/CreateExpenseUseCase.swift deleted file mode 100644 index a624803d..00000000 --- a/Domain/Sources/UseCase/CreateExpenseUseCase.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// CreateExpenseUseCase.swift -// Domain -// -// Created by 홍석현 on 11/30/25. -// - -import Foundation -import Dependencies - -public struct CreateExpenseUseCase { - private let repository: ExpenseRepositoryProtocol - - public init(repository: ExpenseRepositoryProtocol) { - self.repository = repository - } - - public func execute(travelId: String, input: CreateExpenseInput) async throws { - let expense = Expense( - id: UUID().uuidString, - title: input.title, - amount: input.amount, - currency: input.currency, - convertedAmount: input.convertedAmount, - expenseDate: input.expenseDate, - category: input.category, - payerId: input.payerId, - payerName: input.payerName, - participants: input.participants - ) - - try expense.validate() - try await repository.save(travelId: travelId, expense: expense) - } -} - -extension CreateExpenseUseCase: DependencyKey { - public static var liveValue: CreateExpenseUseCase { - @Dependency(\.expenseRepository) var repository - return CreateExpenseUseCase(repository: repository) - } - - public static var testValue: CreateExpenseUseCase { - CreateExpenseUseCase(repository: MockExpenseRepository()) - } -} - -extension DependencyValues { - public var createExpenseUseCase: CreateExpenseUseCase { - get { self[CreateExpenseUseCase.self] } - set { self[CreateExpenseUseCase.self] = newValue } - } -} diff --git a/Domain/Sources/UseCase/Expense/FetchTravelExpenseUseCase.swift b/Domain/Sources/UseCase/Expense/FetchTravelExpenseUseCase.swift index de5a6ea3..958ca652 100644 --- a/Domain/Sources/UseCase/Expense/FetchTravelExpenseUseCase.swift +++ b/Domain/Sources/UseCase/Expense/FetchTravelExpenseUseCase.swift @@ -9,7 +9,7 @@ import Foundation import ComposableArchitecture public protocol FetchTravelExpenseUseCaseProtocol { - func execute(travelId: String, date: Date?) async throws -> [Expense] + func execute(travelId: String, date: Date?) -> AsyncStream> } public struct FetchTravelExpenseUseCase: FetchTravelExpenseUseCaseProtocol { @@ -22,22 +22,34 @@ public struct FetchTravelExpenseUseCase: FetchTravelExpenseUseCaseProtocol { public func execute( travelId: String, date: Date? - ) async throws -> [Expense] { - // TODO: 페이지네이션 처리 없이 전체 데이터를 가져오기 위해 큰 limit 사용 - // 추후 전체 조회 API가 있다면 교체 필요 - let allExpenses = try await repository.fetchTravelExpenses( - travelId: travelId, - page: 1, - limit: 1000 - ) - - if let date = date { - let calendar = Calendar.current - return allExpenses.filter { expense in - calendar.isDate(expense.expenseDate, inSameDayAs: date) + ) -> AsyncStream> { + AsyncStream { continuation in + Task { + for await result in repository.fetchTravelExpenses( + travelId: travelId, + page: 1, + limit: 1000 + ) { + // 날짜 필터링 적용 + let filteredResult = result.map { expenses in + filterExpenses(expenses, by: date) + } + + continuation.yield(filteredResult) + } + + continuation.finish() } - } else { - return allExpenses + } + } + + // 헬퍼 메서드 + private func filterExpenses(_ expenses: [Expense], by date: Date?) -> [Expense] { + guard let date = date else { return expenses } + + let calendar = Calendar.current + return expenses.filter { expense in + calendar.isDate(expense.expenseDate, inSameDayAs: date) } } } diff --git a/Domain/Sources/UseCase/Expense/Mock/MockFetchTravelExpenseUseCase.swift b/Domain/Sources/UseCase/Expense/Mock/MockFetchTravelExpenseUseCase.swift index bdf50bb2..72436bbe 100644 --- a/Domain/Sources/UseCase/Expense/Mock/MockFetchTravelExpenseUseCase.swift +++ b/Domain/Sources/UseCase/Expense/Mock/MockFetchTravelExpenseUseCase.swift @@ -10,7 +10,11 @@ import Foundation public struct MockFetchTravelExpenseUseCase: FetchTravelExpenseUseCaseProtocol { public init() {} - public func execute(travelId: String, date: Date?) async throws -> [Expense] { - return Expense.mockList + public func execute(travelId: String, date: Date?) -> AsyncStream> { + AsyncStream { continuation in + // Mock 데이터를 즉시 emit + continuation.yield(.success(Expense.mockList)) + continuation.finish() + } } } From 98ba5aa56d933f745678543be61f9864b6466bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Wed, 10 Dec 2025 12:05:39 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=A0=95=EC=82=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A7=80=EC=B6=9C=20=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=20=EB=A1=9C=EC=BB=AC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EB=A1=9C=EC=A7=81=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Features/ExpenseList/Sources/ExpenseListFeature.swift | 11 +++++------ Features/Settlement/Sources/SettlementFeature.swift | 3 +-- Features/Settlement/Sources/SettlementView.swift | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/Features/ExpenseList/Sources/ExpenseListFeature.swift b/Features/ExpenseList/Sources/ExpenseListFeature.swift index f85ca925..5cde03f8 100644 --- a/Features/ExpenseList/Sources/ExpenseListFeature.swift +++ b/Features/ExpenseList/Sources/ExpenseListFeature.swift @@ -169,18 +169,17 @@ extension ExpenseListFeature { state.isLoading = true } return .run { send in - // 전체 지출 내역 조회 (date: nil로 전체 조회) - let expensesResult = await Result { - try await fetchTravelExpenseUseCase.execute(travelId: travelId, date: nil) + // AsyncStream을 순회하며 결과 처리 + for await result in fetchTravelExpenseUseCase.execute(travelId: travelId, date: nil) { + await send(.inner(.expensesResponse(result))) } - - await send(.inner(.expensesResponse(expensesResult))) } } } // MARK: - Helper Methods - private func filterExpensesByDate(_ state: inout State, date: Date) { + private func filterExpensesByDate(_ state: inout State, date: Date?) { + guard let date = date else { return } let calendar = Calendar.current state.currentExpense = state.allExpenses.filter { expense in calendar.isDate(expense.expenseDate, inSameDayAs: date) diff --git a/Features/Settlement/Sources/SettlementFeature.swift b/Features/Settlement/Sources/SettlementFeature.swift index aa8efaf7..08cfb570 100644 --- a/Features/Settlement/Sources/SettlementFeature.swift +++ b/Features/Settlement/Sources/SettlementFeature.swift @@ -155,8 +155,7 @@ extension SettlementFeature { // Pass data to children state.expenseList.startDate = travel.startDate state.expenseList.endDate = travel.endDate - state.expenseList.selectedDate = travel.startDate // Initialize selectedDate - + state.expenseList.selectedDate = travel.startDate return .none case let .travelDetailResponse(.failure(error)): diff --git a/Features/Settlement/Sources/SettlementView.swift b/Features/Settlement/Sources/SettlementView.swift index 890cf7cd..50fc9f4c 100644 --- a/Features/Settlement/Sources/SettlementView.swift +++ b/Features/Settlement/Sources/SettlementView.swift @@ -54,7 +54,7 @@ public struct SettlementView: View { } .background(Color.primary50) .navigationBarBackButtonHidden(true) - .onAppear { + .task(id: store.travelId) { send(.onAppear) } } From 055cf2bd6318c08d51f531bdc75267c6cd3b10df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Wed, 10 Dec 2025 12:06:10 +0900 Subject: [PATCH 4/8] =?UTF-8?q?chore:=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EB=A1=9C=EA=B9=85=20=EC=84=A4=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Provider/Extension+MoyaProvider.swift | 8 ++++---- SseuDamApp/Sources/Application/LiveDependencies.swift | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/NetworkService/Sources/Provider/Extension+MoyaProvider.swift b/NetworkService/Sources/Provider/Extension+MoyaProvider.swift index 0b49ad5b..3f201dcf 100644 --- a/NetworkService/Sources/Provider/Extension+MoyaProvider.swift +++ b/NetworkService/Sources/Provider/Extension+MoyaProvider.swift @@ -33,7 +33,7 @@ public extension MoyaProvider { case 200..<400: if T.self == Void.self || response.data.isEmpty { if let value = () as? T { - #logNetwork("\(T.self) 데이터 통신 (Void)", "") +// #logNetwork("\(T.self) 데이터 통신 (Void)", "") return .success(value) } else { return .failure( @@ -49,10 +49,10 @@ public extension MoyaProvider { } else { do { let decoded: T = try response.data.decoded(as: T.self) - #logNetwork("\(T.self) 데이터 통신", decoded) +// #logNetwork("\(T.self) 데이터 통신", decoded) return .success(decoded) } catch { - #logError("DecodingError occurred: \(error.localizedDescription)") +// #logError("DecodingError occurred: \(error.localizedDescription)") return .failure(NetworkError.decodingError(underlying: error)) } } @@ -72,7 +72,7 @@ public extension MoyaProvider { } case .failure(let moyaError): - #logError("네트워크 에러 발생: \(moyaError.localizedDescription)") +// #logError("네트워크 에러 발생: \(moyaError.localizedDescription)") return .failure(NetworkError.underlying(moyaError)) } } diff --git a/SseuDamApp/Sources/Application/LiveDependencies.swift b/SseuDamApp/Sources/Application/LiveDependencies.swift index 9dd63936..c06af6a5 100644 --- a/SseuDamApp/Sources/Application/LiveDependencies.swift +++ b/SseuDamApp/Sources/Application/LiveDependencies.swift @@ -14,7 +14,10 @@ public enum LiveDependencies { public static func register(_ dependencies: inout DependencyValues) { // Repository 인스턴스 생성 (재사용) let travelRepository = TravelRepository(remote: TravelRemoteDataSource()) - let expenseRepository = ExpenseRepository(remote: ExpenseRemoteDataSource()) + let expenseRepository = ExpenseRepository( + remote: ExpenseRemoteDataSource(), + local: ExpenseLocalDataSource() + ) let travelMemberRepository = TravelMemberRepository(remote: TravelMemberRemoteDataSource()) let loginRepository = LoginRepository() let signUpRepository = SignUpRepository() From 3a35e5daec4fd233a84e241938967f53436e2331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Wed, 10 Dec 2025 12:07:34 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20ExpenseRepository=20=EB=82=B4=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20AsyncStre?= =?UTF-8?q?am=20=EB=B0=98=ED=99=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Expense/ExpenseRepository.swift | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/Data/Sources/Repository/Expense/ExpenseRepository.swift b/Data/Sources/Repository/Expense/ExpenseRepository.swift index 2c48887d..3575d1b8 100644 --- a/Data/Sources/Repository/Expense/ExpenseRepository.swift +++ b/Data/Sources/Repository/Expense/ExpenseRepository.swift @@ -11,23 +11,53 @@ import Domain public final class ExpenseRepository: ExpenseRepositoryProtocol { private let remote: ExpenseRemoteDataSourceProtocol + private let local: ExpenseLocalDataSourceProtocol - public init(remote: ExpenseRemoteDataSourceProtocol) { + public init( + remote: ExpenseRemoteDataSourceProtocol, + local: ExpenseLocalDataSourceProtocol + ) { self.remote = remote + self.local = local } public func fetchTravelExpenses( travelId: String, page: Int, limit: Int - ) async throws -> [Expense] { - let responseDTO = try await remote.fetchTravelExpenses( - travelId: travelId, - page: page, - limit: limit - ) - - return responseDTO.items.compactMap { $0.toDomain() } + ) -> AsyncStream> { + AsyncStream { continuation in + Task { + // 1. 캐시 데이터 + if let cached = try? await local.loadCachedExpenses(travelId) { + let expense = cached.expenses.compactMap { $0.toDomain() } + continuation.yield(.success(expense)) + } + + // 2. 네트워크 + do { + let responseDTO = try await remote.fetchTravelExpenses( + travelId: travelId, + page: page, + limit: limit + ) + let expenses = responseDTO.items.compactMap { $0.toDomain() } + + Task.detached { [weak self] in + let cache = ExpenseCache( + travelId: travelId, + expenses: responseDTO.items + ) + + try? await self?.local.saveCachedExpenses(cache) + } + continuation.yield(.success(expenses)) + } catch { + continuation.yield(.failure(error)) + } + continuation.finish() + } + } } public func save(travelId: String, expense: Expense) async throws { From a45e8c2e1ee585ba12aa25b922f4663b7c414453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Wed, 10 Dec 2025 14:24:12 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20ExpenseListFeature=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=ED=95=A0=EB=8B=B9=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/ExpenseListFeature.swift | 17 ++++++++++++++--- .../Settlement/Sources/SettlementFeature.swift | 4 ---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/Features/ExpenseList/Sources/ExpenseListFeature.swift b/Features/ExpenseList/Sources/ExpenseListFeature.swift index 5cde03f8..2be6f21a 100644 --- a/Features/ExpenseList/Sources/ExpenseListFeature.swift +++ b/Features/ExpenseList/Sources/ExpenseListFeature.swift @@ -20,9 +20,20 @@ public struct ExpenseListFeature { @Shared public var travel: Travel? @Shared public var allExpenses: [Expense] public var currentExpense: [Expense] = [] - public var startDate: Date = Date() - public var endDate: Date = Date() - public var selectedDate: Date = Date() + public var startDate: Date { + return travel?.startDate ?? Date() + } + public var endDate: Date { + return travel?.endDate ?? Date() + } + var _selectedDate: Date? = nil + public var selectedDate: Date { + get { + return _selectedDate ?? startDate + } set { + _selectedDate = newValue + } + } public let travelId: String public var isLoading: Bool = false @Presents public var alert: AlertState? diff --git a/Features/Settlement/Sources/SettlementFeature.swift b/Features/Settlement/Sources/SettlementFeature.swift index 08cfb570..6e4ba9eb 100644 --- a/Features/Settlement/Sources/SettlementFeature.swift +++ b/Features/Settlement/Sources/SettlementFeature.swift @@ -152,10 +152,6 @@ extension SettlementFeature { $0 = travel } - // Pass data to children - state.expenseList.startDate = travel.startDate - state.expenseList.endDate = travel.endDate - state.expenseList.selectedDate = travel.startDate return .none case let .travelDetailResponse(.failure(error)): From a1e4aed1b0257164e996501fe18998f4f7a39af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Wed, 10 Dec 2025 14:24:27 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=EC=A7=80=EC=B6=9C=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EC=99=84=EB=A3=8C=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=AC=B8=EA=B5=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Features/SaveExpense/Sources/SaveExpenseFeature.swift | 6 +++--- .../Sources/Coordinator/SettlementCoordinator.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Features/SaveExpense/Sources/SaveExpenseFeature.swift b/Features/SaveExpense/Sources/SaveExpenseFeature.swift index 43211f14..204acfe5 100644 --- a/Features/SaveExpense/Sources/SaveExpenseFeature.swift +++ b/Features/SaveExpense/Sources/SaveExpenseFeature.swift @@ -17,9 +17,9 @@ public enum ExpenseEditType { public var displayName: String { switch self { - case .create: "생성" - case .edit: "수정" - case .delete: "삭제" + case .create: "생성이" + case .edit: "수정이" + case .delete: "삭제가" } } } diff --git a/Features/Settlement/Sources/Coordinator/SettlementCoordinator.swift b/Features/Settlement/Sources/Coordinator/SettlementCoordinator.swift index f13f0c20..873bdce9 100644 --- a/Features/Settlement/Sources/Coordinator/SettlementCoordinator.swift +++ b/Features/Settlement/Sources/Coordinator/SettlementCoordinator.swift @@ -68,7 +68,7 @@ public struct SettlementCoordinator { state.routes.pop() return .run { _ in await MainActor.run { - ToastManager.shared.showSuccess("\(type.displayName)이 완료되었어요.") + ToastManager.shared.showSuccess("\(type.displayName) 완료되었어요.") } } case .router(.routeAction(_, .saveExpense(.delegate(.onTapBackButton)))): From 417937c4dcb0e895d16395b2e8a4810031bf1d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Wed, 10 Dec 2025 14:26:38 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20SettlementView=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=A1=9C=EB=93=9C=20=EC=8B=9C=EC=A0=90=EC=9D=84=20?= =?UTF-8?q?onAppear=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Features/Settlement/Sources/SettlementView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Features/Settlement/Sources/SettlementView.swift b/Features/Settlement/Sources/SettlementView.swift index 50fc9f4c..0dfbcb25 100644 --- a/Features/Settlement/Sources/SettlementView.swift +++ b/Features/Settlement/Sources/SettlementView.swift @@ -54,7 +54,7 @@ public struct SettlementView: View { } .background(Color.primary50) .navigationBarBackButtonHidden(true) - .task(id: store.travelId) { + .task { send(.onAppear) } }