재생 목록 수정 페이지 추가

This commit is contained in:
Yu Sung 2024-12-10 14:06:34 +09:00
parent 8d60e500c0
commit 9ca1493255
6 changed files with 509 additions and 144 deletions

View File

@ -27,4 +27,8 @@ class ContentPlaylistListRepository {
func deletePlaylist(playlistId: Int) -> AnyPublisher<Response, MoyaError> { func deletePlaylist(playlistId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.deletePlaylist(playlistId: playlistId)) return api.requestPublisher(.deletePlaylist(playlistId: playlistId))
} }
func updatePlaylist(playlistId: Int, request: UpdatePlaylistRequest) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.updatePlaylist(playlistId: playlistId, request: request))
}
} }

View File

@ -18,175 +18,184 @@ struct ContentPlaylistDetailView: View {
@State private var isShowPopupMenu = false @State private var isShowPopupMenu = false
@State private var isShowDeleteConfirm = false @State private var isShowDeleteConfirm = false
@State private var isShowModify = false
var body: some View { var body: some View {
BaseView(isLoading: $viewModel.isLoading) { BaseView(isLoading: $viewModel.isLoading) {
VStack(spacing: 21.3) { if reloadData {
HStack(spacing: 5.3) { Color.clear
Image("ic_back")
.resizable()
.frame(width: 20, height: 20)
.padding(8)
.onTapGesture {
isShowing = false
}
Spacer()
Image("ic_edit_white")
.padding(8)
.onTapGesture {
}
Image("ic_seemore_vertical_white")
.padding(8)
.onTapGesture {
isShowPopupMenu = true
}
}
.padding(.horizontal, 13.3)
.frame(height: 50)
.frame(maxWidth: .infinity)
.background(Color.black)
ScrollView(.vertical, showsIndicators: false) { LoadingView()
if let response = viewModel.response { } else {
VStack(alignment: .leading, spacing: 0) { VStack(spacing: 21.3) {
HStack(alignment: .top, spacing: 13.3) { HStack(spacing: 5.3) {
VStack(alignment: .center, spacing: 0) { Image("ic_back")
HStack(spacing: 0) { .resizable()
KFImage(URL(string: response.playlistCoverImageList[0])) .frame(width: 20, height: 20)
.cancelOnDisappear(true) .padding(8)
.downsampling(size: CGSize(width: 80, height: 80)) .onTapGesture {
.resizable() isShowing = false
.scaledToFill() }
.clipped()
.frame(maxWidth: .infinity, maxHeight: .infinity) Spacer()
if response.playlistCoverImageList.count > 2 { Image("ic_edit_white")
KFImage(URL(string: response.playlistCoverImageList[1])) .padding(8)
.onTapGesture {
isShowModify = true
}
Image("ic_seemore_vertical_white")
.padding(8)
.onTapGesture {
isShowPopupMenu = true
}
}
.padding(.horizontal, 13.3)
.frame(height: 50)
.frame(maxWidth: .infinity)
.background(Color.black)
ScrollView(.vertical, showsIndicators: false) {
if let response = viewModel.response {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 13.3) {
VStack(alignment: .center, spacing: 0) {
HStack(spacing: 0) {
KFImage(URL(string: response.playlistCoverImageList[0]))
.cancelOnDisappear(true) .cancelOnDisappear(true)
.downsampling(size: CGSize(width: 80, height: 80)) .downsampling(size: CGSize(width: 80, height: 80))
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.clipped() .clipped()
.frame(maxWidth: 40, maxHeight: 40) .frame(maxWidth: .infinity, maxHeight: .infinity)
if response.playlistCoverImageList.count > 2 {
KFImage(URL(string: response.playlistCoverImageList[1]))
.cancelOnDisappear(true)
.downsampling(size: CGSize(width: 80, height: 80))
.resizable()
.scaledToFill()
.clipped()
.frame(maxWidth: 40, maxHeight: 40)
}
}
HStack(spacing: 0) {
if response.playlistCoverImageList.count > 2 {
KFImage(URL(string: response.playlistCoverImageList[2]))
.cancelOnDisappear(true)
.downsampling(size: CGSize(width: 80, height: 80))
.resizable()
.scaledToFill()
.clipped()
.frame(maxWidth: 40, maxHeight: 40)
}
if response.playlistCoverImageList.count > 3 {
KFImage(URL(string: response.playlistCoverImageList[3]))
.cancelOnDisappear(true)
.downsampling(size: CGSize(width: 80, height: 80))
.resizable()
.scaledToFill()
.clipped()
.frame(maxWidth: 40, maxHeight: 40)
}
} }
} }
.frame(width: 80, height: 80)
.background(Color.graybb)
.cornerRadius(4)
HStack(spacing: 0) { VStack(alignment: .leading, spacing: 6.7) {
if response.playlistCoverImageList.count > 2 { Text(response.title)
KFImage(URL(string: response.playlistCoverImageList[2])) .font(.custom(Font.bold.rawValue, size: 18.3))
.cancelOnDisappear(true) .foregroundColor(Color.grayd2)
.downsampling(size: CGSize(width: 80, height: 80)) .lineLimit(2)
.resizable() .truncationMode(.tail)
.scaledToFill()
.clipped()
.frame(maxWidth: 40, maxHeight: 40)
}
if response.playlistCoverImageList.count > 3 { Text(response.desc.prefix(100))
KFImage(URL(string: response.playlistCoverImageList[3])) .font(.custom(Font.medium.rawValue, size: 12))
.cancelOnDisappear(true) .foregroundColor(Color.gray90)
.downsampling(size: CGSize(width: 80, height: 80)) .truncationMode(.tail)
.resizable()
.scaledToFill()
.clipped()
.frame(maxWidth: 40, maxHeight: 40)
}
} }
} }
.frame(width: 80, height: 80)
.background(Color.graybb)
.cornerRadius(4)
VStack(spacing: 6.7) { HStack(spacing: 0) {
Text(response.title) Text("만든 날짜 \(response.createdDate)")
.font(.custom(Font.bold.rawValue, size: 18.3)) .font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(Color.grayd2)
.lineLimit(2)
.truncationMode(.tail)
Text(response.desc.prefix(100))
.font(.custom(Font.medium.rawValue, size: 12))
.foregroundColor(Color.gray90) .foregroundColor(Color.gray90)
.truncationMode(.tail)
}
}
HStack(spacing: 0) {
Text("만든 날짜 \(response.createdDate)")
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(Color.gray90)
Spacer()
Text("\(response.contentCount)")
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(Color.grayee)
}
.padding(.top, 13.3)
HStack(spacing: 13.3) {
HStack(spacing: 5.3) {
Image("ic_playlist_play")
Text("Play") Spacer()
.font(.custom(Font.bold.rawValue, size: 14.7))
.foregroundColor(Color.white)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 11)
.background(Color.button)
.cornerRadius(5.3)
.contentShape(Rectangle())
.onTapGesture {
}
HStack(spacing: 5.3) {
Image("ic_playlist_shuffle")
Text("Shuffle") Text("\(response.contentCount)")
.font(.custom(Font.bold.rawValue, size: 14.7)) .font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(Color.white) .foregroundColor(Color.grayee)
} }
.frame(maxWidth: .infinity) .padding(.top, 13.3)
.padding(.vertical, 11)
.background(Color.button) HStack(spacing: 13.3) {
.cornerRadius(5.3) HStack(spacing: 5.3) {
.contentShape(Rectangle()) Image("ic_playlist_play")
.onTapGesture {
Text("Play")
.font(.custom(Font.bold.rawValue, size: 14.7))
.foregroundColor(Color.white)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 11)
.background(Color.button)
.cornerRadius(5.3)
.contentShape(Rectangle())
.onTapGesture {
}
HStack(spacing: 5.3) {
Image("ic_playlist_shuffle")
Text("Shuffle")
.font(.custom(Font.bold.rawValue, size: 14.7))
.foregroundColor(Color.white)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 11)
.background(Color.button)
.cornerRadius(5.3)
.contentShape(Rectangle())
.onTapGesture {
}
} }
.padding(.top, 18)
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(0..<response.contentList.count, id: \.self) {
PlaylistContentItemView(item: response.contentList[$0])
}
}
.padding(.top, 18)
} }
.padding(.top, 18) .padding(.horizontal, 13.3)
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(0..<response.contentList.count, id: \.self) {
PlaylistContentItemView(item: response.contentList[$0])
}
}
.padding(.top, 18)
} }
.padding(.horizontal, 13.3)
} }
} }
} .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) { HStack {
HStack { Spacer()
Spacer() Text(viewModel.errorMessage)
Text(viewModel.errorMessage) .padding(.vertical, 13.3)
.padding(.vertical, 13.3) .frame(width: screenSize().width - 66.7, alignment: .center)
.frame(width: screenSize().width - 66.7, alignment: .center) .font(.custom(Font.medium.rawValue, size: 12))
.font(.custom(Font.medium.rawValue, size: 12)) .background(Color.button)
.background(Color.button) .foregroundColor(Color.white)
.foregroundColor(Color.white) .multilineTextAlignment(.leading)
.multilineTextAlignment(.leading) .cornerRadius(20)
.cornerRadius(20) .padding(.bottom, 66.7)
.padding(.bottom, 66.7) Spacer()
Spacer() }
}
.onAppear {
viewModel.playlistId = playlistId
} }
}
.onAppear {
viewModel.playlistId = playlistId
} }
if isShowPopupMenu { if isShowPopupMenu {
@ -243,6 +252,14 @@ struct ContentPlaylistDetailView: View {
) )
} }
} }
if isShowModify {
ContentPlaylistModifyView(
playlistId: playlistId,
isShowing: $isShowModify,
reloadData: $reloadData
)
}
} }
} }
} }

View File

@ -0,0 +1,177 @@
//
// ContentPlaylistModifyView.swift
// SodaLive
//
// Created by klaus on 12/10/24.
//
import SwiftUI
struct ContentPlaylistModifyView: View {
@StateObject var viewModel = ContentPlaylistModifyViewModel()
let playlistId: Int
@Binding var isShowing: Bool
@Binding var reloadData: Bool
@State private var isShowAddContentView = false
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 0) {
Button {
isShowing = false
} label: {
Image("ic_back")
.resizable()
.frame(width: 20, height: 20)
Text("재생목록 수정")
.font(.custom(Font.bold.rawValue, size: 18.3))
.foregroundColor(Color.grayee)
}
Spacer()
Text("수정")
.font(.custom(Font.medium.rawValue, size: 14.7))
.foregroundColor(Color.grayee)
.frame(minHeight: 48)
.onTapGesture {
viewModel.modifyPlaylist {
reloadData = true
isShowing = false
}
}
}
.frame(height: 50)
.padding(.horizontal, 13.3)
.frame(maxWidth: .infinity)
.background(Color.black)
HStack(spacing: 0) {
Text("재생목록 제목")
.font(.custom(Font.bold.rawValue, size: 16.7))
.foregroundColor(Color.grayee)
Spacer()
Text("\(viewModel.title.count)/30")
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(Color.gray77)
.onChange(of: viewModel.title) { newValue in
if newValue.count > 30 {
viewModel.title = String(newValue.prefix(30))
}
}
}
.padding(.top, 26.7)
.padding(.horizontal, 13.3)
TextField("", text: $viewModel.title)
.autocapitalization(.none)
.disableAutocorrection(true)
.font(.custom(Font.bold.rawValue, size: 13.3))
.foregroundColor(Color.grayee)
.keyboardType(.webSearch)
.frame(maxWidth: .infinity)
.padding(.horizontal, 13.3)
.padding(.vertical, 17)
.background(Color.gray22)
.cornerRadius(6.7)
.padding(.top, 13.3)
.padding(.horizontal, 13.3)
HStack(spacing: 0) {
Text("재생목록 설명을 입력해 주세요")
.font(.custom(Font.bold.rawValue, size: 16.7))
.foregroundColor(Color.grayee)
Spacer()
Text("\(viewModel.desc.count)/40")
.font(.custom(Font.medium.rawValue, size: 13.3))
.foregroundColor(Color.gray77)
.onChange(of: viewModel.desc) { newValue in
if newValue.count > 40 {
viewModel.desc = String(newValue.prefix(40))
}
}
}
.padding(.top, 26.7)
.padding(.horizontal, 13.3)
TextField("", text: $viewModel.desc)
.autocapitalization(.none)
.disableAutocorrection(true)
.font(.custom(Font.bold.rawValue, size: 13.3))
.foregroundColor(Color.grayee)
.keyboardType(.webSearch)
.frame(maxWidth: .infinity)
.padding(.horizontal, 13.3)
.padding(.vertical, 17)
.background(Color.gray22)
.cornerRadius(6.7)
.padding(.top, 13.3)
.padding(.horizontal, 13.3)
HStack(spacing: 8) {
Image("btn_plus_round")
Text("새로운 콘텐츠 추가/제거")
.font(.custom(Font.bold.rawValue, size: 14.7))
.foregroundColor(Color.button)
}
.padding(.top, 26.7)
.padding(.horizontal, 13.3)
.onTapGesture {
isShowAddContentView = true
}
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 13.3) {
ForEach(0..<viewModel.contentList.count, id: \.self) { index in
PlaylistCreateContentView(content: viewModel.contentList[index])
}
}
.padding(.horizontal, 13.3)
}
.padding(.vertical, 13.3)
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .bottom, autohideIn: 2) {
HStack {
Spacer()
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: screenSize().width - 66.7, alignment: .center)
.font(.custom(Font.medium.rawValue, size: 12))
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.leading)
.cornerRadius(20)
.padding(.bottom, 66.7)
Spacer()
}
}
.onAppear {
viewModel.playlistId = playlistId
}
if isShowAddContentView {
PlaylistAddContentView(
isShowing: $isShowAddContentView,
contentList: $viewModel.contentList
)
}
}
}
}
#Preview {
ContentPlaylistModifyView(
playlistId: 0,
isShowing: .constant(true),
reloadData: .constant(false)
)
}

View File

@ -0,0 +1,145 @@
//
// ContentPlaylistModifyViewModel.swift
// SodaLive
//
// Created by klaus on 12/10/24.
//
import Foundation
import Combine
final class ContentPlaylistModifyViewModel: ObservableObject {
private let repository = ContentPlaylistListRepository()
private var subscription = Set<AnyCancellable>()
@Published var isLoading = false
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var title: String = ""
@Published var desc: String = ""
var contentList = [AudioContentPlaylistContent]()
@Published var response: GetPlaylistDetailResponse? = nil
var playlistId: Int = 0 {
didSet {
if playlistId > 0 {
getPlaylistDetail()
}
}
}
private func getPlaylistDetail() {
isLoading = true
repository.getPlaylistDetail(playlistId: playlistId)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<GetPlaylistDetailResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.response = data
self.title = data.title
self.desc = data.desc
self.contentList.append(contentsOf: data.contentList)
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self.isShowPopup = true
}
} catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true
}
self.isLoading = false
}
.store(in: &subscription)
}
func modifyPlaylist(onSuccess: @escaping () -> Void) {
if let response = response, validate() {
isLoading = true
let contentIdAndOrderList = contentList.mapIndexed { index, item in
PlaylistContentIdAndOrder(contentId: item.id, order: index + 1)
}
let request = UpdatePlaylistRequest(
title: self.title != response.title ? self.title : nil,
desc: self.desc != response.desc ? self.desc : nil,
contentIdAndOrderList: contentIdAndOrderList
)
repository.updatePlaylist(
playlistId: playlistId,
request: request
)
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { [unowned self] response in
self.isLoading = false
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData)
self.isLoading = false
if decoded.success {
onSuccess()
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self.isShowPopup = true
}
} catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true
self.isLoading = false
}
}
.store(in: &subscription)
}
}
private func validate() -> Bool {
if (title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || title.count < 3) {
errorMessage = "제목을 3자 이상 입력하세요"
isShowPopup = true
return false
}
if (contentList.isEmpty) {
errorMessage = "콘텐츠를 1개 이상 추가하세요"
isShowPopup = true
return false
}
return true
}
}

View File

@ -0,0 +1,12 @@
//
// UpdatePlaylistRequest.swift
// SodaLive
//
// Created by klaus on 12/10/24.
//
struct UpdatePlaylistRequest: Encodable {
let title: String?
let desc: String?
let contentIdAndOrderList: [PlaylistContentIdAndOrder]
}

View File

@ -13,6 +13,7 @@ enum PlaylistApi {
case createPlaylist(request: CreatePlaylistRequest) case createPlaylist(request: CreatePlaylistRequest)
case getPlaylistDetail(playlistId: Int) case getPlaylistDetail(playlistId: Int)
case deletePlaylist(playlistId: Int) case deletePlaylist(playlistId: Int)
case updatePlaylist(playlistId: Int, request: UpdatePlaylistRequest)
} }
extension PlaylistApi: TargetType { extension PlaylistApi: TargetType {
@ -30,6 +31,9 @@ extension PlaylistApi: TargetType {
case .deletePlaylist(let playlistId): case .deletePlaylist(let playlistId):
return "/audio-content/playlist/\(playlistId)" return "/audio-content/playlist/\(playlistId)"
case .updatePlaylist(let playlistId, _):
return "/audio-content/playlist/\(playlistId)"
} }
} }
@ -43,6 +47,9 @@ extension PlaylistApi: TargetType {
case .deletePlaylist: case .deletePlaylist:
return .delete return .delete
case .updatePlaylist:
return .put
} }
} }
@ -53,6 +60,9 @@ extension PlaylistApi: TargetType {
case .createPlaylist(let request): case .createPlaylist(let request):
return .requestJSONEncodable(request) return .requestJSONEncodable(request)
case .updatePlaylist(_, let request):
return .requestJSONEncodable(request)
} }
} }