feat(gift): 선물함 목록 화면을 추가한다
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<GiftListResponse>.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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user