From ed3f3f796a0eb632f608f9357b0595b5b9aa7a66 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Fri, 12 Sep 2025 22:28:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat-character):=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=A0=84=EC=B2=B4=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=ED=99=94=EB=A9=B4=20=EB=B0=8F=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Sources/App/AppStep.swift | 2 + .../Sources/Chat/Character/CharacterApi.swift | 13 ++ .../Chat/Character/CharacterSectionView.swift | 19 ++- .../Chat/Character/CharacterView.swift | 32 +++-- .../Detail/CharacterDetailView.swift | 14 +- .../New/Models/RecentCharactersResponse.swift | 15 +++ .../Repository/NewCharacterRepository.swift | 19 +++ .../NewCharacterListViewModel.swift | 117 +++++++++++++++++ .../New/Views/NewCharacterListView.swift | 123 ++++++++++++++++++ SodaLive/Sources/Chat/ChatTabView.swift | 33 ++++- SodaLive/Sources/ContentView.swift | 3 + 11 files changed, 365 insertions(+), 25 deletions(-) create mode 100644 SodaLive/Sources/Chat/Character/New/Models/RecentCharactersResponse.swift create mode 100644 SodaLive/Sources/Chat/Character/New/Repository/NewCharacterRepository.swift create mode 100644 SodaLive/Sources/Chat/Character/New/ViewModels/NewCharacterListViewModel.swift create mode 100644 SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index 6197f98..f02dbf7 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -165,4 +165,6 @@ enum AppStep { case characterDetail(characterId: Int) case chatRoom(id: Int) + + case newCharacterAll } diff --git a/SodaLive/Sources/Chat/Character/CharacterApi.swift b/SodaLive/Sources/Chat/Character/CharacterApi.swift index b55b749..7099003 100644 --- a/SodaLive/Sources/Chat/Character/CharacterApi.swift +++ b/SodaLive/Sources/Chat/Character/CharacterApi.swift @@ -14,6 +14,7 @@ enum CharacterApi { case getCharacterImageList(characterId: Int, page: Int, size: Int) case getMyCharacterImageList(characterId: Int64, page: Int, size: Int) case purchaseCharacterImage(imageId: Int) + case getRecentCharacters(page: Int, size: Int) } extension CharacterApi: TargetType { @@ -35,6 +36,9 @@ extension CharacterApi: TargetType { case .purchaseCharacterImage: return "/api/chat/character/image/purchase" + + case .getRecentCharacters: + return "/api/chat/character/recent" } } @@ -53,6 +57,15 @@ extension CharacterApi: TargetType { case .getCharacterHome, .getCharacterDetail: return .requestPlain + case .getRecentCharacters(let page, let size): + return .requestParameters( + parameters: [ + "page": page, + "size": size + ], + encoding: URLEncoding.queryString + ) + case .getCharacterImageList(let characterId, let page, let size): return .requestParameters( parameters: [ diff --git a/SodaLive/Sources/Chat/Character/CharacterSectionView.swift b/SodaLive/Sources/Chat/Character/CharacterSectionView.swift index 3836e08..077153d 100644 --- a/SodaLive/Sources/Chat/Character/CharacterSectionView.swift +++ b/SodaLive/Sources/Chat/Character/CharacterSectionView.swift @@ -11,14 +11,25 @@ struct CharacterSectionView: View { let title: String let items: [Character] let isShowRank: Bool + var trailingTitle: String? = nil + var onTapTrailing: (() -> Void)? = nil var onTap: (Character) -> Void = { _ in } var body: some View { VStack(alignment: .leading, spacing: 16) { - Text(title) - .font(.custom(Font.preBold.rawValue, size: 20)) - .foregroundColor(.white) - .padding(.horizontal, 24) + HStack(spacing: 0) { + Text(title) + .font(.custom(Font.preBold.rawValue, size: 20)) + .foregroundColor(.white) + Spacer() + if let trailingTitle = trailingTitle { + Text(trailingTitle) + .font(.custom(Font.preRegular.rawValue, size: 14)) + .foregroundColor(Color(hex: "90A4AE")) + .onTapGesture { onTapTrailing?() } + } + } + .padding(.horizontal, 24) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { diff --git a/SodaLive/Sources/Chat/Character/CharacterView.swift b/SodaLive/Sources/Chat/Character/CharacterView.swift index f8823a6..e2ff861 100644 --- a/SodaLive/Sources/Chat/Character/CharacterView.swift +++ b/SodaLive/Sources/Chat/Character/CharacterView.swift @@ -40,10 +40,11 @@ struct CharacterView: View { CharacterSectionView( title: "인기 캐릭터", items: viewModel.popularCharacters, - isShowRank: true - ) { ch in - onSelectCharacter(ch.characterId) - } + isShowRank: true, + onTap: { ch in + onSelectCharacter(ch.characterId) + } + ) } // 신규 캐릭터 섹션 @@ -51,10 +52,16 @@ struct CharacterView: View { CharacterSectionView( title: "신규 캐릭터", items: viewModel.newCharacters, - isShowRank: false - ) { ch in - onSelectCharacter(ch.characterId) - } + isShowRank: false, + trailingTitle: "전체보기", + onTapTrailing: { + AppState.shared + .setAppStep(step: .newCharacterAll) + }, + onTap: { ch in + onSelectCharacter(ch.characterId) + } + ) } // 큐레이션 섹션 (여러 섹션) @@ -65,10 +72,11 @@ struct CharacterView: View { CharacterSectionView( title: section.title, items: section.characters, - isShowRank: false - ) { ch in - onSelectCharacter(ch.characterId) - } + isShowRank: false, + onTap: { ch in + onSelectCharacter(ch.characterId) + } + ) } } } diff --git a/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift index 79d9976..a545946 100644 --- a/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift +++ b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift @@ -17,6 +17,8 @@ struct CharacterDetailView: View { @State private var showMoreWorldView = false @State private var showMorePersonality = false + @Environment(\.presentationMode) var presentationMode: Binding + private enum InnerTab: Int, CaseIterable { case detail = 0 case gallery = 1 @@ -32,7 +34,13 @@ struct CharacterDetailView: View { var body: some View { BaseView(isLoading: $viewModel.isLoading) { VStack(spacing: 0) { - DetailNavigationBar(title: "캐릭터 정보") + DetailNavigationBar(title: "캐릭터 정보") { + if presentationMode.wrappedValue.isPresented { + presentationMode.wrappedValue.dismiss() + } else { + AppState.shared.back() + } + } tabBar @@ -121,6 +129,8 @@ struct CharacterDetailView: View { } } } + .navigationTitle("") + .navigationBarBackButtonHidden() .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { GeometryReader { geo in HStack { @@ -368,7 +378,7 @@ extension CharacterDetailView { } Text(""" -보이스온의 오픈월드 캐릭터톡으로 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다. 세계관 속 연관 캐릭터가 되어 대화를 하거나 완전히 새로운 인물이 되어 캐릭터와 당신만의 스토리를 만들어 갈 수 있습니다. +보이스온의 오픈월드 캐릭터톡은 대화의 자유도가 높아 대화에 참여하는 당신은 누구든 될 수 있습니다. 세계관 속 연관 캐릭터가 되어 대화를 하거나 완전히 새로운 인물이 되어 캐릭터와 당신만의 스토리를 만들어 갈 수 있습니다. """) .font(.custom(Font.preRegular.rawValue, size: 16)) .foregroundColor(Color(hex: "AEAEB2")) diff --git a/SodaLive/Sources/Chat/Character/New/Models/RecentCharactersResponse.swift b/SodaLive/Sources/Chat/Character/New/Models/RecentCharactersResponse.swift new file mode 100644 index 0000000..154c0ea --- /dev/null +++ b/SodaLive/Sources/Chat/Character/New/Models/RecentCharactersResponse.swift @@ -0,0 +1,15 @@ +// +// RecentCharactersResponse.swift +// SodaLive +// +// Created by klaus on 9/12/25. +// + +import Foundation + +/// 신규 캐릭터 전체보기 응답 모델 +/// 서버 스펙: totalCount(Long), content(List) +struct RecentCharactersResponse: Decodable { + let totalCount: Int + let content: [Character] +} diff --git a/SodaLive/Sources/Chat/Character/New/Repository/NewCharacterRepository.swift b/SodaLive/Sources/Chat/Character/New/Repository/NewCharacterRepository.swift new file mode 100644 index 0000000..88945b0 --- /dev/null +++ b/SodaLive/Sources/Chat/Character/New/Repository/NewCharacterRepository.swift @@ -0,0 +1,19 @@ +// +// NewCharacterRepository.swift +// SodaLive +// +// Created by klaus on 9/12/25. +// + +import Foundation +import Combine +import CombineMoya +import Moya + +final class NewCharacterRepository { + private let api = MoyaProvider() + + func getRecentCharacters(page: Int, size: Int) -> AnyPublisher { + return api.requestPublisher(.getRecentCharacters(page: page, size: size)) + } +} diff --git a/SodaLive/Sources/Chat/Character/New/ViewModels/NewCharacterListViewModel.swift b/SodaLive/Sources/Chat/Character/New/ViewModels/NewCharacterListViewModel.swift new file mode 100644 index 0000000..00b0005 --- /dev/null +++ b/SodaLive/Sources/Chat/Character/New/ViewModels/NewCharacterListViewModel.swift @@ -0,0 +1,117 @@ +// +// NewCharacterListViewModel.swift +// SodaLive +// +// Created by klaus on 9/12/25. +// + +import Foundation +import Combine +import Moya + +final class NewCharacterListViewModel: ObservableObject { + // MARK: - Outputs + @Published private(set) var totalCount: Int = 0 + @Published private(set) var items: [Character] = [] + @Published var isLoading: Bool = false + @Published var isLoadingMore: Bool = false + @Published var errorMessage: String = "" + @Published var isShowPopup: Bool = false + + // MARK: - Private + private let repository = NewCharacterRepository() + private var subscription = Set() + private var currentPage: Int = 0 + private let pageSize: Int = 20 + private var hasMorePages: Bool = true + + // MARK: - API + func fetch() { + // 초기 로드 + currentPage = 0 + hasMorePages = true + items.removeAll() + request(page: currentPage) + } + + func loadMoreIfNeeded(currentIndex: Int) { + guard hasMorePages, + !isLoading, + !isLoadingMore, + currentIndex >= items.count - 1 else { return } + loadMore() + } + + // MARK: - Private + private func loadMore() { + guard hasMorePages, !isLoadingMore else { return } + isLoadingMore = true + currentPage += 1 + request(page: currentPage, isLoadMore: true) + } + + private func request(page: Int, isLoadMore: Bool = false) { + if !isLoadMore { + isLoading = true + } + + repository.getRecentCharacters(page: page, size: pageSize) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + switch completion { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + if isLoadMore { + self?.isLoadingMore = false + } else { + self?.isLoading = false + } + self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self?.isShowPopup = true + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: response.data) + if let data = decoded.data, decoded.success { + self.totalCount = data.totalCount + if isLoadMore { + self.items.append(contentsOf: data.content) + self.isLoadingMore = false + } else { + self.items = data.content + self.isLoading = false + } + // hasMore 계산 (총 개수 대비 현재 로드 수) + if self.items.count >= self.totalCount || data.content.isEmpty { + self.hasMorePages = false + } + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + self.isShowPopup = true + if isLoadMore { + self.isLoadingMore = false + } else { + self.isLoading = false + } + } + } catch { + if isLoadMore { + self.isLoadingMore = false + } else { + self.isLoading = false + } + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } +} diff --git a/SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift b/SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift new file mode 100644 index 0000000..5a63a60 --- /dev/null +++ b/SodaLive/Sources/Chat/Character/New/Views/NewCharacterListView.swift @@ -0,0 +1,123 @@ +// +// NewCharacterListView.swift +// SodaLive +// +// Created by klaus on 9/12/25. +// + +import SwiftUI + +struct NewCharacterListView: View { + @StateObject private var viewModel = NewCharacterListViewModel() + + private let horizontalPadding: CGFloat = 12 + private let gridSpacing: CGFloat = 12 + + var body: some View { + NavigationStack { + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 8) { + // Toolbar + DetailNavigationBar(title: "신규 캐릭터 전체보기") + + VStack(alignment: .leading, spacing: 12) { + // 전체 n개 + HStack(spacing: 0) { + Text("전체 ") + .font(.custom(Font.preRegular.rawValue, size: 12)) + .foregroundColor(Color(hex: "e2e2e2")) + Text("\(viewModel.totalCount)") + .font(.custom(Font.preRegular.rawValue, size: 12)) + .foregroundColor(Color(hex: "ff5c49")) + Text("개") + .font(.custom(Font.preRegular.rawValue, size: 12)) + .foregroundColor(Color(hex: "e2e2e2")) + Spacer() + } + .padding(.horizontal, 24) + + // Grid 3열 + GeometryReader { geo in + let totalSpacing: CGFloat = gridSpacing * 2 + let width = (geo.size.width - (horizontalPadding * 2) - totalSpacing) / 3 + + ScrollView(.vertical, showsIndicators: false) { + LazyVGrid( + columns: Array( + repeating: GridItem( + .flexible(), + spacing: gridSpacing, + alignment: .topLeading + ), + count: 3 + ), + alignment: .leading, + spacing: gridSpacing + ) { + ForEach(viewModel.items.indices, id: \.self) { idx in + let item = viewModel.items[idx] + + NavigationLink(value: item.characterId) { + CharacterItemView( + character: item, + size: width, + rank: 0, + isShowRank: false + ) + .onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) } + } + } + } + .padding(.horizontal, horizontalPadding) + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .padding(.vertical, 16) + Spacer() + } + } + } + } + .frame(minHeight: 0, maxHeight: .infinity) + } + .padding(.vertical, 12) + .onAppear { + // 최초 1회만 로드하여 상세 진입 후 복귀 시 스크롤 위치가 유지되도록 함 + if viewModel.items.isEmpty { + viewModel.fetch() + } + } + } + .background(Color.black) + } + .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() + } + } + } + .navigationDestination(for: Int.self) { characterId in + CharacterDetailView(characterId: characterId) + } + } + } +} + +#Preview { + NewCharacterListView() + .background(Color.black) +} diff --git a/SodaLive/Sources/Chat/ChatTabView.swift b/SodaLive/Sources/Chat/ChatTabView.swift index b1746bc..264bcc7 100644 --- a/SodaLive/Sources/Chat/ChatTabView.swift +++ b/SodaLive/Sources/Chat/ChatTabView.swift @@ -29,7 +29,7 @@ struct ChatTabView: View { @State private var selectedTab: InnerTab = .character @State private var isShowAuthView: Bool = false @State private var isShowAuthConfirmView: Bool = false - @State private var pendingCharacterId: Int? = nil + @State private var pendingAction: (() -> Void)? = nil @State private var payload = Payload() // CharacterView에서 전달받는 단일 진입 함수 @@ -40,14 +40,33 @@ struct ChatTabView: View { return } if auth == false { - // 본인인증 전체화면 표시 후 완료 시 바로 이동 - pendingCharacterId = characterId + pendingAction = { + AppState.shared + .setAppStep(step: .characterDetail(characterId: characterId)) + } isShowAuthConfirmView = true return } AppState.shared.setAppStep(step: .characterDetail(characterId: characterId)) } + private func handleCharacterSelection() { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + AppState.shared.setAppStep(step: .login) + return + } + if auth == false { + pendingAction = { + AppState.shared + .setAppStep(step: .newCharacterAll) + } + isShowAuthConfirmView = true + return + } + AppState.shared.setAppStep(step: .newCharacterAll) + } + var body: some View { ZStack { VStack(alignment: .leading, spacing: 0) { @@ -126,9 +145,9 @@ struct ChatTabView: View { .onDone { _ in auth = true isShowAuthView = false - if let chId = pendingCharacterId { - pendingCharacterId = nil - AppState.shared.setAppStep(step: .characterDetail(characterId: chId)) + if let action = pendingAction { + pendingAction = nil + action() } } .onClose { @@ -149,7 +168,7 @@ struct ChatTabView: View { cancelButtonTitle: "취소", cancelButtonAction: { isShowAuthConfirmView = false - pendingCharacterId = nil + pendingAction = nil }, textAlignment: .center ) diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index ae63737..b9ccdb0 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -254,6 +254,9 @@ struct ContentView: View { case .chatRoom(let id): ChatRoomView(roomId: id) + case .newCharacterAll: + NewCharacterListView() + default: EmptyView() .frame(width: 0, height: 0, alignment: .topLeading)