diff --git a/SodaLive/Sources/Chat/Character/Banner/AutoSlideCharacterBannerView.swift b/SodaLive/Sources/Chat/Character/Banner/AutoSlideCharacterBannerView.swift new file mode 100644 index 0000000..eb51c02 --- /dev/null +++ b/SodaLive/Sources/Chat/Character/Banner/AutoSlideCharacterBannerView.swift @@ -0,0 +1,61 @@ +// +// AutoSlideCharacterBannerView.swift +// SodaLive +// +// Created by klaus on 8/29/25. +// + +import SwiftUI +import Kingfisher + +struct AutoSlideCharacterBannerView: View { + var items: [CharacterBannerResponse] = [] + var onTap: (CharacterBannerResponse) -> Void = { _ in } + + @State private var index: Int = 0 + @State private var height: CGFloat = 0 + private let timer = Timer.publish(every: 4, on: .main, in: .common).autoconnect() + + var body: some View { + TabView(selection: $index) { + ForEach(0..() + + func getCharacterMain() -> AnyPublisher { + return api.requestPublisher(.getCharacterHome) + } +} diff --git a/SodaLive/Sources/Chat/Character/CharacterSectionView.swift b/SodaLive/Sources/Chat/Character/CharacterSectionView.swift new file mode 100644 index 0000000..dbd99fa --- /dev/null +++ b/SodaLive/Sources/Chat/Character/CharacterSectionView.swift @@ -0,0 +1,49 @@ +// +// CharacterSectionView.swift +// SodaLive +// +// Created by klaus on 8/29/25. +// + +import SwiftUI + +struct CharacterSectionView: View { + let title: String + let items: [Character] + var onTap: (Character) -> Void = { _ in } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.custom(Font.preBold.rawValue, size: 20)) + .foregroundColor(.white) + .padding(.horizontal, 24) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(items.indices, id: \.self) { idx in + let item = items[idx] + CharacterItemView( + character: item, + size: screenSize().width * 0.42 + ) + .onTapGesture { onTap(item) } + } + } + .padding(.horizontal, 24) + } + } + } +} + +#Preview { + CharacterSectionView( + title: "신규 캐릭터", + items: [ + Character(characterId: 1, name: "찰리", description: "새로운 친구", imageUrl: "https://picsum.photos/300"), + Character(characterId: 2, name: "데이지", description: "", imageUrl: "https://picsum.photos/300") + ] + ) + .padding() + .background(Color.black) +} diff --git a/SodaLive/Sources/Chat/Character/CharacterView.swift b/SodaLive/Sources/Chat/Character/CharacterView.swift index 83d3ed6..1da5ffa 100644 --- a/SodaLive/Sources/Chat/Character/CharacterView.swift +++ b/SodaLive/Sources/Chat/Character/CharacterView.swift @@ -8,17 +8,85 @@ import SwiftUI struct CharacterView: View { - var body: some View { - VStack(spacing: 12) { - Spacer() - Text("캐릭터 페이지 (준비중)") - .font(.custom(Font.preMedium.rawValue, size: 16)) - .multilineTextAlignment(.center) - Spacer() + @StateObject var viewModel = CharacterViewModel() + + private let horizontalPadding: CGFloat = 16 + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + // 배너 + if !viewModel.banners.isEmpty { + AutoSlideCharacterBannerView(items: viewModel.banners) { banner in + DEBUG_LOG("Banner tapped: \(banner.characterId)") + } + } + + // 최근 대화한 캐릭터 섹션 + if !viewModel.recentCharacters.isEmpty { + RecentCharacterSectionView( + titleCount: viewModel.recentCharacters.count, + items: viewModel.recentCharacters + ) { ch in + DEBUG_LOG("Recent tapped: \(ch.characterId)") + } + } + + // 신규 캐릭터 섹션 + if !viewModel.newCharacters.isEmpty { + CharacterSectionView( + title: "신규 캐릭터", + items: viewModel.newCharacters + ) { ch in + DEBUG_LOG("New tapped: \(ch.characterId)") + } + } + + // 큐레이션 섹션 (여러 섹션) + if !viewModel.curations.isEmpty { + VStack(alignment: .leading, spacing: 24) { + ForEach(viewModel.curations.indices, id: \.self) { idx in + let section = viewModel.curations[idx] + CharacterSectionView( + title: section.title, + items: section.characters + ) { ch in + DEBUG_LOG("Curation tapped: \\(ch.characterId)") + } + } + } + } + } + .padding(.vertical, 24) + } + .background(Color.black.ignoresSafeArea()) + .onAppear { + if !viewModel.isLoading { + viewModel.getCharacterMain() + } + } + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color.button) + .foregroundColor(Color.white) + .multilineTextAlignment(.center) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } } - } } #Preview { - CharacterView() + CharacterView() } diff --git a/SodaLive/Sources/Chat/Character/CharacterViewModel.swift b/SodaLive/Sources/Chat/Character/CharacterViewModel.swift new file mode 100644 index 0000000..ddbc76e --- /dev/null +++ b/SodaLive/Sources/Chat/Character/CharacterViewModel.swift @@ -0,0 +1,69 @@ +// +// CharacterViewModel.swift +// SodaLive +// +// Created by klaus on 8/29/25. +// + +import Foundation +import Combine +import Moya + +final class CharacterViewModel: ObservableObject { + // MARK: - Published State + @Published private(set) var banners: [CharacterBannerResponse] = [] + @Published private(set) var recentCharacters: [RecentCharacter] = [] + @Published private(set) var newCharacters: [Character] = [] + @Published private(set) var curations: [CurationSection] = [] + + @Published var isLoading: Bool = false + @Published var errorMessage: String = "" + @Published var isShowPopup = false + + // MARK: - Private + private let repository = CharacterRepository() + private var subscription = Set() + + // MARK: - API + func getCharacterMain() { + self.isLoading = true + + repository.getCharacterMain() + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { response in + let responseData = response.data + self.isLoading = false + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.banners = data.banners + self.recentCharacters = data.recentCharacters + self.newCharacters = data.newCharacters + self.curations = data.curationSections.filter { !$0.characters.isEmpty } + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } +} + diff --git a/SodaLive/Sources/Chat/Character/Curation/CurationSection.swift b/SodaLive/Sources/Chat/Character/Curation/CurationSection.swift new file mode 100644 index 0000000..4859bfd --- /dev/null +++ b/SodaLive/Sources/Chat/Character/Curation/CurationSection.swift @@ -0,0 +1,12 @@ +// +// CurationSection.swift +// SodaLive +// +// Created by klaus on 8/29/25. +// + +struct CurationSection: Decodable { + let characterCurationId: Int + let title: String + let characters: [Character] +} diff --git a/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift new file mode 100644 index 0000000..3fc79df --- /dev/null +++ b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift @@ -0,0 +1,18 @@ +// +// CharacterDetailView.swift +// SodaLive +// +// Created by klaus on 8/29/25. +// + +import SwiftUI + +struct CharacterDetailView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + CharacterDetailView() +} diff --git a/SodaLive/Sources/Chat/Character/Recent/RecentCharacter.swift b/SodaLive/Sources/Chat/Character/Recent/RecentCharacter.swift new file mode 100644 index 0000000..0b5c013 --- /dev/null +++ b/SodaLive/Sources/Chat/Character/Recent/RecentCharacter.swift @@ -0,0 +1,12 @@ +// +// RecentCharacter.swift +// SodaLive +// +// Created by klaus on 8/29/25. +// + +struct RecentCharacter: Decodable { + let characterId: Int + let name: String + let imageUrl: String +} diff --git a/SodaLive/Sources/Chat/Character/Recent/RecentCharacterItemView.swift b/SodaLive/Sources/Chat/Character/Recent/RecentCharacterItemView.swift new file mode 100644 index 0000000..c6454f1 --- /dev/null +++ b/SodaLive/Sources/Chat/Character/Recent/RecentCharacterItemView.swift @@ -0,0 +1,40 @@ +// +// RecentCharacterItemView.swift +// SodaLive +// +// Created by klaus on 8/29/25. +// + +import SwiftUI +import Kingfisher + +struct RecentCharacterItemView: View { + let character: RecentCharacter + + var body: some View { + VStack(spacing: 6) { + KFImage(URL(string: character.imageUrl)) + .placeholder { Circle().fill(Color.gray.opacity(0.2)) } + .retry(maxCount: 2, interval: .seconds(1)) + .cancelOnDisappear(true) + .resizable() + .scaledToFill() + .frame(width: 76, height: 76) + .clipShape(Circle()) + + Text(character.name) + .font(.custom(Font.preRegular.rawValue, size: 18)) + .foregroundColor(.white) + .lineLimit(1) + .frame(maxWidth: 76) + .multilineTextAlignment(.center) + } + } +} + +#Preview { + RecentCharacterItemView( + character: RecentCharacter(characterId: 1, name: "앨리스", imageUrl: "https://picsum.photos/200") + ) + .background(Color.black) +} diff --git a/SodaLive/Sources/Chat/Character/Recent/RecentCharacterSectionView.swift b/SodaLive/Sources/Chat/Character/Recent/RecentCharacterSectionView.swift new file mode 100644 index 0000000..66e4db9 --- /dev/null +++ b/SodaLive/Sources/Chat/Character/Recent/RecentCharacterSectionView.swift @@ -0,0 +1,51 @@ +// +// RecentCharacterSectionView.swift +// SodaLive +// +// Created by klaus on 8/29/25. +// + +import SwiftUI + +struct RecentCharacterSectionView: View { + let titleCount: Int + let items: [RecentCharacter] + var onTap: (RecentCharacter) -> Void = { _ in } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 0) { + Text("최근 대화한 캐릭터 ") + .font(.custom(Font.preBold.rawValue, size: 20)) + .foregroundColor(.white) + Text("\(titleCount)") + .font(.custom(Font.preBold.rawValue, size: 20)) + .foregroundColor(Color(hex: "FDCA2F")) + Spacer() + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(items.indices, id: \.self) { idx in + let item = items[idx] + RecentCharacterItemView(character: item) + .onTapGesture { onTap(item) } + } + } + } + } + } +} + +#Preview { + RecentCharacterSectionView( + titleCount: 3, + items: [ + RecentCharacter(characterId: 1, name: "라라", imageUrl: "https://picsum.photos/200"), + RecentCharacter(characterId: 2, name: "마리", imageUrl: "https://picsum.photos/200"), + RecentCharacter(characterId: 3, name: "Nana", imageUrl: "https://picsum.photos/200") + ] + ) + .padding() + .background(Color.black) +}