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