diff --git a/SodaLive/Sources/V2/Gift/List/GiftBoxListView.swift b/SodaLive/Sources/V2/Gift/List/GiftBoxListView.swift new file mode 100644 index 00000000..c2c1ea11 --- /dev/null +++ b/SodaLive/Sources/V2/Gift/List/GiftBoxListView.swift @@ -0,0 +1,78 @@ +import SwiftUI + +struct GiftBoxListView: View { + @StateObject private var viewModel = GiftBoxListViewModel() + @State private var hasAppeared = false + + var body: some View { + VStack(spacing: 0) { + DetailNavigationBar(title: "선물함") + .frame(height: 54) + BaseView(isLoading: .constant(viewModel.isLoading)) { + GeometryReader { geometry in + ScrollView { + if viewModel.hasLoaded { + if viewModel.items.isEmpty && isCreator { + GiftBoxEmptyView { + AppState.shared.setAppStep(step: GiftConstants.feedbackStep) + } + .frame(minHeight: geometry.size.height) + } else { + listContent + } + } + } + .refreshable { await viewModel.refresh() } + } + } + .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage) + } + .background(Color.black.ignoresSafeArea()) + .onAppear { + guard !hasAppeared else { return } + hasAppeared = true + viewModel.fetchInitialPage() + } + } + + private var isCreator: Bool { + UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue + } + + private var listContent: some View { + VStack(spacing: SodaSpacing.s12) { + GiftBoxInfoBannerView(direction: viewModel.items.contains { + GiftDisplayPolicy.listCard(from: $0)?.direction == .received + } ? .received : .sent) + + LazyVStack(spacing: 0) { + LazyVStack(spacing: SodaSpacing.s8) { + ForEach(viewModel.items, id: \.applicationNo) { item in + if let model = GiftDisplayPolicy.listCard(from: item) { + GiftBoxCardView(model: model) { + AppState.shared.setAppStep(step: .giftDetail(applicationNo: model.applicationNo)) + } + } + } + + if viewModel.isLoadingMore { + ProgressView() + .tint(.white) + .padding(SodaSpacing.s14) + } + } + + if let lastItem = viewModel.items.last { + Color.clear + .frame(height: 1) + .allowsHitTesting(false) + .accessibilityHidden(true) + .id(lastItem.applicationNo) + .onAppear { viewModel.fetchNextPage(currentItem: lastItem) } + } + } + } + .padding(.top, SodaSpacing.s8) + .padding(.bottom, SodaSpacing.s14) + } +} diff --git a/SodaLive/Sources/V2/Gift/List/GiftBoxListViewModel.swift b/SodaLive/Sources/V2/Gift/List/GiftBoxListViewModel.swift new file mode 100644 index 00000000..d15e6566 --- /dev/null +++ b/SodaLive/Sources/V2/Gift/List/GiftBoxListViewModel.swift @@ -0,0 +1,102 @@ +import Foundation +import Combine + +final class GiftBoxListViewModel: ObservableObject { + private let repository = GiftRepository() + private let pageSize = 20 + private var subscription: AnyCancellable? + private var page = 0 + private var isRequestInFlight = false + + @Published private(set) var items = [GiftListItemResponse]() + @Published private(set) var totalCount = 0 + @Published private(set) var hasNext = false + @Published private(set) var hasLoaded = false + @Published private(set) var isLoading = false + @Published private(set) var isLoadingMore = false + @Published private(set) var isRefreshing = false + @Published private(set) var errorMessage = "" + @Published var isShowPopup = false + + func fetchInitialPage() { + guard !hasLoaded else { return } + fetch(page: 0) + } + + func fetchNextPage(currentItem: GiftListItemResponse) { + guard hasLoaded, hasNext, + currentItem.applicationNo == items.last?.applicationNo else { return } + fetch(page: page + 1) + } + + @MainActor + func refresh() async { + await withCheckedContinuation { continuation in + fetch(page: 0, isRefresh: true) { + continuation.resume() + } + } + } + + private func fetch(page requestedPage: Int, isRefresh: Bool = false, completion: (() -> Void)? = nil) { + guard !isRequestInFlight else { + completion?() + return + } + + isRequestInFlight = true + isLoading = requestedPage == 0 && !isRefresh + isLoadingMore = requestedPage > 0 + isRefreshing = isRefresh + errorMessage = "" + isShowPopup = false + + subscription = repository.getGifts(page: requestedPage, size: pageSize) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + defer { completion?() } + guard let self else { return } + + if case .failure(let error) = result { + self.showError(data: error.response?.data) + } + self.isLoading = false + self.isLoadingMore = false + self.isRefreshing = false + self.isRequestInFlight = false + } receiveValue: { [weak self] response in + guard let self else { return } + + do { + let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) + guard decoded.success, let data = decoded.data else { + self.showError(data: response.data) + return + } + + if requestedPage == 0 { + self.items = data.items + } else { + self.items.append(contentsOf: data.items) + } + self.page = data.page + self.totalCount = data.totalCount + self.hasNext = data.hasNext + self.hasLoaded = true + } catch { + self.showError(data: response.data) + } + } + } + + private func showError(data: Data?) { + struct ErrorResponse: Decodable { + let message: String? + } + + let decoded = data.flatMap { try? JSONDecoder().decode(ErrorResponse.self, from: $0) } + let message = decoded?.message?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + errorMessage = message.isEmpty ? I18n.Common.commonError : message + isShowPopup = true + } +}