diff --git a/Data/Sources/DTO/Travel/TravelCacheDTO.swift b/Data/Sources/DTO/Travel/TravelCacheDTO.swift new file mode 100644 index 00000000..d9308988 --- /dev/null +++ b/Data/Sources/DTO/Travel/TravelCacheDTO.swift @@ -0,0 +1,132 @@ +// +// 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 new file mode 100644 index 00000000..d5478d97 --- /dev/null +++ b/Data/Sources/DataSource/Local/TravelLocalDataSource.swift @@ -0,0 +1,143 @@ +// +// TravelLocalDataSource.swift +// Data +// +// Created by 김민희 on 12/15/25. +// + +import Foundation +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) +} + +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) + } + } + } + } + + public func load(status: TravelStatus) async throws -> TravelCacheDTO? { + let url = try cacheURL(for: status) + guard fileManager.fileExists(atPath: url.path()) 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 + } + } + + 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) + } +} + +private extension TravelLocalDataSource { + func cacheURL(for status: TravelStatus) throws -> URL { + guard var directory = cacheDirectory else { + throw TravelCacheError.cacheDirectoryUnavailable + } + 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 + } + + func removeObserver(status: TravelStatus, id: UUID) { + var continuations = observers[status] ?? [:] + continuations[id] = nil + observers[status] = continuations.isEmpty ? nil : continuations + } + + func notifyObservers(status: TravelStatus, travels: [TravelCacheItemDTO]) { + guard let continuations = observers[status] else { return } + continuations.values.forEach { $0.yield(travels) } + } +} diff --git a/Data/Sources/Repository/Travel/TravelRepository.swift b/Data/Sources/Repository/Travel/TravelRepository.swift index 33614c36..73151011 100644 --- a/Data/Sources/Repository/Travel/TravelRepository.swift +++ b/Data/Sources/Repository/Travel/TravelRepository.swift @@ -11,9 +11,14 @@ import Domain public final class TravelRepository: TravelRepositoryProtocol { private let remote: TravelRemoteDataSourceProtocol + private let local: TravelLocalDataSourceProtocol - public init(remote: TravelRemoteDataSourceProtocol) { + public init( + remote: TravelRemoteDataSourceProtocol, + local: TravelLocalDataSourceProtocol = TravelLocalDataSource() + ) { self.remote = remote + self.local = local } public func fetchTravels( @@ -21,7 +26,16 @@ public final class TravelRepository: TravelRepositoryProtocol { ) async throws -> [Travel] { let requestDTO = input.toDTO() let dtoList = try await remote.fetchTravels(body: requestDTO).items - return dtoList.map { $0.toDomain() } + let travels = dtoList.map { $0.toDomain() } + if let status = input.status { + let appendExisting = input.page > 1 + try await persistCache( + travels: travels, + status: status, + appendExisting: appendExisting + ) + } + return travels } public func createTravel( @@ -53,4 +67,56 @@ public final class TravelRepository: TravelRepositoryProtocol { let responseDTO = try await remote.fetchTravelDetail(id: id) 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 { + func persistCache( + travels: [Travel], + status: TravelStatus, + appendExisting: Bool + ) async throws { + var cacheItems = travels.map { $0.toCacheItem() } + + if appendExisting, + let existing = try? await local.load(status: status)?.travels { + cacheItems = existing + cacheItems + } + + let deduped = deduplicate(items: cacheItems) + let cache = TravelCacheDTO( + statusRawValue: status.rawValue, + cachedAt: Date(), + travels: deduped + ) + try await local.save(cache) + } + + // 한 번 캐시된 여행은 다시 저장하지 않도록 ID 기반으로 중복을 제거한다. + func deduplicate(items: [TravelCacheItemDTO]) -> [TravelCacheItemDTO] { + var seen = Set() + return items.filter { item in + guard !seen.contains(item.id) else { return false } + seen.insert(item.id) + return true + } + } } diff --git "a/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/ChatGPT Image 2025\353\205\204 12\354\233\224 12\354\235\274 \354\230\244\355\233\204 02_55_30 1.png" "b/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/ChatGPT Image 2025\353\205\204 12\354\233\224 12\354\235\274 \354\230\244\355\233\204 02_55_30 1.png" new file mode 100644 index 00000000..9b416ba5 Binary files /dev/null and "b/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/ChatGPT Image 2025\353\205\204 12\354\233\224 12\354\235\274 \354\230\244\355\233\204 02_55_30 1.png" differ diff --git a/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/Contents.json b/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/Contents.json new file mode 100644 index 00000000..22114761 --- /dev/null +++ b/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "ChatGPT Image 2025년 12월 12일 오후 02_55_30 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "travelList2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "travelList3.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/travelList2.png b/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/travelList2.png new file mode 100644 index 00000000..276766c7 Binary files /dev/null and b/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/travelList2.png differ diff --git a/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/travelList3.png b/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/travelList3.png new file mode 100644 index 00000000..9291d457 Binary files /dev/null and b/DesignSystem/Resources/Assets.xcassets/Images/EmptyTravelList.imageset/travelList3.png differ diff --git a/DesignSystem/Sources/View/Travel/TravelListSkeletonView.swift b/DesignSystem/Sources/View/Travel/TravelListSkeletonView.swift new file mode 100644 index 00000000..12ce5f2d --- /dev/null +++ b/DesignSystem/Sources/View/Travel/TravelListSkeletonView.swift @@ -0,0 +1,95 @@ +// +// TravelListSkeletonView.swift +// DesignSystem +// +// Created by 김민희 on 12/15/25. +// + +import SwiftUI + +/// 여행 카드 리스트가 로딩될 때 TravelCardView의 형태를 흉내 내는 스켈레톤 뷰. +public struct TravelListSkeletonView: View { + @State private var shimmerPhase: CGFloat = -1.0 + + public init() {} + + public var body: some View { + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 18) { + ForEach(0..<5, id: \.self) { _ in + cardSkeleton() + } + } + .padding(16) + } + .onAppear { + withAnimation( + .linear(duration: 1.8) + .repeatForever(autoreverses: false) + ) { + shimmerPhase = 1.2 + } + } + } + + private func cardSkeleton() -> some View { + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 16) + .fill(Color.primary100) + + RoundedRectangle(cornerRadius: 16) + .fill(Color.appWhite) + .offset(x: 5) + + VStack(alignment: .leading, spacing: 12) { + capsulePlaceholder(width: 56, height: 18) + + fullBar(height: 20) + + HStack(spacing: 12) { + iconPlaceholder() + smallBar(width: 120) + Divider() + .frame(width: 1, height: 16) + .overlay(Color.gray1) + iconPlaceholder() + smallBar(width: 60) + } + } + .padding(20) + } + .fixedSize(horizontal: false, vertical: true) + } + + private func capsulePlaceholder(width: CGFloat, height: CGFloat) -> some View { + Capsule() + .fill(Color.gray1) + .frame(width: width, height: height) + .skeletonShimmer(phase: shimmerPhase) + } + + private func smallBar(width: CGFloat, height: CGFloat = 12) -> some View { + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray1) + .frame(width: width, height: height) + .skeletonShimmer(phase: shimmerPhase) + } + + private func fullBar(height: CGFloat) -> some View { + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray1) + .frame(maxWidth: .infinity, minHeight: height, maxHeight: height) + .skeletonShimmer(phase: shimmerPhase) + } + + private func iconPlaceholder(size: CGFloat = 18) -> some View { + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray1) + .frame(width: size, height: size) + .skeletonShimmer(phase: shimmerPhase) + } +} + +#Preview { + TravelListSkeletonView() +} diff --git a/DesignSystem/Sources/ViewModifier/SkeletonShimmerModifier.swift b/DesignSystem/Sources/ViewModifier/SkeletonShimmerModifier.swift index 18f2396c..5a003313 100644 --- a/DesignSystem/Sources/ViewModifier/SkeletonShimmerModifier.swift +++ b/DesignSystem/Sources/ViewModifier/SkeletonShimmerModifier.swift @@ -15,13 +15,13 @@ public extension View { LinearGradient( colors: [ .gray2.opacity(0.2), - .white.opacity(0.5), + .gray2.opacity(0.4), .gray2.opacity(0.2) ], startPoint: .leading, endPoint: .trailing ) - .frame(width: proxy.size.width * 1.2) + .frame(width: proxy.size.width * 0.8) .offset(x: proxy.size.width * phase) } .clipped() diff --git a/Domain/Sources/Repository/Travel/MockTravelRepository.swift b/Domain/Sources/Repository/Travel/MockTravelRepository.swift index 27575ea7..07c4b91d 100644 --- a/Domain/Sources/Repository/Travel/MockTravelRepository.swift +++ b/Domain/Sources/Repository/Travel/MockTravelRepository.swift @@ -96,6 +96,14 @@ public final class MockTravelRepository: TravelRepositoryProtocol { public func deleteTravel(id: String) async throws { 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() + } + } } private extension MockTravelRepository { diff --git a/Domain/Sources/Repository/Travel/TravelRepositoryProtocol.swift b/Domain/Sources/Repository/Travel/TravelRepositoryProtocol.swift index 37247b38..4735c95a 100644 --- a/Domain/Sources/Repository/Travel/TravelRepositoryProtocol.swift +++ b/Domain/Sources/Repository/Travel/TravelRepositoryProtocol.swift @@ -13,4 +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]> } diff --git a/Domain/Sources/UseCase/Travel/ObserveTravelCacheUseCase.swift b/Domain/Sources/UseCase/Travel/ObserveTravelCacheUseCase.swift new file mode 100644 index 00000000..12191f55 --- /dev/null +++ b/Domain/Sources/UseCase/Travel/ObserveTravelCacheUseCase.swift @@ -0,0 +1,46 @@ +// +// 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 50c9c622..15605a00 100644 --- a/Features/Travel/Demo/Sources/TravelDemoApp.swift +++ b/Features/Travel/Demo/Sources/TravelDemoApp.swift @@ -19,12 +19,16 @@ struct TravelDemoApp: App { private static let mockCountriesRepo = MockCountriesRepository() private static let mockExchangeRateRepo = MockExchangeRateRepository() - private static let repo = TravelRepository(remote: TravelRemoteDataSource()) + private static let repo = TravelRepository( + remote: TravelRemoteDataSource(), + local: TravelLocalDataSource() + ) private let store = Store(initialState: TravelListFeature.State()) { TravelListFeature() } withDependencies: { $0.fetchTravelsUseCase = FetchTravelsUseCase(repository: repo) + $0.observeTravelCacheUseCase = ObserveTravelCacheUseCase(repository: repo) $0.createTravelUseCase = CreateTravelUseCase(repository: repo) } diff --git a/Features/Travel/Sources/Reducer/TravelListFeature.swift b/Features/Travel/Sources/Reducer/TravelListFeature.swift index fdfa27a8..34d2da05 100644 --- a/Features/Travel/Sources/Reducer/TravelListFeature.swift +++ b/Features/Travel/Sources/Reducer/TravelListFeature.swift @@ -16,6 +16,8 @@ public struct TravelListFeature { public struct State: Equatable, Hashable { var travels: [Travel] = [] var selectedTab: TravelTab = .ongoing + var cachedTravelsByTab: [TravelTab: [Travel]] = [:] + var didStartObservation = false var isMenuOpen = false @@ -44,8 +46,10 @@ public struct TravelListFeature { case refresh case fetch case fetchNextPageIfNeeded(currentItemID: String?) + case startObserveCache(TravelTab) + case cachedTravelsUpdated(tab: TravelTab, travels: [Travel]) - case fetchTravelsResponse(Result<[Travel], Error>) + case fetchTravelsResponse(tab: TravelTab, page: Int, Result<[Travel], Error>) case travelTabSelected(TravelTab) case travelSelected(travelId: String) case openInviteCode(String) @@ -66,42 +70,83 @@ public struct TravelListFeature { @Dependency(\.fetchTravelsUseCase) var fetchTravelsUseCase @Dependency(\.joinTravelUseCase) var joinTravelUseCase + @Dependency(\.observeTravelCacheUseCase) var observeTravelCacheUseCase public var body: some Reducer { Reduce { state, action in switch action { case .onAppear: - return .send(.refresh) + // 탭별 캐시 스트림을 한번만 구독하고 서버 데이터를 요청 + 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) + + case .cachedTravelsUpdated(let tab, let travels): + state.cachedTravelsByTab[tab] = travels + if state.selectedTab == tab { + state.travels = travels + state.isLoading = false + } + return .none case .travelTabSelected(let newTab): + guard state.selectedTab != newTab else { return .none } state.selectedTab = newTab + state.page = 1 + state.hasNext = true + state.isLoadingNextPage = false + let cached = state.cachedTravelsByTab[newTab] + state.travels = cached ?? [] + state.isLoading = (cached == nil) return .send(.refresh) case .refresh: state.page = 1 state.hasNext = true - state.travels = [] + state.isLoadingNextPage = false + if state.travels.isEmpty { + state.isLoading = true + } return .send(.fetch) case .fetch: guard state.hasNext else { return .none } - if state.page == 1 { + let currentTab = state.selectedTab + let currentPage = state.page + state.uiError = nil + if currentPage == 1 { state.isLoading = true } else { state.isLoadingNextPage = true } let input = FetchTravelsInput( - page: state.page, - status: state.selectedTab.status + page: currentPage, + status: currentTab.status ) - return .run { send in + return .run { [currentTab, currentPage] send in do { let result = try await fetchTravelsUseCase.execute(input: input) - await send(.fetchTravelsResponse(.success(result))) + await send(.fetchTravelsResponse(tab: currentTab, page: currentPage, .success(result))) } catch { - await send(.fetchTravelsResponse(.failure(error))) + await send(.fetchTravelsResponse(tab: currentTab, page: currentPage, .failure(error))) } } @@ -114,34 +159,41 @@ public struct TravelListFeature { state.page += 1 return .send(.fetch) - case .fetchTravelsResponse(.success(let items)): - state.isLoading = false - state.isLoadingNextPage = false + case .fetchTravelsResponse(let tab, let page, .success(let items)): + if tab == state.selectedTab { + state.isLoading = false + state.isLoadingNextPage = false + } if items.isEmpty { - state.hasNext = false + if tab == state.selectedTab { + state.hasNext = false + if page == 1 { + state.travels = [] + } + } + if page == 1 { + state.cachedTravelsByTab[tab] = [] + } return .none } - if state.page == 1 { - state.travels = items - } else { - state.travels.append(contentsOf: items) - } + var existing = page == 1 ? [] : state.cachedTravelsByTab[tab] ?? [] + existing.append(contentsOf: items) + let deduped = deduplicate(existing) + state.cachedTravelsByTab[tab] = deduped - // 동일 여행이 중복 노출되지 않도록 ID 기준으로 정리 - var seen = Set() - state.travels = state.travels.filter { travel in - guard !seen.contains(travel.id) else { return false } - seen.insert(travel.id) - return true + if tab == state.selectedTab { + state.travels = deduped } return .none - case .fetchTravelsResponse(.failure(let error)): - state.isLoading = false - state.isLoadingNextPage = false - state.uiError = error.localizedDescription + case .fetchTravelsResponse(let tab, _, .failure(let error)): + if tab == state.selectedTab { + state.isLoading = false + state.isLoadingNextPage = false + state.uiError = error.localizedDescription + } return .none case .travelSelected: @@ -227,3 +279,17 @@ public struct TravelListFeature { } } } + +private struct TravelCacheObservationID: Hashable { + let tab: TravelTab +} + +// 서버에서 동일 데이터를 여러 번 수신하더라도 한 번만 노출되도록 ID 기준으로 정리 +private func deduplicate(_ travels: [Travel]) -> [Travel] { + var seen = Set() + return travels.filter { travel in + guard !seen.contains(travel.id) else { return false } + seen.insert(travel.id) + return true + } +} diff --git a/Features/Travel/Sources/View/Travels/Components/TravelEmptyView.swift b/Features/Travel/Sources/View/Travels/Components/TravelEmptyView.swift index 5c0e8960..7534680a 100644 --- a/Features/Travel/Sources/View/Travels/Components/TravelEmptyView.swift +++ b/Features/Travel/Sources/View/Travels/Components/TravelEmptyView.swift @@ -11,7 +11,7 @@ import DesignSystem struct TravelEmptyView: View { var body: some View { VStack(alignment: .center, spacing: 0) { - Image(assetName: "luggage") + Image(assetName: "emptyTravelList") .resizable() .scaledToFit() .frame(height: 160) diff --git a/Features/Travel/Sources/View/Travels/TravelView.swift b/Features/Travel/Sources/View/Travels/TravelView.swift index e65a873e..1dd9bdc2 100644 --- a/Features/Travel/Sources/View/Travels/TravelView.swift +++ b/Features/Travel/Sources/View/Travels/TravelView.swift @@ -19,21 +19,24 @@ public struct TravelView: View { } public var body: some View { - ZStack { + let hasCacheForTab = store.cachedTravelsByTab[store.selectedTab] != nil + let shouldShowSkeleton = store.isLoading && !hasCacheForTab && store.travels.isEmpty + + return ZStack { Color.primary50.ignoresSafeArea() - - if store.isLoading { - DashboardSkeletonView() - } else { - VStack { - TravelListHeaderView { - store.send(.profileButtonTapped) - } - - TabBarView(selectedTab: $store.selectedTab.sending(\.travelTabSelected)) - .padding(.horizontal, 20) - if store.travels.isEmpty { + VStack { + TravelListHeaderView { + store.send(.profileButtonTapped) + } + + TabBarView(selectedTab: $store.selectedTab.sending(\.travelTabSelected)) + .padding(.horizontal, 20) + + Group { + if shouldShowSkeleton { + TravelListSkeletonView() + } else if store.travels.isEmpty { TravelEmptyView() } else { ScrollView { @@ -47,25 +50,25 @@ public struct TravelView: View { store.send(.travelSelected(travelId: travel.id)) } } - + if store.isLoadingNextPage { ProgressView().padding(.vertical, 20) } } .padding(16) - } + } .refreshable { - store.send(.refresh) + store.send(.refresh) } } } } } .task { - store.send(.onAppear) + store.send(.onAppear) } .overlay(alignment: .bottomTrailing) { - if !store.isLoading { + if !shouldShowSkeleton { ZStack(alignment: .bottomTrailing) { if store.isMenuOpen { TravelCreateMenuView( diff --git a/SseuDamApp/Sources/Application/LiveDependencies.swift b/SseuDamApp/Sources/Application/LiveDependencies.swift index 85f8103e..a7163d66 100644 --- a/SseuDamApp/Sources/Application/LiveDependencies.swift +++ b/SseuDamApp/Sources/Application/LiveDependencies.swift @@ -13,7 +13,10 @@ import Domain public enum LiveDependencies { @MainActor public static func register(_ dependencies: inout DependencyValues) { // Repository 인스턴스 생성 (재사용) - let travelRepository = TravelRepository(remote: TravelRemoteDataSource()) + let travelRepository = TravelRepository( + remote: TravelRemoteDataSource(), + local: TravelLocalDataSource() + ) let expenseRepository = ExpenseRepository( remote: ExpenseRemoteDataSource(), local: ExpenseLocalDataSource() @@ -45,6 +48,7 @@ public enum LiveDependencies { // Travel dependencies.fetchTravelsUseCase = FetchTravelsUseCase(repository: travelRepository) + dependencies.observeTravelCacheUseCase = ObserveTravelCacheUseCase(repository: travelRepository) dependencies.createTravelUseCase = CreateTravelUseCase(repository: travelRepository) dependencies.fetchTravelDetailUseCase = FetchTravelDetailUseCase(repository: travelRepository) dependencies.updateTravelUseCase = UpdateTravelUseCase(repository: travelRepository)