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/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/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/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/Expense/ExpenseRepository.swift b/Data/Sources/Repository/Expense/ExpenseRepository.swift new file mode 100644 index 00000000..3575d1b8 --- /dev/null +++ b/Data/Sources/Repository/Expense/ExpenseRepository.swift @@ -0,0 +1,76 @@ +// +// ExpenseRepository.swift +// Data +// +// Created by 홍석현 on 11/28/25. +// + +import Foundation +import Domain + +public final class ExpenseRepository: ExpenseRepositoryProtocol { + + private let remote: ExpenseRemoteDataSourceProtocol + private let local: ExpenseLocalDataSourceProtocol + + public init( + remote: ExpenseRemoteDataSourceProtocol, + local: ExpenseLocalDataSourceProtocol + ) { + self.remote = remote + self.local = local + } + + public func fetchTravelExpenses( + travelId: String, + page: Int, + limit: Int + ) -> 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 { + 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/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/Expense/CreateExpenseUseCase.swift similarity index 100% rename from Domain/Sources/UseCase/CreateExpenseUseCase.swift rename to Domain/Sources/UseCase/Expense/CreateExpenseUseCase.swift 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() + } } } diff --git a/Features/ExpenseList/Sources/ExpenseListFeature.swift b/Features/ExpenseList/Sources/ExpenseListFeature.swift index f85ca925..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? @@ -169,18 +180,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/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)))): diff --git a/Features/Settlement/Sources/SettlementFeature.swift b/Features/Settlement/Sources/SettlementFeature.swift index aa8efaf7..6e4ba9eb 100644 --- a/Features/Settlement/Sources/SettlementFeature.swift +++ b/Features/Settlement/Sources/SettlementFeature.swift @@ -152,11 +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 // Initialize selectedDate - return .none case let .travelDetailResponse(.failure(error)): diff --git a/Features/Settlement/Sources/SettlementView.swift b/Features/Settlement/Sources/SettlementView.swift index 890cf7cd..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) - .onAppear { + .task { send(.onAppear) } } 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()