diff --git a/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3aa7930..a9dd4ee 100644 --- a/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -144,6 +144,15 @@ "version" : "1.1.1" } }, + { + "identity" : "richtext", + "kind" : "remoteSourceControl", + "location" : "https://github.com/NuPlay/RichText.git", + "state" : { + "revision" : "ff468d18b066ea5838a2d3f9cb572d55b8ebdb11", + "version" : "2.3.0" + } + }, { "identity" : "rxswift", "kind" : "remoteSourceControl", diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index ec23b2f..444c9a6 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -23,4 +23,18 @@ enum AppStep { case writeVoiceMessage(userId: Int?, nickname: String?, onRefresh: () -> Void) case settings + + case notices + + case noticeDetail(notice: NoticeItem) + + case events + + case eventDetail(event: EventItem) + + case terms + + case privacy + + case notificationSettings } diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 328f3f3..2487e34 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -36,8 +36,28 @@ struct ContentView: View { VoiceMessageWriteView(replySenderId: userId, replySenderNickname: nickname, onRefresh: onRefresh) case .settings: - EmptyView() - .frame(width: 0, height: 0, alignment: .topLeading) + SettingsView() + + case .notices: + NoticeListView() + + case .noticeDetail(let notice): + NoticeDetailView(notice: notice) + + case .events: + EventListView() + + case .eventDetail(let event): + EventDetailView(event: event) + + case .terms: + TermsView(isPrivacyPolicy: false) + + case .privacy: + TermsView(isPrivacyPolicy: true) + + case .notificationSettings: + NotificationSettingsView() default: EmptyView() diff --git a/SodaLive/Sources/Settings/Event/EventDetailView.swift b/SodaLive/Sources/Settings/Event/EventDetailView.swift new file mode 100644 index 0000000..382eb5d --- /dev/null +++ b/SodaLive/Sources/Settings/Event/EventDetailView.swift @@ -0,0 +1,69 @@ +// +// EventDetailView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI +import Kingfisher + +struct EventDetailView: View { + + let event: EventItem + + var body: some View { + BaseView { + GeometryReader { proxy in + VStack(spacing: 0) { + DetailNavigationBar(title: "이벤트 상세") + + ScrollView(.vertical, showsIndicators: false) { + KFImage(URL(string: event.detailImageUrl!)) + .resizable() + .scaledToFit() + } + + Spacer() + + if let link = event.link, link.count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) { + Text("이벤트 참여하기") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(.white) + .padding(.vertical, 16) + .frame(width: screenSize().width - 26.7) + .background(Color(hex: "3e737c")) + .cornerRadius(10) + .padding(13.3) + .background(Color(hex: "222222")) + .cornerRadius(16.7, corners: [.topLeft, .topRight]) + .onTapGesture { + UIApplication.shared.open(url) + } + } + + if proxy.safeAreaInsets.bottom > 0 { + Rectangle() + .foregroundColor(Color(hex: "222222")) + .frame(width: proxy.size.width, height: 15.3) + } + } + .edgesIgnoringSafeArea(.bottom) + } + } + } +} + +struct EventDetailView_Previews: PreviewProvider { + static var previews: some View { + EventDetailView( + event: EventItem( + id: 1, + thumbnailImageUrl: "", + detailImageUrl: "", + popupImageUrl: "", + link: "http://m.naver.com" + ) + ) + } +} diff --git a/SodaLive/Sources/Settings/Event/EventListView.swift b/SodaLive/Sources/Settings/Event/EventListView.swift new file mode 100644 index 0000000..624f8bb --- /dev/null +++ b/SodaLive/Sources/Settings/Event/EventListView.swift @@ -0,0 +1,73 @@ +// +// EventListView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI +import Kingfisher + +struct EventListView: View { + + @ObservedObject var viewModel = EventListViewModel() + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 0) { + DetailNavigationBar(title: "이벤트") + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + ForEach(viewModel.events, id: \.self) { event in + KFImage(URL(string: event.thumbnailImageUrl)) + .resizable() + .scaledToFill() + .frame( + width: screenSize().width - 26.7, + height: (screenSize().width - 26.7) * 300 / 1000 + ) + .contentShape(Rectangle()) + .onTapGesture { + if let _ = event.detailImageUrl { + AppState.shared.setAppStep(step: .eventDetail(event: event)) + } else if let link = event.link, link.trimmingCharacters(in: .whitespaces).count > 0, let url = URL(string: link), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } + } + } + } + .padding(.vertical, 13.3) + } + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .padding(.horizontal, 6.7) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + .onAppear { + viewModel.getEvents() + } + } +} + +struct EventListView_Previews: PreviewProvider { + static var previews: some View { + EventListView() + } +} diff --git a/SodaLive/Sources/Settings/Event/EventListViewModel.swift b/SodaLive/Sources/Settings/Event/EventListViewModel.swift new file mode 100644 index 0000000..872c92a --- /dev/null +++ b/SodaLive/Sources/Settings/Event/EventListViewModel.swift @@ -0,0 +1,60 @@ +// +// EventListViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Combine + +final class EventListViewModel: ObservableObject { + private let eventRepository = EventRepository() + private var subscription = Set() + + @Published private(set) var events = [EventItem]() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + private var page = 1 + private let size = 10 + + func getEvents() { + isLoading = true + eventRepository.getEvents() + .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(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.events.append(contentsOf: data.eventList) + } 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/Settings/Notice/GetNoticeResponse.swift b/SodaLive/Sources/Settings/Notice/GetNoticeResponse.swift new file mode 100644 index 0000000..6cbe360 --- /dev/null +++ b/SodaLive/Sources/Settings/Notice/GetNoticeResponse.swift @@ -0,0 +1,19 @@ +// +// GetNoticeResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation + +struct GetNoticeResponse: Decodable { + let totalCount: Int + let noticeList: [NoticeItem] +} + +struct NoticeItem: Decodable, Hashable { + let title: String + let content: String + let date: String +} diff --git a/SodaLive/Sources/Settings/Notice/NoticeApi.swift b/SodaLive/Sources/Settings/Notice/NoticeApi.swift new file mode 100644 index 0000000..d07c611 --- /dev/null +++ b/SodaLive/Sources/Settings/Notice/NoticeApi.swift @@ -0,0 +1,45 @@ +// +// NoticeApi.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Moya + +enum NoticeApi { + case getNotices +} + +extension NoticeApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .getNotices: + return "/notice" + } + } + + var method: Moya.Method { + return .get + } + + var task: Task { + switch self { + case .getNotices: + let parameters = ["timezone": TimeZone.current.identifier] as [String: Any] + return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) + } + } + + var headers: [String : String]? { + switch self { + default: + return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } + } +} diff --git a/SodaLive/Sources/Settings/Notice/NoticeDetailView.swift b/SodaLive/Sources/Settings/Notice/NoticeDetailView.swift new file mode 100644 index 0000000..b8131b6 --- /dev/null +++ b/SodaLive/Sources/Settings/Notice/NoticeDetailView.swift @@ -0,0 +1,57 @@ +// +// NoticeDetailView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI +import RichText + +struct NoticeDetailView: View { + + let notice: NoticeItem + + var body: some View { + BaseView { + VStack(spacing: 0) { + DetailNavigationBar(title: "공지사항 상세") + + VStack(alignment: .leading, spacing: 6.7) { + Text(notice.title) + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.horizontal, 13.3) + + Text(notice.date) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "525252")) + .padding(.horizontal, 13.3) + } + .padding(.horizontal, 13.3) + .padding(.vertical, 21.7) + .frame(width: screenSize().width - 26.7, alignment: .leading) + .background(Color(hex: "222222")) + .cornerRadius(6.7) + + ScrollView(.vertical, showsIndicators: false) { + RichText(html: notice.content) + .padding(.horizontal, 33.3) + .padding(.vertical, 26.7) + } + } + } + } +} + +struct NoticeDetailView_Previews: PreviewProvider { + static var previews: some View { + NoticeDetailView( + notice: NoticeItem( + title: "제목", + content: "

콘텐츠

", + date: "2022.03.03" + ) + ) + } +} diff --git a/SodaLive/Sources/Settings/Notice/NoticeListView.swift b/SodaLive/Sources/Settings/Notice/NoticeListView.swift new file mode 100644 index 0000000..dd239ed --- /dev/null +++ b/SodaLive/Sources/Settings/Notice/NoticeListView.swift @@ -0,0 +1,82 @@ +// +// NoticeListView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI +import RichText + +struct NoticeListView: View { + @ObservedObject var viewModel = NoticeListViewModel() + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 0) { + DetailNavigationBar(title: "공지사항") + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + ForEach(viewModel.notices, id: \.self) { notice in + VStack(alignment: .leading, spacing: 6.7) { + Spacer() + + Text(notice.title) + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.horizontal, 13.3) + + Text(notice.date) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "525252")) + .padding(.horizontal, 13.3) + + Spacer() + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.5)) + } + .padding(.horizontal, 13.3) + .frame(width: screenSize().width, height: 80) + .contentShape(Rectangle()) + .onTapGesture { + AppState.shared.setAppStep(step: .noticeDetail(notice: notice)) + } + } + } + } + .padding(.vertical, 13.3) + } + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .padding(.horizontal, 6.7) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + .onAppear { + viewModel.getNotices() + } + } +} + +struct NoticeListView_Previews: PreviewProvider { + static var previews: some View { + NoticeListView() + } +} diff --git a/SodaLive/Sources/Settings/Notice/NoticeListViewModel.swift b/SodaLive/Sources/Settings/Notice/NoticeListViewModel.swift new file mode 100644 index 0000000..cbcaecf --- /dev/null +++ b/SodaLive/Sources/Settings/Notice/NoticeListViewModel.swift @@ -0,0 +1,59 @@ +// +// NoticeListViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Combine + +final class NoticeListViewModel: ObservableObject { + + private let repository = NoticeRepository() + private var subscription = Set() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var notices = [NoticeItem]() + + func getNotices() { + isLoading = true + + repository.getNotices() + .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(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.notices.append(contentsOf: data.noticeList) + } 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/Settings/Notice/NoticeRepository.swift b/SodaLive/Sources/Settings/Notice/NoticeRepository.swift new file mode 100644 index 0000000..6657117 --- /dev/null +++ b/SodaLive/Sources/Settings/Notice/NoticeRepository.swift @@ -0,0 +1,21 @@ +// +// NoticeRepository.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import CombineMoya +import Combine +import Moya + +final class NoticeRepository { + + private let api = MoyaProvider() + + func getNotices() -> AnyPublisher { + return api.requestPublisher(.getNotices) + } +} + diff --git a/SodaLive/Sources/Settings/Notification/NotificationSettingsView.swift b/SodaLive/Sources/Settings/Notification/NotificationSettingsView.swift new file mode 100644 index 0000000..a731325 --- /dev/null +++ b/SodaLive/Sources/Settings/Notification/NotificationSettingsView.swift @@ -0,0 +1,115 @@ +// +// NotificationSettingsView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI + +struct NotificationSettingsView: View { + + @StateObject var viewModel = NotificationSettingsViewModel() + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 0) { + DetailNavigationBar(title: "알림 설정") + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("라이브 알림") + .font(.custom(Font.bold.rawValue, size: 15)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Image(viewModel.followingChannelLive ? "btn_toggle_on_big" : "btn_toggle_off_big") + .resizable() + .frame(width: 44, height: 27) + .onTapGesture { + viewModel.followingChannelLive.toggle() + } + } + .frame(height: 50) + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.3)) + + HStack(spacing: 0) { + Text("콘텐츠 업로드 알림") + .font(.custom(Font.bold.rawValue, size: 15)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Image(viewModel.followingChannelUploadContent ? "btn_toggle_on_big" : "btn_toggle_off_big") + .resizable() + .frame(width: 44, height: 27) + .onTapGesture { + viewModel.followingChannelUploadContent.toggle() + } + } + .frame(height: 50) + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.3)) + + HStack(spacing: 0) { + Text("메시지 알림") + .font(.custom(Font.bold.rawValue, size: 15)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Image(viewModel.message ? "btn_toggle_on_big" : "btn_toggle_off_big") + .resizable() + .frame(width: 44, height: 27) + .onTapGesture { + viewModel.message.toggle() + } + } + .frame(height: 50) + } + .padding(.vertical, 6.7) + .padding(.horizontal, 13.3) + .background(Color(hex: "222222")) + .cornerRadius(10) + .padding(.top, 26.7) + .padding(.horizontal, 13.3) + } + } + .onAppear { + viewModel.getMemberInfo() + } + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .padding(.horizontal, 6.7) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + } +} + +struct NotificationSettingsView_Previews: PreviewProvider { + static var previews: some View { + NotificationSettingsView() + } +} diff --git a/SodaLive/Sources/Settings/Notification/NotificationSettingsViewModel.swift b/SodaLive/Sources/Settings/Notification/NotificationSettingsViewModel.swift new file mode 100644 index 0000000..5bb9b18 --- /dev/null +++ b/SodaLive/Sources/Settings/Notification/NotificationSettingsViewModel.swift @@ -0,0 +1,93 @@ +// +// NotificationSettingsViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Combine + +final class NotificationSettingsViewModel: ObservableObject { + + private let userRepository = UserRepository() + private var subscription = Set() + + @Published var followingChannelLive = false { + didSet { + submit(live: followingChannelLive) + } + } + @Published var followingChannelUploadContent = false { + didSet { + submit(uploadContent: followingChannelUploadContent) + } + } + @Published var message = false { + didSet { + submit(message: message) + } + } + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + func getMemberInfo() { + isLoading = true + + userRepository.getMemberInfo() + .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.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.followingChannelLive = data.followingChannelLiveNotice ?? false + self.followingChannelUploadContent = data.followingChannelUploadContentNotice ?? false + self.message = data.messageNotice ?? false + } 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 submit(live: Bool? = nil, uploadContent: Bool? = nil, message: Bool? = nil) { + if !isLoading && (live != nil || uploadContent != nil || message != nil) { + userRepository + .updateNotificationSettings(live: live, uploadContent: uploadContent, message: message) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { _ in + } + .store(in: &subscription) + } + } +} diff --git a/SodaLive/Sources/Settings/Notification/UpdateNotificationSettingRequest.swift b/SodaLive/Sources/Settings/Notification/UpdateNotificationSettingRequest.swift new file mode 100644 index 0000000..f150a88 --- /dev/null +++ b/SodaLive/Sources/Settings/Notification/UpdateNotificationSettingRequest.swift @@ -0,0 +1,14 @@ +// +// UpdateNotificationSettingRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation + +struct UpdateNotificationSettingRequest: Encodable { + var live: Bool? = nil + var uploadContent: Bool? = nil + var message: Bool? = nil +} diff --git a/SodaLive/Sources/Settings/SettingsView.swift b/SodaLive/Sources/Settings/SettingsView.swift new file mode 100644 index 0000000..b07826d --- /dev/null +++ b/SodaLive/Sources/Settings/SettingsView.swift @@ -0,0 +1,252 @@ +// +// SettingsView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI + +struct SettingsView: View { + @State private var isShowLogoutDialog = false + @State private var isShowLogoutAllDeviceDialog = false + + @StateObject var viewModel = SettingsViewModel() + + var body: some View { + let cardWidth = screenSize().width - 26.7 + + BaseView(isLoading: $viewModel.isLoading) { + GeometryReader { geo in + VStack(spacing: 0) { + DetailNavigationBar(title: "설정") + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("공지사항") + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Image("ic_forward") + .resizable() + .frame(width: 20, height: 20) + } + .padding(.horizontal, 3.3) + .frame(width: cardWidth - 26.7, height: 50) + .contentShape(Rectangle()) + .onTapGesture { + AppState.shared.setAppStep(step: .notices) + } + + Rectangle() + .frame(width: cardWidth - 26.7, height: 0.3) + .foregroundColor(Color(hex: "909090")) + + HStack(spacing: 0) { + Text("이벤트") + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Image("ic_forward") + .resizable() + .frame(width: 20, height: 20) + } + .padding(.horizontal, 3.3) + .frame(width: cardWidth - 26.7, height: 50) + .contentShape(Rectangle()) + .onTapGesture { + AppState.shared.setAppStep(step: .events) + } + + Rectangle() + .frame(width: cardWidth - 26.7, height: 0.3) + .foregroundColor(Color(hex: "909090")) + + HStack(spacing: 0) { + Text("알림 설정") + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Image("ic_forward") + .resizable() + .frame(width: 20, height: 20) + } + .padding(.horizontal, 3.3) + .frame(width: cardWidth - 26.7, height: 50) + .contentShape(Rectangle()) + .onTapGesture { + AppState.shared.setAppStep(step: .notificationSettings) + } + } + .padding(.horizontal, 13.3) + .frame(width: cardWidth) + .background(Color(hex: "222222")) + .cornerRadius(6.7) + .padding(.top, 26.7) + + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("이용약관") + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Image("ic_forward") + .resizable() + .frame(width: 20, height: 20) + } + .padding(.horizontal, 3.3) + .frame(width: cardWidth - 26.7, height: 50) + .contentShape(Rectangle()) + .onTapGesture { + AppState.shared.setAppStep(step: .terms) + } + + Rectangle() + .frame(width: cardWidth - 26.7, height: 0.3) + .foregroundColor(Color(hex: "909090")) + + HStack(spacing: 0) { + Text("개인정보처리방침") + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Image("ic_forward") + .resizable() + .frame(width: 20, height: 20) + } + .padding(.horizontal, 3.3) + .frame(width: cardWidth - 26.7, height: 50) + .contentShape(Rectangle()) + .onTapGesture { + AppState.shared.setAppStep(step: .privacy) + } + } + .padding(.horizontal, 13.3) + .frame(width: cardWidth) + .background(Color(hex: "222222")) + .cornerRadius(6.7) + .padding(.top, 13.3) + + HStack(spacing: 0) { + Text("앱 버전 정보") + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + + Text("Ver \(version!)") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + .padding(.horizontal, 16.7) + .frame(width: cardWidth, height: 50) + .background(Color(hex: "222222")) + .cornerRadius(6.7) + .padding(.top, 13.3) + + Spacer() + + Text("로그아웃") + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(width: cardWidth, height: 50) + .background(Color(hex: "222222")) + .cornerRadius(6.7) + .onTapGesture { + isShowLogoutDialog = true + } + + Text("모든 기기에서 로그아웃") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "777777")) + .padding(.top, 13.3) + .onTapGesture { + isShowLogoutAllDeviceDialog = true + } + + Text("회원탈퇴") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "777777")) + .underline() + .padding(.vertical, 26.7) + .onTapGesture { + } + } + } + + if isShowLogoutDialog { + SodaDialog( + title: "알림", + desc: "로그아웃 하시겠어요?", + confirmButtonTitle: "확인", + confirmButtonAction: { + viewModel.logout { + self.isShowLogoutDialog = false + AppState.shared.setAppStep(step: .main) + UserDefaults.reset() + } + }, + cancelButtonTitle: "취소", + cancelButtonAction: { + self.isShowLogoutDialog = false + } + ) + } + + if isShowLogoutAllDeviceDialog { + SodaDialog( + title: "알림", + desc: "모든 기기에서 로그아웃 하시겠어요?", + confirmButtonTitle: "확인", + confirmButtonAction: { + viewModel.logoutAllDevice { + self.isShowLogoutDialog = false + AppState.shared.setAppStep(step: .main) + UserDefaults.reset() + } + }, + cancelButtonTitle: "취소", + cancelButtonAction: { + self.isShowLogoutDialog = false + } + ) + } + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .padding(.horizontal, 6.7) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + } +} + +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + SettingsView() + } +} diff --git a/SodaLive/Sources/Settings/SettingsViewModel.swift b/SodaLive/Sources/Settings/SettingsViewModel.swift new file mode 100644 index 0000000..660c1a3 --- /dev/null +++ b/SodaLive/Sources/Settings/SettingsViewModel.swift @@ -0,0 +1,97 @@ +// +// SettingsViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Combine + +final class SettingsViewModel: ObservableObject { + + private let userRepository = UserRepository() + private var subscription = Set() + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + func logout(onSuccess: @escaping () -> Void) { + isLoading = true + + userRepository.logout() + .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(ApiResponseWithoutData.self, from: responseData) + + 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) + } + + func logoutAllDevice(onSuccess: @escaping () -> Void) { + isLoading = true + + userRepository.logoutAllDevice() + .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(ApiResponseWithoutData.self, from: responseData) + + 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) + } +} diff --git a/SodaLive/Sources/Settings/Terms/Terms.swift b/SodaLive/Sources/Settings/Terms/Terms.swift new file mode 100644 index 0000000..9234327 --- /dev/null +++ b/SodaLive/Sources/Settings/Terms/Terms.swift @@ -0,0 +1,13 @@ +// +// Terms.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation + +struct Terms: Decodable { + let title: String + let description: String +} diff --git a/SodaLive/Sources/Settings/Terms/TermsApi.swift b/SodaLive/Sources/Settings/Terms/TermsApi.swift new file mode 100644 index 0000000..d298fef --- /dev/null +++ b/SodaLive/Sources/Settings/Terms/TermsApi.swift @@ -0,0 +1,42 @@ +// +// TermsApi.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Moya + +enum TermsApi { + case terms + case privacy +} + +extension TermsApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .terms: + return "/stplat/terms_of_service" + + case .privacy: + return "/stplat/privacy_policy" + } + } + + var method: Moya.Method { + return .get + } + + var task: Task { + return .requestPlain + } + + var headers: [String : String]? { + return nil + } +} diff --git a/SodaLive/Sources/Settings/Terms/TermsRepository.swift b/SodaLive/Sources/Settings/Terms/TermsRepository.swift new file mode 100644 index 0000000..6f2b95f --- /dev/null +++ b/SodaLive/Sources/Settings/Terms/TermsRepository.swift @@ -0,0 +1,23 @@ +// +// TermsRepository.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import CombineMoya +import Combine +import Moya + +final class TermsRepository { + private let api = MoyaProvider() + + func getTermsOfService() -> AnyPublisher { + return api.requestPublisher(.terms) + } + + func getPrivacyPolicy() -> AnyPublisher { + return api.requestPublisher(.privacy) + } +} diff --git a/SodaLive/Sources/Settings/Terms/TermsView.swift b/SodaLive/Sources/Settings/Terms/TermsView.swift new file mode 100644 index 0000000..0794552 --- /dev/null +++ b/SodaLive/Sources/Settings/Terms/TermsView.swift @@ -0,0 +1,42 @@ +// +// TermsView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI +import RichText + +struct TermsView: View { + + let isPrivacyPolicy: Bool + + @ObservedObject var viewModel = TermsViewModel() + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + VStack(spacing: 0) { + DetailNavigationBar(title: viewModel.title) + + ScrollView(.vertical, showsIndicators: false) { + RichText(html: viewModel.description) + .frame(width: screenSize().width - 26.7) + } + } + } + .onAppear { + if isPrivacyPolicy { + viewModel.getPrivacyPolicy() + } else { + viewModel.getTermsOfService() + } + } + } +} + +struct TermsView_Previews: PreviewProvider { + static var previews: some View { + TermsView(isPrivacyPolicy: false) + } +} diff --git a/SodaLive/Sources/Settings/Terms/TermsViewModel.swift b/SodaLive/Sources/Settings/Terms/TermsViewModel.swift new file mode 100644 index 0000000..a30761e --- /dev/null +++ b/SodaLive/Sources/Settings/Terms/TermsViewModel.swift @@ -0,0 +1,99 @@ +// +// TermsViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Combine + +final class TermsViewModel: ObservableObject { + private let repository = TermsRepository() + private var subscription = Set() + + @Published var title: String = "" + @Published var description: String = "" + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + func getTermsOfService() { + isLoading = true + + repository.getTermsOfService() + .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(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.title = data.title + self.description = data.description + } 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) + } + + func getPrivacyPolicy() { + isLoading = true + + repository.getPrivacyPolicy() + .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(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.title = data.title + self.description = data.description + } 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/User/UserApi.swift b/SodaLive/Sources/User/UserApi.swift index 8f82bfb..71aa16e 100644 --- a/SodaLive/Sources/User/UserApi.swift +++ b/SodaLive/Sources/User/UserApi.swift @@ -14,6 +14,10 @@ enum UserApi { case findPassword(request: ForgotPasswordRequest) case searchUser(nickname: String) case getMypage + case getMemberInfo + case notification(request: UpdateNotificationSettingRequest) + case logout + case logoutAllDevice } extension UserApi: TargetType { @@ -37,15 +41,27 @@ extension UserApi: TargetType { case .getMypage: return "/member/mypage" + + case .getMemberInfo: + return "/member/info" + + case .notification: + return "/member/notification" + + case .logout: + return "/member/logout" + + case .logoutAllDevice: + return "/member/logout/all" } } var method: Moya.Method { switch self { - case .login, .signUp, .findPassword: + case .login, .signUp, .findPassword, .notification, .logout, .logoutAllDevice: return .post - case .searchUser, .getMypage: + case .searchUser, .getMypage, .getMemberInfo: return .get } } @@ -66,6 +82,12 @@ extension UserApi: TargetType { case .getMypage: return .requestParameters(parameters: ["container" : "ios"], encoding: URLEncoding.queryString) + + case .getMemberInfo, .logout, .logoutAllDevice: + return .requestPlain + + case .notification(let request): + return .requestJSONEncodable(request) } } diff --git a/SodaLive/Sources/User/UserRepository.swift b/SodaLive/Sources/User/UserRepository.swift index ce3633c..af5cb71 100644 --- a/SodaLive/Sources/User/UserRepository.swift +++ b/SodaLive/Sources/User/UserRepository.swift @@ -32,4 +32,28 @@ final class UserRepository { func getMypage() -> AnyPublisher { return api.requestPublisher(.getMypage) } + + func getMemberInfo() -> AnyPublisher { + return api.requestPublisher(.getMemberInfo) + } + + func updateNotificationSettings(live: Bool? = nil, uploadContent: Bool? = nil, message: Bool? = nil) -> AnyPublisher { + return api.requestPublisher( + .notification( + request: UpdateNotificationSettingRequest( + live: live, + uploadContent: uploadContent, + message: message + ) + ) + ) + } + + func logout() -> AnyPublisher { + return api.requestPublisher(.logout) + } + + func logoutAllDevice() -> AnyPublisher { + return api.requestPublisher(.logoutAllDevice) + } }