diff --git a/Data/Sources/Cache/TravelCacheModel.swift b/Data/Sources/Cache/TravelCacheModel.swift new file mode 100644 index 00000000..98a5fbd0 --- /dev/null +++ b/Data/Sources/Cache/TravelCacheModel.swift @@ -0,0 +1,208 @@ +// +// TravelCacheModel.swift +// Data +// +// Created by 김민희 on 12/15/25. +// + +import Foundation +import SwiftData +import Domain + +@Model +final class TravelCacheEntity { + @Attribute(.unique) var statusRawValue: String + var cachedAt: Date + @Relationship(deleteRule: .cascade) + var travels: [TravelCacheItemEntity] + + init( + status: TravelStatus, + cachedAt: Date, + travels: [TravelCacheItemEntity] = [] + ) { + self.statusRawValue = status.rawValue + self.cachedAt = cachedAt + self.travels = travels + } + + var status: TravelStatus { + TravelStatus(rawValue: statusRawValue) ?? .unknown + } + + // 날짜가 변경되면 캐시 초기화 + var isExpired: Bool { + !Calendar.current.isDate(cachedAt, inSameDayAs: Date()) + } +} + +@Model +final class TravelCacheItemEntity { + // 여행 기본 정보 + var id: String + var title: String + var startDate: Date + var endDate: Date + // 국가 / 통화 관련 + var countryCode: String + var koreanCountryName: String + var baseCurrency: String + var baseExchangeRate: Double + var destinationCurrency: String + // 초대 관련 + var inviteCode: String? + var deepLink: String? + // 상태 / 역할 + var statusRawValue: String + var role: String? + // 생성 정보 + var createdAt: Date + var ownerName: String + // 사용 통화 목록 + var currencies: [String] + // 정렬 순서 (UI 표시용) + var orderIndex: Int + // 여행 멤버 목록 + // 여행 삭제 시 멤버도 함께 삭제 + @Relationship(deleteRule: .cascade) + var members: [TravelCacheMemberEntity] + + init( + id: String, + title: String, + startDate: Date, + endDate: Date, + countryCode: String, + koreanCountryName: String, + baseCurrency: String, + baseExchangeRate: Double, + destinationCurrency: String, + inviteCode: String?, + deepLink: String?, + statusRawValue: String, + role: String?, + createdAt: Date, + ownerName: String, + members: [TravelCacheMemberEntity] = [], + currencies: [String] = [], + orderIndex: Int + ) { + self.id = id + self.title = title + self.startDate = startDate + self.endDate = endDate + self.countryCode = countryCode + self.koreanCountryName = koreanCountryName + self.baseCurrency = baseCurrency + self.baseExchangeRate = baseExchangeRate + self.destinationCurrency = destinationCurrency + self.inviteCode = inviteCode + self.deepLink = deepLink + self.statusRawValue = statusRawValue + self.role = role + self.createdAt = createdAt + self.ownerName = ownerName + self.members = members + self.currencies = currencies + self.orderIndex = orderIndex + } + + var status: TravelStatus { + TravelStatus(rawValue: statusRawValue) ?? .unknown + } + + func toDomain() -> Travel { + Travel( + id: id, + title: title, + startDate: startDate, + endDate: endDate, + countryCode: countryCode, + koreanCountryName: koreanCountryName, + baseCurrency: baseCurrency, + baseExchangeRate: baseExchangeRate, + destinationCurrency: destinationCurrency, + inviteCode: inviteCode, + deepLink: deepLink, + status: status, + role: role, + createdAt: createdAt, + ownerName: ownerName, + members: members.map { $0.toDomain() }, + currencies: currencies.isEmpty ? nil : currencies + ) + } +} + +@Model +final class TravelCacheMemberEntity { + var id: String + var name: String + var roleRawValue: String + var email: String? + var avatarUrl: String? + init( + id: String, + name: String, + roleRawValue: String, + email: String?, + avatarUrl: String? + ) { + self.id = id + self.name = name + self.roleRawValue = roleRawValue + self.email = email + self.avatarUrl = avatarUrl + } + + var role: MemberRole { + MemberRole(rawValue: roleRawValue) ?? .member + } + + func toDomain() -> TravelMember { + TravelMember( + id: id, + name: name, + role: role, + email: email, + avatarUrl: avatarUrl + ) + } +} + +extension Travel { + func toCacheModel(orderIndex: Int) -> TravelCacheItemEntity { + TravelCacheItemEntity( + id: id, + title: title, + startDate: startDate, + endDate: endDate, + countryCode: countryCode, + koreanCountryName: koreanCountryName, + baseCurrency: baseCurrency, + baseExchangeRate: baseExchangeRate, + destinationCurrency: destinationCurrency, + inviteCode: inviteCode, + deepLink: deepLink, + statusRawValue: status.rawValue, + role: role, + createdAt: createdAt, + ownerName: ownerName, + members: members.map { $0.toCacheModel() }, + currencies: currencies ?? [], + orderIndex: orderIndex + ) + } +} + +extension TravelMember { + func toCacheModel() -> TravelCacheMemberEntity { + TravelCacheMemberEntity( + id: id, + name: name, + roleRawValue: role.rawValue, + email: email, + avatarUrl: avatarUrl + ) + } +} diff --git a/Data/Sources/DTO/Travel/TravelCacheDTO.swift b/Data/Sources/DTO/Travel/TravelCacheDTO.swift deleted file mode 100644 index d9308988..00000000 --- a/Data/Sources/DTO/Travel/TravelCacheDTO.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// TravelCacheDTO.swift -// Data -// -// Created by 김민희 on 12/15/25. -// - -import Foundation -import Domain - -public struct TravelCacheDTO: Codable { - let statusRawValue: String - let cachedAt: Date - let travels: [TravelCacheItemDTO] - - var status: TravelStatus { - TravelStatus(rawValue: statusRawValue) ?? .unknown - } - - var isExpired: Bool { - Date().timeIntervalSince(cachedAt) > TravelCacheConstants.expiration - } -} - -public struct TravelCacheItemDTO: Codable { - let id: String - let title: String - let startDate: Date - let endDate: Date - let countryCode: String - let koreanCountryName: String - let baseCurrency: String - let baseExchangeRate: Double - let destinationCurrency: String - let inviteCode: String? - let deepLink: String? - let statusRawValue: String - let role: String? - let createdAt: Date - let ownerName: String - let members: [TravelCacheMemberDTO] - let currencies: [String]? - - var status: TravelStatus { - TravelStatus(rawValue: statusRawValue) ?? .unknown - } -} - -struct TravelCacheMemberDTO: Codable { - let id: String - let name: String - let role: String - let email: String? - let avatarUrl: String? -} - -enum TravelCacheConstants { - static let directoryName = "travels" - static let expiration: TimeInterval = 60 * 60 * 6 // 6 hours -} - -extension TravelCacheItemDTO { - func toDomain() -> Travel { - Travel( - id: id, - title: title, - startDate: startDate, - endDate: endDate, - countryCode: countryCode, - koreanCountryName: koreanCountryName, - baseCurrency: baseCurrency, - baseExchangeRate: baseExchangeRate, - destinationCurrency: destinationCurrency, - inviteCode: inviteCode, - deepLink: deepLink, - status: status, - role: role, - createdAt: createdAt, - ownerName: ownerName, - members: members.map { $0.toDomain() }, - currencies: currencies - ) - } -} - -extension TravelCacheMemberDTO { - func toDomain() -> TravelMember { - TravelMember( - id: id, - name: name, - role: MemberRole(value: role), - email: email, - avatarUrl: avatarUrl - ) - } -} - -extension Travel { - func toCacheItem() -> TravelCacheItemDTO { - TravelCacheItemDTO( - id: id, - title: title, - startDate: startDate, - endDate: endDate, - countryCode: countryCode, - koreanCountryName: koreanCountryName, - baseCurrency: baseCurrency, - baseExchangeRate: baseExchangeRate, - destinationCurrency: destinationCurrency, - inviteCode: inviteCode, - deepLink: deepLink, - statusRawValue: status.rawValue, - role: role, - createdAt: createdAt, - ownerName: ownerName, - members: members.map { $0.toCacheItem() }, - currencies: currencies - ) - } -} - -extension TravelMember { - func toCacheItem() -> TravelCacheMemberDTO { - TravelCacheMemberDTO( - id: id, - name: name, - role: role.rawValue, - email: email, - avatarUrl: avatarUrl - ) - } -} diff --git a/Data/Sources/DataSource/Local/TravelLocalDataSource.swift b/Data/Sources/DataSource/Local/TravelLocalDataSource.swift index d5478d97..cc2f05d6 100644 --- a/Data/Sources/DataSource/Local/TravelLocalDataSource.swift +++ b/Data/Sources/DataSource/Local/TravelLocalDataSource.swift @@ -6,138 +6,90 @@ // import Foundation +import SwiftData import Domain -public enum TravelCacheError: Error { - case cacheDirectoryUnavailable - case fileNotFound - case dataCorrupted -} - public protocol TravelLocalDataSourceProtocol: Actor { - func observe(status: TravelStatus) -> AsyncStream<[TravelCacheItemDTO]> - func load(status: TravelStatus) async throws -> TravelCacheDTO? - func save(_ cache: TravelCacheDTO) async throws - func clear(status: TravelStatus) + func load(status: TravelStatus) async throws -> [Travel]? + func save(travels: [Travel], status: TravelStatus) async throws + func clear(status: TravelStatus) async throws } public actor TravelLocalDataSource: TravelLocalDataSourceProtocol { - 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: TravelCacheConstants.directoryName, - 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 - }() - - // 여행 상태별로 AsyncStream 보관 - private var observers: [TravelStatus: [UUID: AsyncStream<[TravelCacheItemDTO]>.Continuation]] = [:] - - public init() {} - - public func observe(status: TravelStatus) -> AsyncStream<[TravelCacheItemDTO]> { - // 캐시 파일이 갱신될 때마다 새로운 배열을 내보내며, 가지고 있는 캐시가 있다면 즉시 전달한다. - AsyncStream { continuation in - Task { [weak self] in - guard let self else { return } - let id = UUID() - continuation.onTermination = { [weak self] _ in - Task { await self?.removeObserver(status: status, id: id) } - } - await self.storeObserver(status: status, id: id, continuation: continuation) - if let cache = try? await self.load(status: status) { - continuation.yield(cache.travels) - } + private let container: ModelContainer + + public init(container: ModelContainer? = nil) { + if let container { + self.container = container + } else { + let schema = Schema([ + TravelCacheEntity.self, + TravelCacheItemEntity.self, + TravelCacheMemberEntity.self + ]) + do { + self.container = try ModelContainer( + for: schema, + configurations: ModelConfiguration(isStoredInMemoryOnly: false) + ) + } catch { + fatalError("Failed to create Travel cache container: \(error)") } } } - public func load(status: TravelStatus) async throws -> TravelCacheDTO? { - let url = try cacheURL(for: status) - guard fileManager.fileExists(atPath: url.path()) else { + public func load(status: TravelStatus) async throws -> [Travel]? { + let context = ModelContext(container) + guard let cache = try fetchCache(for: status, in: context) else { return nil } - do { - let data = try Data(contentsOf: url) - let cache = try decoder.decode(TravelCacheDTO.self, from: data) - if cache.isExpired { - try? fileManager.removeItem(at: url) - return nil - } - return cache - } catch { - try? fileManager.removeItem(at: url) - throw TravelCacheError.dataCorrupted + // 캐시 시간 만료되면 삭제 + if cache.isExpired { + context.delete(cache) + try context.save() + return nil } + // api로 받은 리스트랑 순서 같도록 정렬 + let sorted = cache.travels.sorted { $0.orderIndex < $1.orderIndex } + return sorted.map { $0.toDomain() } } - public func save(_ cache: TravelCacheDTO) async throws { - let url = try cacheURL(for: cache.status) - let data = try encoder.encode(cache) - try data.write(to: url, options: [.atomic]) - notifyObservers(status: cache.status, travels: cache.travels) - } - - public func clear(status: TravelStatus) { - guard let url = try? cacheURL(for: status) else { return } - try? fileManager.removeItem(at: url) - } -} + public func save(travels: [Travel], status: TravelStatus) async throws { + let context = ModelContext(container) + if let existing = try fetchCache(for: status, in: context) { + context.delete(existing) + } -private extension TravelLocalDataSource { - func cacheURL(for status: TravelStatus) throws -> URL { - guard var directory = cacheDirectory else { - throw TravelCacheError.cacheDirectoryUnavailable + let cache = TravelCacheEntity(status: status, cachedAt: Date()) + // 순서 유지하기 위해 index 저장 + cache.travels = travels.enumerated().map { index, travel in + travel.toCacheModel(orderIndex: index) } - directory.append( - path: "travel_\(status.rawValue).json", - directoryHint: .notDirectory - ) - return directory - } - func storeObserver( - status: TravelStatus, - id: UUID, - continuation: AsyncStream<[TravelCacheItemDTO]>.Continuation - ) { - var continuations = observers[status] ?? [:] - continuations[id] = continuation - observers[status] = continuations + context.insert(cache) + try context.save() } - func removeObserver(status: TravelStatus, id: UUID) { - var continuations = observers[status] ?? [:] - continuations[id] = nil - observers[status] = continuations.isEmpty ? nil : continuations + public func clear(status: TravelStatus) async throws { + let context = ModelContext(container) + guard let cache = try fetchCache(for: status, in: context) else { return } + context.delete(cache) + try context.save() } +} - func notifyObservers(status: TravelStatus, travels: [TravelCacheItemDTO]) { - guard let continuations = observers[status] else { return } - continuations.values.forEach { $0.yield(travels) } +private extension TravelLocalDataSource { + func fetchCache( + for status: TravelStatus, + in context: ModelContext + ) throws -> TravelCacheEntity? { + var descriptor = FetchDescriptor() + // TravelCacheEntity 호출 시 travels도 같이 호출 + descriptor.relationshipKeyPathsForPrefetching = [ + \TravelCacheEntity.travels + ] + let caches = try context.fetch(descriptor) + return caches.first { $0.statusRawValue == status.rawValue } } } diff --git a/Data/Sources/Repository/Travel/TravelRepository.swift b/Data/Sources/Repository/Travel/TravelRepository.swift index 73151011..03ff2cf2 100644 --- a/Data/Sources/Repository/Travel/TravelRepository.swift +++ b/Data/Sources/Repository/Travel/TravelRepository.swift @@ -38,6 +38,12 @@ public final class TravelRepository: TravelRepositoryProtocol { return travels } + public func loadCachedTravels( + status: TravelStatus + ) async throws -> [Travel]? { + try await local.load(status: status) + } + public func createTravel( input: CreateTravelInput ) async throws -> Travel { @@ -68,24 +74,6 @@ public final class TravelRepository: TravelRepositoryProtocol { return responseDTO.toDomain() } - // 로컬 캐시 파일이 갱신될 때마다 스트림을 통해 최신 Travel 배열을 방출 - public func observeCachedTravels(status: TravelStatus) -> AsyncStream<[Travel]> { - AsyncStream { continuation in - let task = Task { - let baseStream = await local.observe(status: status) - for await cacheItems in baseStream { - guard !Task.isCancelled else { break } - let travels = cacheItems.map { $0.toDomain() } - continuation.yield(travels) - } - continuation.finish() - } - - continuation.onTermination = { _ in - task.cancel() - } - } - } } private extension TravelRepository { @@ -94,24 +82,19 @@ private extension TravelRepository { status: TravelStatus, appendExisting: Bool ) async throws { - var cacheItems = travels.map { $0.toCacheItem() } + var cacheItems = travels if appendExisting, - let existing = try? await local.load(status: status)?.travels { + let existing = try await local.load(status: status) { cacheItems = existing + cacheItems } let deduped = deduplicate(items: cacheItems) - let cache = TravelCacheDTO( - statusRawValue: status.rawValue, - cachedAt: Date(), - travels: deduped - ) - try await local.save(cache) + try await local.save(travels: deduped, status: status) } // 한 번 캐시된 여행은 다시 저장하지 않도록 ID 기반으로 중복을 제거한다. - func deduplicate(items: [TravelCacheItemDTO]) -> [TravelCacheItemDTO] { + func deduplicate(items: [Travel]) -> [Travel] { var seen = Set() return items.filter { item in guard !seen.contains(item.id) else { return false } diff --git a/Domain/Sources/Repository/Travel/MockTravelRepository.swift b/Domain/Sources/Repository/Travel/MockTravelRepository.swift index 07c4b91d..e7311b37 100644 --- a/Domain/Sources/Repository/Travel/MockTravelRepository.swift +++ b/Domain/Sources/Repository/Travel/MockTravelRepository.swift @@ -97,12 +97,9 @@ public final class MockTravelRepository: TravelRepositoryProtocol { travels.removeAll { $0.id == id } } - public func observeCachedTravels(status: TravelStatus) -> AsyncStream<[Travel]> { - AsyncStream { continuation in - let filtered = travels.filter { $0.status == status } - continuation.yield(filtered) - continuation.finish() - } + public func loadCachedTravels(status: TravelStatus) async throws -> [Travel]? { + let filtered = travels.filter { $0.status == status } + return filtered.isEmpty ? nil : filtered } } diff --git a/Domain/Sources/Repository/Travel/TravelRepositoryProtocol.swift b/Domain/Sources/Repository/Travel/TravelRepositoryProtocol.swift index 4735c95a..615c07bb 100644 --- a/Domain/Sources/Repository/Travel/TravelRepositoryProtocol.swift +++ b/Domain/Sources/Repository/Travel/TravelRepositoryProtocol.swift @@ -13,5 +13,5 @@ public protocol TravelRepositoryProtocol { func updateTravel(id: String, input: UpdateTravelInput) async throws -> Travel func deleteTravel(id: String) async throws func fetchTravelDetail(id: String) async throws -> Travel - func observeCachedTravels(status: TravelStatus) -> AsyncStream<[Travel]> + func loadCachedTravels(status: TravelStatus) async throws -> [Travel]? } diff --git a/Domain/Sources/UseCase/Travel/LoadTravelCacheUseCase.swift b/Domain/Sources/UseCase/Travel/LoadTravelCacheUseCase.swift new file mode 100644 index 00000000..2a67c136 --- /dev/null +++ b/Domain/Sources/UseCase/Travel/LoadTravelCacheUseCase.swift @@ -0,0 +1,46 @@ +// +// LoadTravelCacheUseCase.swift +// Domain +// +// Created by 김민희 on 12/15/25. +// + +import Foundation +import Dependencies + +public protocol LoadTravelCacheUseCaseProtocol { + func execute(status: TravelStatus) async throws -> [Travel]? +} + +public struct LoadTravelCacheUseCase: LoadTravelCacheUseCaseProtocol { + private let repository: TravelRepositoryProtocol + + public init(repository: TravelRepositoryProtocol) { + self.repository = repository + } + + public func execute(status: TravelStatus) async throws -> [Travel]? { + try await repository.loadCachedTravels(status: status) + } +} + +extension LoadTravelCacheUseCase: DependencyKey { + public static var liveValue: LoadTravelCacheUseCaseProtocol = { + LoadTravelCacheUseCase(repository: MockTravelRepository()) + }() + + public static var previewValue: LoadTravelCacheUseCaseProtocol = { + LoadTravelCacheUseCase(repository: MockTravelRepository()) + }() + + public static var testValue: LoadTravelCacheUseCaseProtocol = { + LoadTravelCacheUseCase(repository: MockTravelRepository()) + }() +} + +public extension DependencyValues { + var loadTravelCacheUseCase: LoadTravelCacheUseCaseProtocol { + get { self[LoadTravelCacheUseCase.self] } + set { self[LoadTravelCacheUseCase.self] = newValue } + } +} diff --git a/Domain/Sources/UseCase/Travel/ObserveTravelCacheUseCase.swift b/Domain/Sources/UseCase/Travel/ObserveTravelCacheUseCase.swift deleted file mode 100644 index 12191f55..00000000 --- a/Domain/Sources/UseCase/Travel/ObserveTravelCacheUseCase.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// ObserveTravelCacheUseCase.swift -// Domain -// -// Created by 김민희 on 12/15/25. -// - -import Foundation -import Dependencies - -public protocol ObserveTravelCacheUseCaseProtocol { - func execute(status: TravelStatus) -> AsyncStream<[Travel]> -} - -public struct ObserveTravelCacheUseCase: ObserveTravelCacheUseCaseProtocol { - private let repository: TravelRepositoryProtocol - - public init(repository: TravelRepositoryProtocol) { - self.repository = repository - } - - public func execute(status: TravelStatus) -> AsyncStream<[Travel]> { - repository.observeCachedTravels(status: status) - } -} - -extension ObserveTravelCacheUseCase: DependencyKey { - public static var liveValue: ObserveTravelCacheUseCaseProtocol = { - ObserveTravelCacheUseCase(repository: MockTravelRepository()) - }() - - public static var previewValue: ObserveTravelCacheUseCaseProtocol = { - ObserveTravelCacheUseCase(repository: MockTravelRepository()) - }() - - public static var testValue: ObserveTravelCacheUseCaseProtocol = { - ObserveTravelCacheUseCase(repository: MockTravelRepository()) - }() -} - -public extension DependencyValues { - var observeTravelCacheUseCase: ObserveTravelCacheUseCaseProtocol { - get { self[ObserveTravelCacheUseCase.self] } - set { self[ObserveTravelCacheUseCase.self] = newValue } - } -} diff --git a/Features/Travel/Demo/Sources/TravelDemoApp.swift b/Features/Travel/Demo/Sources/TravelDemoApp.swift index 15605a00..4f8751ff 100644 --- a/Features/Travel/Demo/Sources/TravelDemoApp.swift +++ b/Features/Travel/Demo/Sources/TravelDemoApp.swift @@ -28,7 +28,7 @@ struct TravelDemoApp: App { TravelListFeature() } withDependencies: { $0.fetchTravelsUseCase = FetchTravelsUseCase(repository: repo) - $0.observeTravelCacheUseCase = ObserveTravelCacheUseCase(repository: repo) + $0.loadTravelCacheUseCase = LoadTravelCacheUseCase(repository: repo) $0.createTravelUseCase = CreateTravelUseCase(repository: repo) } diff --git a/Features/Travel/Sources/Reducer/TravelListFeature.swift b/Features/Travel/Sources/Reducer/TravelListFeature.swift index 34d2da05..9c73a642 100644 --- a/Features/Travel/Sources/Reducer/TravelListFeature.swift +++ b/Features/Travel/Sources/Reducer/TravelListFeature.swift @@ -17,7 +17,6 @@ public struct TravelListFeature { var travels: [Travel] = [] var selectedTab: TravelTab = .ongoing var cachedTravelsByTab: [TravelTab: [Travel]] = [:] - var didStartObservation = false var isMenuOpen = false @@ -31,6 +30,14 @@ public struct TravelListFeature { var isPresentInvitationView: Bool = false var inviteCode: String = "" + var hasCacheForSelectedTab: Bool { + cachedTravelsByTab[selectedTab] != nil + } + + var shouldShowSkeleton: Bool { + isLoading && !hasCacheForSelectedTab && travels.isEmpty + } + @Presents var create: TravelCreateFeature.State? public init(pendingInviteCode: String? = nil) { @@ -46,7 +53,6 @@ public struct TravelListFeature { case refresh case fetch case fetchNextPageIfNeeded(currentItemID: String?) - case startObserveCache(TravelTab) case cachedTravelsUpdated(tab: TravelTab, travels: [Travel]) case fetchTravelsResponse(tab: TravelTab, page: Int, Result<[Travel], Error>) @@ -70,32 +76,13 @@ public struct TravelListFeature { @Dependency(\.fetchTravelsUseCase) var fetchTravelsUseCase @Dependency(\.joinTravelUseCase) var joinTravelUseCase - @Dependency(\.observeTravelCacheUseCase) var observeTravelCacheUseCase + @Dependency(\.loadTravelCacheUseCase) var loadTravelCacheUseCase public var body: some Reducer { Reduce { state, action in switch action { case .onAppear: - // 탭별 캐시 스트림을 한번만 구독하고 서버 데이터를 요청 - guard !state.didStartObservation else { - return .send(.refresh) - } - state.didStartObservation = true - return .run { send in - for tab in TravelTab.allCases { - await send(.startObserveCache(tab)) - } - await send(.refresh) - } - - case .startObserveCache(let tab): - return .run { [tab] send in - let stream = observeTravelCacheUseCase.execute(status: tab.status) - for await travels in stream { - await send(.cachedTravelsUpdated(tab: tab, travels: travels)) - } - } - .cancellable(id: TravelCacheObservationID(tab: tab), cancelInFlight: true) + return .send(.refresh) case .cachedTravelsUpdated(let tab, let travels): state.cachedTravelsByTab[tab] = travels @@ -141,7 +128,13 @@ public struct TravelListFeature { status: currentTab.status ) - return .run { [currentTab, currentPage] send in + let loadCacheUseCase = loadTravelCacheUseCase + return .run { [currentTab, currentPage, loadCacheUseCase] send in + if currentPage == 1, + let cached = try? await loadCacheUseCase.execute(status: currentTab.status), + !cached.isEmpty { + await send(.cachedTravelsUpdated(tab: currentTab, travels: cached)) + } do { let result = try await fetchTravelsUseCase.execute(input: input) await send(.fetchTravelsResponse(tab: currentTab, page: currentPage, .success(result))) @@ -280,10 +273,6 @@ public struct TravelListFeature { } } -private struct TravelCacheObservationID: Hashable { - let tab: TravelTab -} - // 서버에서 동일 데이터를 여러 번 수신하더라도 한 번만 노출되도록 ID 기준으로 정리 private func deduplicate(_ travels: [Travel]) -> [Travel] { var seen = Set() diff --git a/Features/Travel/Sources/View/Travels/TravelView.swift b/Features/Travel/Sources/View/Travels/TravelView.swift index 1dd9bdc2..71389b1f 100644 --- a/Features/Travel/Sources/View/Travels/TravelView.swift +++ b/Features/Travel/Sources/View/Travels/TravelView.swift @@ -19,56 +19,50 @@ public struct TravelView: View { } public var body: some View { - let hasCacheForTab = store.cachedTravelsByTab[store.selectedTab] != nil - let shouldShowSkeleton = store.isLoading && !hasCacheForTab && store.travels.isEmpty - - return ZStack { - Color.primary50.ignoresSafeArea() - - VStack { - TravelListHeaderView { - store.send(.profileButtonTapped) - } + return VStack { + TravelListHeaderView { + store.send(.profileButtonTapped) + } - TabBarView(selectedTab: $store.selectedTab.sending(\.travelTabSelected)) - .padding(.horizontal, 20) + TabBarView(selectedTab: $store.selectedTab.sending(\.travelTabSelected)) + .padding(.horizontal, 20) - Group { - if shouldShowSkeleton { - TravelListSkeletonView() - } else if store.travels.isEmpty { - TravelEmptyView() - } else { - ScrollView { - LazyVStack(spacing: 18) { - ForEach(store.travels, id: \.id) { travel in - TravelCardView(travel: travel) - .onAppear { - store.send(.fetchNextPageIfNeeded(currentItemID: travel.id)) - } - .onTapGesture { - store.send(.travelSelected(travelId: travel.id)) - } - } + Group { + if store.shouldShowSkeleton { + TravelListSkeletonView() + } else if store.travels.isEmpty { + TravelEmptyView() + } else { + ScrollView { + LazyVStack(spacing: 18) { + ForEach(store.travels, id: \.id) { travel in + TravelCardView(travel: travel) + .onAppear { + store.send(.fetchNextPageIfNeeded(currentItemID: travel.id)) + } + .onTapGesture { + store.send(.travelSelected(travelId: travel.id)) + } + } - if store.isLoadingNextPage { - ProgressView().padding(.vertical, 20) - } + if store.isLoadingNextPage { + ProgressView().padding(.vertical, 20) } - .padding(16) - } - .refreshable { - store.send(.refresh) } + .padding(16) + } + .refreshable { + store.send(.refresh) } } } } + .background(Color.primary50) .task { store.send(.onAppear) } .overlay(alignment: .bottomTrailing) { - if !shouldShowSkeleton { + if !store.shouldShowSkeleton { ZStack(alignment: .bottomTrailing) { if store.isMenuOpen { TravelCreateMenuView( diff --git a/SseuDamApp/Sources/Application/LiveDependencies.swift b/SseuDamApp/Sources/Application/LiveDependencies.swift index a7163d66..71374731 100644 --- a/SseuDamApp/Sources/Application/LiveDependencies.swift +++ b/SseuDamApp/Sources/Application/LiveDependencies.swift @@ -48,7 +48,7 @@ public enum LiveDependencies { // Travel dependencies.fetchTravelsUseCase = FetchTravelsUseCase(repository: travelRepository) - dependencies.observeTravelCacheUseCase = ObserveTravelCacheUseCase(repository: travelRepository) + dependencies.loadTravelCacheUseCase = LoadTravelCacheUseCase(repository: travelRepository) dependencies.createTravelUseCase = CreateTravelUseCase(repository: travelRepository) dependencies.fetchTravelDetailUseCase = FetchTravelDetailUseCase(repository: travelRepository) dependencies.updateTravelUseCase = UpdateTravelUseCase(repository: travelRepository)