CharacterDetailView에 갤러리 탭을 추가하고, 갤러리 화면/상태 관리/네트워킹을 구현했습니다. 소유/미소유 UI, 페이지네이션, 이미지 뷰어, 오류 토스트를 포함합니다. TODO: 이미지 구매 API 연동
204 lines
7.5 KiB
Swift
204 lines
7.5 KiB
Swift
//
|
|
// CharacterDetailGalleryView.swift
|
|
// SodaLive
|
|
//
|
|
// Created by klaus on 9/1/25.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct CharacterDetailGalleryView: View {
|
|
@StateObject var viewModel = CharacterDetailGalleryViewModel()
|
|
|
|
private let columns = Array(repeating: GridItem(.flexible()), count: 3)
|
|
let characterId: Int
|
|
|
|
// 계산된 속성들
|
|
private var progressBarWidth: CGFloat {
|
|
let maxWidth: CGFloat = screenSize().width - 48
|
|
guard viewModel.totalCount > 0 else { return 0 }
|
|
let percentage = Double(viewModel.ownedCount) / Double(viewModel.totalCount)
|
|
return maxWidth * percentage
|
|
}
|
|
|
|
var body: some View {
|
|
BaseView(isLoading: $viewModel.isLoading) {
|
|
VStack(spacing: 24) {
|
|
// 보유 정보 섹션
|
|
collectionInfoView()
|
|
.padding(.horizontal, 24)
|
|
|
|
// 갤러리 그리드
|
|
ScrollView {
|
|
LazyVGrid(columns: columns, spacing: 2) {
|
|
ForEach(Array(viewModel.galleryItems.enumerated()), id: \.element.id) { index, item in
|
|
galleryImageView(item: item, index: index)
|
|
.onAppear {
|
|
viewModel.loadMoreIfNeeded(currentItem: item)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 0)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.top, 24)
|
|
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
|
|
GeometryReader { geo in
|
|
HStack {
|
|
Spacer()
|
|
Text(viewModel.errorMessage)
|
|
.padding(.vertical, 13.3)
|
|
.frame(alignment: .center)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.horizontal, 33.3)
|
|
.font(.custom(Font.medium.rawValue, size: 12))
|
|
.background(Color.button)
|
|
.foregroundColor(Color.white)
|
|
.multilineTextAlignment(.center)
|
|
.cornerRadius(20)
|
|
.padding(.top, 66.7)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
viewModel.characterId = characterId
|
|
viewModel.loadInitialData()
|
|
}
|
|
.sheet(isPresented: $viewModel.isShowImageViewer) {
|
|
ImageViewerView(
|
|
images: viewModel.ownedImages.map { $0.imageUrl },
|
|
selectedIndex: $viewModel.selectedImageIndex
|
|
)
|
|
}
|
|
.overlay {
|
|
if viewModel.isShowPurchaseDialog {
|
|
SodaDialog(
|
|
title: "구매 확인",
|
|
desc: "선택한 이미지를 구매하시겠습니까?",
|
|
confirmButtonTitle: viewModel.selectedItemPrice,
|
|
confirmButtonAction: viewModel.onPurchaseConfirm,
|
|
cancelButtonTitle: "취소",
|
|
cancelButtonAction: viewModel.onPurchaseCancel
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func collectionInfoView() -> some View {
|
|
VStack(spacing: 8) {
|
|
// 상단 정보 (계산된 % 보유중, 정보 아이콘, 개수)
|
|
HStack {
|
|
Text("\(viewModel.ownershipPercentage)% 보유중")
|
|
.font(.custom(Font.preBold.rawValue, size: 18))
|
|
.foregroundColor(.white)
|
|
|
|
Spacer()
|
|
|
|
HStack(spacing: 4) {
|
|
Text("\(viewModel.ownedCount)")
|
|
.font(.custom(Font.preRegular.rawValue, size: 16))
|
|
.foregroundColor(Color(hex: "#FDD453"))
|
|
|
|
Text("/")
|
|
.font(.custom(Font.preRegular.rawValue, size: 16))
|
|
.foregroundColor(.white)
|
|
|
|
Text("\(viewModel.totalCount)개")
|
|
.font(.custom(Font.preRegular.rawValue, size: 16))
|
|
.foregroundColor(.white)
|
|
}
|
|
}
|
|
|
|
// 진행률 바
|
|
GeometryReader { geometry in
|
|
ZStack(alignment: .leading) {
|
|
// 배경 바
|
|
RoundedRectangle(cornerRadius: 999)
|
|
.foregroundColor(Color(hex: "#37474F"))
|
|
.frame(height: 9)
|
|
|
|
// 진행률 바 (계산된 퍼센트)
|
|
RoundedRectangle(cornerRadius: 999)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [Color(hex: "#80D8FF"), Color(hex: "#6D5ED7")],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
)
|
|
.frame(width: min(progressBarWidth, geometry.size.width), height: 9)
|
|
}
|
|
}
|
|
.frame(height: 9)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func galleryImageView(item: CharacterImageListItemResponse, index: Int) -> some View {
|
|
ZStack {
|
|
// 이미지
|
|
AsyncImage(url: URL(string: item.imageUrl)) { image in
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
} placeholder: {
|
|
Rectangle()
|
|
.fill(Color.gray.opacity(0.3))
|
|
}
|
|
.frame(width: 132, height: 165)
|
|
.clipped()
|
|
.cornerRadius(0)
|
|
|
|
// 미소유 이미지 오버레이
|
|
if !item.isOwned {
|
|
// 어두운 오버레이
|
|
Rectangle()
|
|
.fill(Color.black.opacity(0.2))
|
|
.frame(width: 132, height: 165)
|
|
|
|
// 자물쇠 아이콘과 코인 정보
|
|
VStack(spacing: 8) {
|
|
// 자물쇠 아이콘
|
|
Image("ic_new_lock")
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: 24)
|
|
|
|
// 코인 정보 배경
|
|
HStack(spacing: 4) {
|
|
Image("ic_can")
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: 16)
|
|
|
|
Text("\(item.imagePriceCan)")
|
|
.font(.custom(Font.preBold.rawValue, size: 16))
|
|
.foregroundColor(Color(hex: "#263238"))
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color(hex: "#B5E7FA"))
|
|
.cornerRadius(30)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 30)
|
|
.strokeBorder(lineWidth: 1)
|
|
.foregroundColor(.button)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onTapGesture {
|
|
viewModel.onImageTapped(item, index: index)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
CharacterDetailGalleryView(characterId: 1)
|
|
}
|