feat(gift): 선물함 목록 화면을 추가한다

This commit is contained in:
Yu Sung
2026-10-08 12:32:46 +09:00
parent 104e6d93ad
commit 78f2b737bb
2 changed files with 180 additions and 0 deletions
@@ -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
}
}