feat(creator): 팬Talk 쓰기 화면을 연결한다

This commit is contained in:
Yu Sung
2026-07-06 22:54:20 +09:00
parent d129583d3b
commit 9c4c304fc6
11 changed files with 716 additions and 10 deletions

View File

@@ -5,6 +5,7 @@ struct CreatorChannelFanTalkTabView: View {
let isOwnCreatorChannel: Bool
@ObservedObject var viewModel: CreatorChannelFanTalkViewModel
let onTapWrite: () -> Void
let onTapEdit: (CreatorChannelFanTalkItemResponse) -> Void
@State private var actionPopupFanTalk: CreatorChannelFanTalkItemResponse?
@State private var fanTalkItemFrames = [Int: CGRect]()
@@ -98,6 +99,7 @@ struct CreatorChannelFanTalkTabView: View {
isOwnCreatorChannel: isOwnCreatorChannel,
onTapEdit: {
actionPopupFanTalk = nil
onTapEdit(fanTalk)
},
onTapDelete: {
actionPopupFanTalk = nil

View File

@@ -0,0 +1,146 @@
import SwiftUI
struct CreatorChannelFanTalkWriteView: View {
@StateObject private var viewModel: CreatorChannelFanTalkWriteViewModel
@FocusState private var isFocused: Bool
let onSuccess: () -> Void
init(
creatorId: Int,
mode: CreatorChannelFanTalkWriteViewModel.Mode,
onSuccess: @escaping () -> Void
) {
_viewModel = StateObject(wrappedValue: CreatorChannelFanTalkWriteViewModel(creatorId: creatorId, mode: mode))
self.onSuccess = onSuccess
}
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
ZStack(alignment: .bottom) {
Color.black
.ignoresSafeArea()
.onTapGesture { hideKeyboard() }
VStack(spacing: 0) {
navigationBar
TextEditor(text: $viewModel.content)
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.white)
.accentColor(Color.soda400)
.scrollContentBackground(.hidden)
.background(Color.black)
.focused($isFocused)
.padding(.horizontal, SodaSpacing.s14)
.padding(.top, SodaSpacing.s20)
.padding(.bottom, 52)
}
characterCountBar
}
.navigationBarBackButtonHidden(true)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
isFocused = true
}
}
.overlay(exitModal)
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 1)
}
}
private var navigationBar: some View {
HStack(spacing: 0) {
Button(action: cancel) {
Text(I18n.Common.cancel)
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.white)
.frame(width: 64, height: 56, alignment: .leading)
}
.buttonStyle(.plain)
Spacer()
Button(action: submit) {
Text(I18n.CreatorChannelFanTalk.send)
.appFont(size: 16, weight: .medium)
.foregroundColor(Color.white)
.padding(.horizontal, SodaSpacing.s12)
.frame(height: 44)
.background(viewModel.canSend ? Color.soda400 : Color.gray800)
.clipShape(Capsule())
}
.buttonStyle(.plain)
.disabled(viewModel.canSend == false)
}
.padding(.horizontal, SodaSpacing.s14)
.frame(height: 56)
.background(Color.black)
}
private var characterCountBar: some View {
HStack(spacing: 0) {
Spacer()
Text("\(viewModel.content.count)")
.appFont(size: 16, weight: .medium)
.foregroundColor(Color.white)
Text("/\(viewModel.maxContentCount)")
.appFont(size: 16, weight: .medium)
.foregroundColor(Color.gray500)
}
.padding(.horizontal, SodaSpacing.s14)
.frame(height: 52)
.background(Color.black)
.overlay(alignment: .top) {
Rectangle()
.fill(Color.gray700)
.frame(height: 1)
}
}
@ViewBuilder
private var exitModal: some View {
if viewModel.shouldShowExitModal {
SodaV2ActionModal(
title: I18n.CreatorChannelFanTalk.exitModalTitle,
message: I18n.CreatorChannelFanTalk.exitModalMessage,
button1: SodaV2ActionModalButton(
label: I18n.CreatorChannelFanTalk.exitModalContinue,
action: {
viewModel.shouldShowExitModal = false
}
),
button2: SodaV2ActionModalButton(
label: I18n.CreatorChannelFanTalk.exitModalExit,
action: {
viewModel.shouldShowExitModal = false
AppState.shared.back()
}
),
onDimmedTap: {
viewModel.shouldShowExitModal = false
}
)
}
}
private func cancel() {
hideKeyboard()
if viewModel.handleCancel() {
AppState.shared.back()
}
}
private func submit() {
hideKeyboard()
viewModel.submit {
AppState.shared.back()
DispatchQueue.main.async {
onSuccess()
}
}
}
}

View File

@@ -0,0 +1,130 @@
import Foundation
import Combine
final class CreatorChannelFanTalkWriteViewModel: ObservableObject {
enum Mode {
case write
case modify(fanTalkId: Int, initialContent: String)
var initialContent: String {
if case .modify(_, let initialContent) = self {
return initialContent
}
return ""
}
}
private let repository = ExplorerRepository()
private var subscription = Set<AnyCancellable>()
let creatorId: Int
let mode: Mode
@Published var content: String
@Published var isLoading = false
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var shouldShowExitModal = false
let maxContentCount = 500
var canSend: Bool {
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
init(creatorId: Int, mode: Mode) {
self.creatorId = creatorId
self.mode = mode
content = mode.initialContent
}
func handleCancel() -> Bool {
guard canSend else { return true }
shouldShowExitModal = true
return false
}
func submit(onSuccess: @escaping () -> Void) {
guard canSend, isLoading == false else { return }
switch mode {
case .write:
writeCheers(onSuccess: onSuccess)
case .modify(let fanTalkId, _):
modifyCheers(fanTalkId: fanTalkId, onSuccess: onSuccess)
}
}
private func writeCheers(onSuccess: @escaping () -> Void) {
isLoading = true
repository.writeCheers(parentCheersId: nil, creatorId: creatorId, content: content)
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyFailureState()
}
} receiveValue: { [weak self] response in
self?.handleMutationResponse(response.data, onSuccess: onSuccess)
}
.store(in: &subscription)
}
private func modifyCheers(fanTalkId: Int, onSuccess: @escaping () -> Void) {
guard fanTalkId > 0 else {
errorMessage = I18n.Common.commonError
isShowPopup = true
return
}
isLoading = true
repository.modifyCheers(cheersId: fanTalkId, content: content, isActive: nil)
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyFailureState()
}
} receiveValue: { [weak self] response in
self?.handleMutationResponse(response.data, onSuccess: onSuccess)
}
.store(in: &subscription)
}
private func handleMutationResponse(_ data: Data, onSuccess: @escaping () -> Void) {
isLoading = false
do {
let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: data)
if decoded.success {
onSuccess()
} else {
errorMessage = decoded.message ?? I18n.Common.commonError
isShowPopup = true
}
} catch {
ERROR_LOG(error.localizedDescription)
errorMessage = I18n.Common.commonError
isShowPopup = true
}
}
private func applyFailureState() {
isLoading = false
errorMessage = I18n.Common.commonError
isShowPopup = true
}
}