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

@@ -127,6 +127,14 @@ enum AppStep {
case creatorCommunityWrite(onSuccess: () -> Void)
case creatorCommunityModify(postId: Int, onSuccess: () -> Void)
case creatorChannelFanTalkWrite(
creatorId: Int,
mode: CreatorChannelFanTalkWriteViewModel.Mode,
fanTalkId: Int?,
initialContent: String,
onSuccess: () -> Void
)
case canCoupon(refresh: () -> Void)

View File

@@ -254,6 +254,9 @@ struct AppStepLayerView: View {
case .creatorCommunityModify(let postId, let onSuccess):
CreatorCommunityModifyView(postId: postId, onSuccess: onSuccess)
case .creatorChannelFanTalkWrite(let creatorId, let mode, _, _, let onSuccess):
CreatorChannelFanTalkWriteView(creatorId: creatorId, mode: mode, onSuccess: onSuccess)
case .canCoupon(let refresh):
CanCouponView(refresh: refresh)

View File

@@ -3407,6 +3407,14 @@ If you block this user, the following features will be restricted.
}
}
enum CreatorChannelFanTalk {
static var send: String { pick(ko: "보내기", en: "Send", ja: "送信") }
static var exitModalTitle: String { pick(ko: "입력 종료", en: "Discard input", ja: "入力終了") }
static var exitModalMessage: String { pick(ko: "입력을 종료할까요?\n지금 나가면 입력한 내용이 저장되지 않아요.", en: "Discard your input?\nIf you leave now, your input will not be saved.", ja: "入力を終了しますか?\n今退出すると入力した内容は保存されません。") }
static var exitModalExit: String { pick(ko: "종료", en: "Discard", ja: "終了") }
static var exitModalContinue: String { pick(ko: "계속 작성", en: "Keep writing", ja: "続けて書く") }
}
enum HomeRecommendation {
static var activityLive: String {
pick(ko: "라이브", en: "Live", ja: "ライブ")

View File

@@ -0,0 +1,90 @@
import SwiftUI
struct SodaV2ActionModalButton {
let label: String?
let action: (() -> Void)?
}
struct SodaV2ActionModal: View {
let title: String
let message: String
let button1: SodaV2ActionModalButton?
let button2: SodaV2ActionModalButton?
let onDimmedTap: () -> Void
private var visibleButtons: [(label: String, action: () -> Void, isPrimary: Bool)] {
var buttons = [(label: String, action: () -> Void, isPrimary: Bool)]()
if let button2,
let label = button2.label,
let action = button2.action {
buttons.append((label, action, false))
}
if let button1,
let label = button1.label,
let action = button1.action {
buttons.append((label, action, true))
}
return buttons
}
var body: some View {
ZStack {
Color.black.opacity(0.6)
.ignoresSafeArea()
.onTapGesture(perform: onDimmedTap)
VStack(spacing: 0) {
Text(title)
.appFont(size: 20, weight: .bold)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.frame(width: 340, height: 64)
Text(message)
.appFont(size: 16, weight: .regular)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.padding(20)
.frame(width: 340)
buttonArea
.padding(.horizontal, 20)
.padding(.vertical, 14)
.frame(width: 340)
}
.background(Color.gray900)
.cornerRadius(14)
}
}
@ViewBuilder
private var buttonArea: some View {
let buttons = visibleButtons
if buttons.count == 1, let button = buttons.first {
actionButton(title: button.label, isPrimary: true, action: button.action)
} else if buttons.count == 2 {
HStack(spacing: 8) {
ForEach(Array(buttons.enumerated()), id: \.offset) { _, button in
actionButton(title: button.label, isPrimary: button.isPrimary, action: button.action)
}
}
}
}
private func actionButton(title: String, isPrimary: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.appFont(size: 18, weight: .medium)
.foregroundColor(isPrimary ? Color.soda400 : Color.white)
.frame(maxWidth: .infinity)
.padding(14)
.background(Color.clear)
.cornerRadius(100)
}
.buttonStyle(.plain)
}
}

View File

@@ -291,7 +291,8 @@ struct CreatorChannelView: View {
creatorId: creatorId,
isOwnCreatorChannel: isOwnCreatorChannel,
viewModel: fanTalkViewModel,
onTapWrite: showFanTalkWriteView
onTapWrite: showFanTalkWriteView,
onTapEdit: showFanTalkModifyView
)
} else if viewModel.selectedTab == .community {
CreatorChannelCommunityTabView(
@@ -599,7 +600,35 @@ struct CreatorChannelView: View {
communityViewModel.selectedPostId = 0
}
private func showFanTalkWriteView() {}
private func showFanTalkWriteView() {
AppState.shared.setAppStep(
step: .creatorChannelFanTalkWrite(
creatorId: creatorId,
mode: .write,
fanTalkId: nil,
initialContent: "",
onSuccess: {
viewModel.fetchHome(creatorId: creatorId)
fanTalkViewModel.fetchFirstPage(creatorId: creatorId)
}
)
)
}
private func showFanTalkModifyView(_ fanTalk: CreatorChannelFanTalkItemResponse) {
AppState.shared.setAppStep(
step: .creatorChannelFanTalkWrite(
creatorId: creatorId,
mode: .modify(fanTalkId: fanTalk.fanTalkId, initialContent: fanTalk.content),
fanTalkId: fanTalk.fanTalkId,
initialContent: fanTalk.content,
onSuccess: {
viewModel.fetchHome(creatorId: creatorId)
fanTalkViewModel.fetchFirstPage(creatorId: creatorId)
}
)
)
}
}
struct CreatorChannelView_Previews: PreviewProvider {

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
}
}