feat(auth): 인증 후 콘텐츠 설정과 개발용 모의 인증을 추가한다

This commit is contained in:
Yu Sung
2026-10-13 11:37:03 +09:00
parent e34a51b411
commit 61bfa46c11
20 changed files with 1203 additions and 181 deletions
+16 -19
View File
@@ -7,7 +7,6 @@
import SwiftUI
import Bootpay
import BootpayUI
struct ChatTabView: View {
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
@AppStorage("auth") private var auth: Bool = UserDefaults.bool(forKey: UserDefaultsKey.auth)
@@ -159,33 +158,30 @@ struct ChatTabView: View {
payload.orderName = "본인인증"
payload.authenticationId = "\(UserDefaults.string(forKey: .nickname))__\(String(NSTimeIntervalSince1970))"
}
.fullScreenCover(isPresented: $isShowAuthView) {
BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION)
.onConfirm { _ in
true
}
.onCancel { _ in
.fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) {
AuthVerificationView(
payload: payload,
onCancel: {
isShowAuthView = false
}
.onError { _ in
},
onError: {
AppState.shared.errorMessage = I18n.Chat.Auth.authenticationError
AppState.shared.isShowErrorPopup = true
isShowAuthView = false
}
.onDone {
DEBUG_LOG("onDone: \($0)")
},
onDone: {
let action = pendingAction
mypageViewModel.authVerify($0) {
auth = true
isShowAuthView = false
if let action = pendingAction {
pendingAction = nil
action()
}
pendingAction = nil
action?()
}
}
.onClose {
isShowAuthView = false
},
onClose: {
isShowAuthView = false
}
)
}
if isShowAuthConfirmView {
@@ -206,6 +202,7 @@ struct ChatTabView: View {
)
}
}
.authContentSettings(viewModel: mypageViewModel)
}
}
@@ -43,6 +43,14 @@ extension UserDefaults {
}
static func set(_ value: String, forKey key: UserDefaultsKey) {
#if AUTH_VERIFICATION_MOCK
if key == .token {
AuthVerificationMock.shared.resetSession {
UserDefaults.standard.set(value, forKey: key.rawValue)
}
return
}
#endif
let key = key.rawValue
UserDefaults.standard.set(value, forKey: key)
}
@@ -78,6 +86,12 @@ extension UserDefaults {
}
static func reset() {
#if AUTH_VERIFICATION_MOCK
AuthVerificationMock.shared.resetSession {
UserDefaultsKey.allCases.filter { $0 != .pushToken }.forEach { UserDefaults.standard.removeObject(forKey: $0.rawValue) }
}
#else
UserDefaultsKey.allCases.filter { $0 != .pushToken }.forEach { UserDefaults.standard.removeObject(forKey: $0.rawValue) }
#endif
}
}
+16 -19
View File
@@ -7,7 +7,6 @@
import SwiftUI
import Bootpay
import BootpayUI
struct HomeTabView: View {
@StateObject var viewModel = HomeTabViewModel()
@@ -479,36 +478,34 @@ struct HomeTabView: View {
viewModel.fetchData()
}
.fullScreenCover(isPresented: $isShowAuthView) {
BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION)
.onConfirm { _ in
true
}
.onCancel { _ in
.fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) {
AuthVerificationView(
payload: payload,
onCancel: {
isShowAuthView = false
}
.onError { _ in
},
onError: {
AppState.shared.errorMessage = I18n.Chat.Auth.authenticationError
AppState.shared.isShowErrorPopup = true
isShowAuthView = false
}
.onDone {
DEBUG_LOG("onDone: \($0)")
},
onDone: {
let action = pendingAction
mypageViewModel.authVerify($0) {
auth = true
isShowAuthView = false
if let action = pendingAction {
pendingAction = nil
action()
}
pendingAction = nil
action?()
}
}
.onClose {
isShowAuthView = false
},
onClose: {
isShowAuthView = false
}
)
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.authContentSettings(viewModel: mypageViewModel)
}
}
+40
View File
@@ -1653,6 +1653,46 @@ enum I18n {
}
enum Content {
static var authSensitiveContentOnDescription: String {
pick(
ko: "성인인증 완료 후 성인 콘텐츠를 볼 수 있어요",
en: "You can view adult content after age verification.",
ja: "年齢認証完了後、成人向けコンテンツを表示できます"
)
}
static var authSensitiveContentOffDescription: String {
pick(
ko: "성인 콘텐츠가 목록과 검색에 노출되지 않아요",
en: "Adult content will not appear in lists or search.",
ja: "成人向けコンテンツは一覧や検索に表示されません"
)
}
static var preferredContent: String {
pick(ko: "선호 콘텐츠", en: "Preferred content", ja: "好みのコンテンツ")
}
static var authSettingsGuide: String {
pick(
ko: "마이 > 설정 > 콘텐츠 보기 설정에서 언제든 바꿀 수 있어요",
en: "You can change this anytime in My > Settings > Content view settings.",
ja: "マイ > 設定 > コンテンツ表示設定でいつでも変更できます"
)
}
static var confirmAuthSettings: String {
pick(ko: "설정 완료", en: "Confirm settings", ja: "設定を完了")
}
static var selectedPreference: String {
pick(ko: "선택됨", en: "Selected", ja: "選択済み")
}
static var unselectedPreference: String {
pick(ko: "선택 안 됨", en: "Not selected", ja: "未選択")
}
static var sensitiveContentTitle: String {
pick(ko: "민감한 콘텐츠 보기", en: "Show sensitive content", ja: "センシティブなコンテンツ表示")
}
@@ -8,7 +8,6 @@
import SwiftUI
import RefreshableScrollView
import Bootpay
import BootpayUI
struct LiveNowAllView: View {
@@ -126,35 +125,33 @@ struct LiveNowAllView: View {
viewModel.getLiveNowList()
}
.fullScreenCover(isPresented: $isShowAuthView) {
BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION)
.onConfirm { _ in
true
}
.onCancel { _ in
.fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) {
AuthVerificationView(
payload: payload,
onCancel: {
isShowAuthView = false
}
.onError { _ in
},
onError: {
AppState.shared.errorMessage = I18n.Main.Auth.authenticationError
AppState.shared.isShowErrorPopup = true
isShowAuthView = false
}
.onDone {
DEBUG_LOG("onDone: \($0)")
},
onDone: {
let action = pendingAction
mypageViewModel.authVerify($0) {
auth = true
isShowAuthView = false
if let action = pendingAction {
pendingAction = nil
action()
}
pendingAction = nil
action?()
}
}
.onClose {
isShowAuthView = false
},
onClose: {
isShowAuthView = false
}
)
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.authContentSettings(viewModel: mypageViewModel)
}
private func handleLiveNowItemTap(roomId: Int, isAdult: Bool) {
+16 -19
View File
@@ -10,7 +10,6 @@ import SwiftUI
import Firebase
import Kingfisher
import Bootpay
import BootpayUI
struct HomeView: View {
@StateObject var viewModel = HomeViewModel()
@@ -296,33 +295,30 @@ struct HomeView: View {
}
}
.edgesIgnoringSafeArea(.bottom)
.fullScreenCover(isPresented: $isShowAuthView) {
BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION)
.onConfirm { _ in
true
}
.onCancel { _ in
.fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) {
AuthVerificationView(
payload: payload,
onCancel: {
isShowAuthView = false
}
.onError { _ in
},
onError: {
AppState.shared.errorMessage = I18n.Main.Auth.authenticationError
AppState.shared.isShowErrorPopup = true
isShowAuthView = false
}
.onDone {
DEBUG_LOG("onDone: \($0)")
},
onDone: {
let action = pendingAction
mypageViewModel.authVerify($0) {
auth = true
isShowAuthView = false
if let action = pendingAction {
pendingAction = nil
action()
}
pendingAction = nil
action?()
}
}
.onClose {
isShowAuthView = false
},
onClose: {
isShowAuthView = false
}
)
}
.valueChanged(value: appState.pushRoomId) { value in
guard value > 0 else {
@@ -463,6 +459,7 @@ struct HomeView: View {
autohideIn: 2
)
}
.authContentSettings(viewModel: mypageViewModel)
}
private func handleLiveNowItemTap(roomId: Int, isAdult: Bool) {
@@ -13,8 +13,18 @@ import Moya
final class AuthRepository {
private let api = MoyaProvider<AuthApi>()
func authCertificate(receiptId: String) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.auth(request: AuthVerifyRequest(receiptId: receiptId)))
func authCertificate(request: AuthVerifyRequest) -> AnyPublisher<Response, MoyaError> {
#if AUTH_VERIFICATION_MOCK
let requestToken = UserDefaults.string(forKey: .token)
guard let data = AuthVerificationMock.shared.confirm(request: request, requestToken: requestToken) else {
return Fail(error: MoyaError.statusCode(Response(statusCode: 401, data: Data())))
.eraseToAnyPublisher()
}
return Just(Response(statusCode: 200, data: data))
.setFailureType(to: MoyaError.self)
.eraseToAnyPublisher()
#else
return api.requestPublisher(.auth(request: request))
#endif
}
}
@@ -9,8 +9,12 @@ import Foundation
struct AuthVerifyRequest: Encodable {
let receiptId: String
let isAdultContentVisible: Bool
let contentType: ContentType
enum CodingKeys : String, CodingKey {
case receiptId = "receipt_id"
case isAdultContentVisible
case contentType
}
}
+12 -18
View File
@@ -8,7 +8,6 @@
import SwiftUI
import Bootpay
import BootpayUI
import Kingfisher
import RefreshableScrollView
@@ -33,28 +32,23 @@ struct MyPageView: View {
BaseView(isLoading: $viewModel.isLoading) {
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
viewModel.isShowAuthView {
BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION)
.onConfirm {
DEBUG_LOG("onConfirm: \($0)")
return true
}
.onCancel {
DEBUG_LOG("onCancel: \($0)")
}
.onError {
DEBUG_LOG("onError: \($0)")
AuthVerificationView(
payload: payload,
onCancel: {},
onError: {
viewModel.errorMessage = I18n.Main.Auth.authenticationError
viewModel.isShowPopup = true
viewModel.isShowAuthView = false
}
.onDone {
DEBUG_LOG("onDone: \($0)")
},
onDone: {
viewModel.authVerify($0)
}
.onClose {
DEBUG_LOG("onClose")
viewModel.isShowAuthView = false
viewModel.presentAuthContentSettings()
},
onClose: {
viewModel.isShowAuthView = false
}
)
} else {
VStack(spacing: 0) {
// Header
@@ -180,7 +174,7 @@ struct MyPageView: View {
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.authContentSettings(viewModel: viewModel)
.onAppear {
payload.applicationId = BOOTPAY_APP_ID
payload.price = 0
+168 -52
View File
@@ -14,6 +14,9 @@ final class MyPageViewModel: ObservableObject {
private let authRepository = AuthRepository()
private let noticeRepository = NoticeRepository()
private var subscription = Set<AnyCancellable>()
private var pendingAuthReceiptId: String?
private var pendingAuthRequest: AuthVerifyRequest?
private var pendingAuthSuccess: (() -> Void)?
@Published var latestNotice: NoticeItem? = nil
@@ -28,6 +31,16 @@ final class MyPageViewModel: ObservableObject {
@Published var isShowPopup = false
@Published var isLoading = false
@Published var isShowAuthView = false
@Published var isShowAuthContentSettings = false
@Published var authIsAdultContentVisible = true {
didSet {
if !authIsAdultContentVisible {
authContentType = .ALL
}
}
}
@Published var authContentType: ContentType = .ALL
@Published private(set) var isSubmittingAuth = false
func getMypage() {
isLoading = true
@@ -73,61 +86,164 @@ final class MyPageViewModel: ObservableObject {
}
func authVerify(_ data: [String: Any], onSuccess: (() -> Void)? = nil) {
isLoading = true
let _data = data["data"] as? [String: Any]
if let data = _data {
let receiptId = data["receipt_id"] as! String
authRepository.authCertificate(receiptId: receiptId)
.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<AuthResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
if data.gender == 0 {
UserDefaults.set(ContentType.FEMALE.rawValue, forKey: .contentPreference)
} else {
UserDefaults.set(ContentType.MALE.rawValue, forKey: .contentPreference)
}
if let onSuccess = onSuccess {
onSuccess()
} else {
AppState.shared.isRestartApp = true
AppState.shared.setAppStep(step: .splash)
}
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = I18n.MyPage.Auth.verificationErrorWithSupport
}
self.isShowPopup = true
}
} catch {
self.errorMessage = I18n.MyPage.Auth.verificationErrorWithSupport
self.isShowPopup = true
}
}
.store(in: &subscription)
} else {
isLoading = false
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in
self?.authVerify(data, onSuccess: onSuccess)
}
return
}
guard pendingAuthReceiptId == nil, !isSubmittingAuth else { return }
guard let payload = data["data"] as? [String: Any],
let receiptId = payload["receipt_id"] as? String,
!receiptId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
errorMessage = I18n.Main.Auth.authenticationError
isShowPopup = true
return
}
pendingAuthReceiptId = receiptId
pendingAuthSuccess = onSuccess
authIsAdultContentVisible = true
authContentType = .ALL
}
func presentAuthContentSettings() {
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in
self?.presentAuthContentSettings()
}
return
}
guard pendingAuthReceiptId != nil, !isSubmittingAuth else { return }
isShowAuthContentSettings = true
}
func confirmAuthContentSettings() {
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in
self?.confirmAuthContentSettings()
}
return
}
guard let receiptId = pendingAuthReceiptId, !isSubmittingAuth else { return }
pendingAuthRequest = AuthVerifyRequest(
receiptId: receiptId,
isAdultContentVisible: authIsAdultContentVisible,
contentType: authIsAdultContentVisible ? authContentType : .ALL
)
isSubmittingAuth = true
isShowAuthContentSettings = false
}
func submitPendingAuthVerification() {
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in
self?.submitPendingAuthVerification()
}
return
}
guard isSubmittingAuth, let request = pendingAuthRequest else { return }
pendingAuthRequest = nil
authRepository.authCertificate(request: request)
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
if case .failure = result {
self?.handleAuthSubmissionFailure()
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
guard (200..<300).contains(response.statusCode) else {
self.handleAuthSubmissionFailure()
return
}
do {
let decoded = try JSONDecoder().decode(ApiResponse<AuthResponse>.self, from: response.data)
guard decoded.success, decoded.data != nil else {
self.handleAuthSubmissionFailure()
return
}
if self.pendingAuthSuccess != nil {
self.synchronizeAuthenticatedMember()
} else {
UserDefaults.set(request.isAdultContentVisible, forKey: .isAdultContentVisible)
UserDefaults.set(request.contentType.rawValue, forKey: .contentPreference)
self.restartAfterAuthVerification()
}
} catch {
self.handleAuthSubmissionFailure()
}
}
.store(in: &subscription)
}
private func handleAuthSubmissionFailure() {
pendingAuthRequest = nil
isSubmittingAuth = false
errorMessage = I18n.MyPage.Auth.verificationErrorWithSupport
isShowPopup = true
isShowAuthContentSettings = true
}
private func synchronizeAuthenticatedMember() {
repository.getMemberInfo()
.receive(on: DispatchQueue.main)
.sink { [weak self] result in
if case .failure = result {
self?.restartAfterAuthVerification()
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
guard (200..<300).contains(response.statusCode) else {
self.restartAfterAuthVerification()
return
}
do {
let decoded = try JSONDecoder().decode(ApiResponse<GetMemberInfoResponse>.self, from: response.data)
guard let data = decoded.data, decoded.success, data.isAuth else {
self.restartAfterAuthVerification()
return
}
UserDefaults.set(data.can, forKey: .can)
UserDefaults.set(data.point, forKey: .point)
UserDefaults.set(data.isAuth, forKey: .auth)
UserDefaults.set(data.role.rawValue, forKey: .role)
UserDefaults.set(data.auditionNotice ?? false, forKey: .isAuditionNotification)
UserDefaults.set(data.countryCode, forKey: .countryCode)
UserDefaults.set(data.isAdultContentVisible, forKey: .isAdultContentVisible)
UserDefaults.set(data.isAdultContentVisible ? data.contentType : ContentType.ALL.rawValue, forKey: .contentPreference)
self.isAuth = data.isAuth
let onSuccess = self.pendingAuthSuccess
self.clearPendingAuthVerification()
onSuccess?()
} catch {
self.restartAfterAuthVerification()
}
}
.store(in: &subscription)
}
private func clearPendingAuthVerification() {
pendingAuthReceiptId = nil
pendingAuthRequest = nil
pendingAuthSuccess = nil
isSubmittingAuth = false
isShowAuthContentSettings = false
}
private func restartAfterAuthVerification() {
clearPendingAuthVerification()
AppState.shared.isRestartApp = true
AppState.shared.setAppStep(step: .splash)
}
func getLatestNotice() {
@@ -46,19 +46,45 @@ final class UserRepository {
}
func getMypage() -> AnyPublisher<Response, MoyaError> {
#if AUTH_VERIFICATION_MOCK
return selfMemberPublisher(.getMypage, as: MyPageResponse.self)
#else
return api.requestPublisher(.getMypage)
#endif
}
func getMemberInfo() -> AnyPublisher<Response, MoyaError> {
#if AUTH_VERIFICATION_MOCK
return selfMemberPublisher(.getMemberInfo, as: GetMemberInfoResponse.self)
#else
return api.requestPublisher(.getMemberInfo)
#endif
}
func updateContentPreference(request: UpdateContentPreferenceRequest) -> AnyPublisher<Response, MoyaError> {
#if AUTH_VERIFICATION_MOCK
let requestToken = UserDefaults.string(forKey: .token)
let session = AuthVerificationMock.shared.session(for: requestToken)
return api.requestPublisher(.updateContentPreference(request: request))
.map { response in
AuthVerificationMock.shared.updateContentPreference(from: response.data,
statusCode: response.statusCode,
requestToken: requestToken,
session: session)
return response
}
.eraseToAnyPublisher()
#else
return api.requestPublisher(.updateContentPreference(request: request))
#endif
}
func getMemberCan() -> AnyPublisher<Response, MoyaError> {
#if AUTH_VERIFICATION_MOCK
return getMemberInfo()
#else
return api.requestPublisher(.getMemberInfo)
#endif
}
func updateNotificationSettings(live: Bool? = nil, uploadContent: Bool? = nil, message: Bool? = nil, audition: Bool? = nil) -> AnyPublisher<Response, MoyaError> {
@@ -149,4 +175,23 @@ final class UserRepository {
func getMemberProfile(memberId: Int) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getMemberProfile(memberId: memberId))
}
#if AUTH_VERIFICATION_MOCK
private func selfMemberPublisher<Member: Decodable>(_ target: UserApi, as type: Member.Type) -> AnyPublisher<Response, MoyaError> {
let requestToken = UserDefaults.string(forKey: .token)
let session = AuthVerificationMock.shared.session(for: requestToken)
return api.requestPublisher(target)
.map { response in
let data = AuthVerificationMock.shared.overlayMemberResponse(response.data,
statusCode: response.statusCode,
requestToken: requestToken,
session: session,
as: type)
guard data != response.data else { return response }
return Response(statusCode: response.statusCode, data: data,
request: response.request, response: response.response)
}
.eraseToAnyPublisher()
}
#endif
}
@@ -0,0 +1,86 @@
#if AUTH_VERIFICATION_MOCK
import Foundation
final class AuthVerificationMock {
static let shared = AuthVerificationMock()
final class Session {
fileprivate let token: String
fileprivate var preference: (isAdultContentVisible: Bool, contentType: ContentType)?
fileprivate init(token: String) {
self.token = token
}
}
private let lock = NSLock()
private var loginSession: Session?
private init() {}
func confirm(request: AuthVerifyRequest, requestToken: String) -> Data? {
lock.lock()
defer { lock.unlock() }
guard let session = currentSession(for: requestToken) else { return nil }
session.preference = (request.isAdultContentVisible, request.isAdultContentVisible ? request.contentType : .ALL)
return Data(#"{"success":true,"data":{"gender":0}}"#.utf8)
}
func session(for requestToken: String) -> Session? {
lock.lock()
defer { lock.unlock() }
return currentSession(for: requestToken)
}
func resetSession(_ changes: () -> Void) {
lock.lock()
defer { lock.unlock() }
loginSession = nil
changes()
}
func overlayMemberResponse<Member: Decodable>(_ data: Data, statusCode: Int, requestToken: String,
session: Session?, as type: Member.Type) -> Data {
lock.lock()
defer { lock.unlock() }
guard isActive(session, requestToken: requestToken), let preference = session?.preference,
(200..<300).contains(statusCode),
let decoded = try? JSONDecoder().decode(ApiResponse<Member>.self, from: data),
decoded.success, decoded.data != nil,
var envelope = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
var member = envelope["data"] as? [String: Any] else { return data }
member["isAuth"] = true
member["isAdultContentVisible"] = preference.isAdultContentVisible
member["contentType"] = preference.contentType.rawValue
envelope["data"] = member
return (try? JSONSerialization.data(withJSONObject: envelope)) ?? data
}
func updateContentPreference(from data: Data, statusCode: Int, requestToken: String, session: Session?) {
lock.lock()
defer { lock.unlock() }
guard isActive(session, requestToken: requestToken), let session = session, session.preference != nil,
(200..<300).contains(statusCode),
let decoded = try? JSONDecoder().decode(ApiResponse<UpdateContentPreferenceResponse>.self, from: data),
decoded.success, let preference = decoded.data else { return }
session.preference = (preference.isAdultContentVisible, preference.isAdultContentVisible ? preference.contentType : .ALL)
}
private func currentSession(for requestToken: String) -> Session? {
guard !requestToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
requestToken == UserDefaults.string(forKey: .token) else { return nil }
if loginSession?.token != requestToken {
loginSession = Session(token: requestToken)
}
return loginSession
}
private func isActive(_ session: Session?, requestToken: String) -> Bool {
guard let session = session, session === loginSession else { return false }
return session.token == requestToken && requestToken == UserDefaults.string(forKey: .token)
}
}
#endif
@@ -0,0 +1,349 @@
import SwiftUI
private enum AuthContentSettingsLayout {
static let topInset: CGFloat = 26
static let headerHeight: CGFloat = 54
static let touchHeight: CGFloat = 44
static let buttonHeight: CGFloat = 52
static let titleLineHeight: CGFloat = 23
static let captionLineHeight: CGFloat = 17
static let radioSize: CGFloat = 22
static let radioDotSize: CGFloat = 12
static let radioRowGap: CGFloat = 1
static let radioBottomInset: CGFloat = 10
static let switchWidth: CGFloat = 44
static let switchHeight: CGFloat = 24
static let switchInset: CGFloat = 2
static let strokeWidth: CGFloat = 1
}
struct AuthContentSettingsSheet: View {
@Binding var isAdultContentVisible: Bool
@Binding var contentType: ContentType
let onConfirm: () -> Void
var body: some View {
VStack(spacing: 0) {
ViewThatFits(in: .vertical) {
settingsContent
.fixedSize(horizontal: false, vertical: true)
ScrollView(.vertical, showsIndicators: false) {
settingsContent
}
.scrollBounceBehavior(.basedOnSize)
}
Button(action: onConfirm) {
Text(I18n.Settings.Content.confirmAuthSettings)
.appFont(size: SodaTypography.heading4.size, weight: .medium)
.foregroundColor(.white)
.frame(maxWidth: .infinity, minHeight: AuthContentSettingsLayout.buttonHeight)
.background(Color.soda400)
.clipShape(Capsule())
}
.buttonStyle(.plain)
.accessibilityIdentifier("authContentSettings.confirm")
.padding(.horizontal, SodaSpacing.s14)
.padding(.vertical, SodaSpacing.s8)
.fixedSize(horizontal: false, vertical: true)
}
.background(Color.gray900)
}
private var settingsContent: some View {
VStack(spacing: 0) {
Text(I18n.Settings.contentViewSettings)
.appFont(size: SodaTypography.heading4.size, weight: .medium)
.foregroundColor(.white)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, minHeight: AuthContentSettingsLayout.headerHeight)
.accessibilityAddTraits(.isHeader)
sensitiveContentRow
.padding(.top, SodaSpacing.s8)
.padding(.bottom, isAdultContentVisible ? SodaSpacing.s14 : SodaSpacing.s8)
if isAdultContentVisible {
preferenceSection
}
HStack(alignment: .top, spacing: SodaSpacing.s6) {
Image(systemName: "info.circle")
.resizable()
.frame(width: SodaSpacing.s16, height: SodaSpacing.s16)
.accessibilityHidden(true)
Text(I18n.Settings.Content.authSettingsGuide)
.appFont(.caption3)
.frame(maxWidth: .infinity, minHeight: AuthContentSettingsLayout.captionLineHeight, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
.foregroundColor(.gray400)
.padding(.top, isAdultContentVisible ? 0 : SodaSpacing.s8)
.padding(.bottom, SodaSpacing.s20)
}
.padding(.top, AuthContentSettingsLayout.topInset)
.padding(.horizontal, SodaSpacing.s14)
}
private var sensitiveContentRow: some View {
HStack(spacing: SodaSpacing.s12) {
VStack(alignment: .leading, spacing: SodaSpacing.s4) {
Text(I18n.Settings.Content.sensitiveContentTitle)
.appFont(.body2)
.fontWeight(.semibold)
.foregroundColor(.white)
.frame(minHeight: AuthContentSettingsLayout.titleLineHeight, alignment: .leading)
Text(isAdultContentVisible ? I18n.Settings.Content.authSensitiveContentOnDescription : I18n.Settings.Content.authSensitiveContentOffDescription)
.appFont(.caption3)
.foregroundColor(.gray400)
.frame(minHeight: AuthContentSettingsLayout.captionLineHeight, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
Toggle(I18n.Settings.Content.sensitiveContentTitle, isOn: $isAdultContentVisible)
.toggleStyle(AuthContentSettingsToggleStyle())
.accessibilityIdentifier("authContentSettings.toggle")
}
}
private var preferenceSection: some View {
VStack(alignment: .leading, spacing: 0) {
Rectangle()
.fill(Color.gray800)
.frame(height: AuthContentSettingsLayout.strokeWidth)
Text(I18n.Settings.Content.preferredContent)
.appFont(.caption2)
.foregroundColor(.gray400)
.frame(minHeight: AuthContentSettingsLayout.captionLineHeight)
.padding(.top, SodaSpacing.s14)
HStack(spacing: SodaSpacing.s12) {
preferenceButton(.ALL, title: I18n.Settings.Content.all)
preferenceButton(.MALE, title: I18n.Settings.Content.maleOriented)
preferenceButton(.FEMALE, title: I18n.Settings.Content.femaleOriented)
}
.padding(.top, AuthContentSettingsLayout.radioRowGap)
.padding(.bottom, AuthContentSettingsLayout.radioBottomInset)
}
}
private func preferenceButton(_ type: ContentType, title: String) -> some View {
Button {
contentType = type
} label: {
HStack(spacing: SodaSpacing.s6) {
Circle()
.stroke(Color.gray700, lineWidth: AuthContentSettingsLayout.strokeWidth)
.frame(width: AuthContentSettingsLayout.radioSize, height: AuthContentSettingsLayout.radioSize)
.overlay {
if contentType == type {
Circle()
.fill(Color.soda400)
.frame(width: AuthContentSettingsLayout.radioDotSize, height: AuthContentSettingsLayout.radioDotSize)
}
}
Text(title)
.appFont(.body2)
.foregroundColor(.white)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, minHeight: AuthContentSettingsLayout.touchHeight, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityValue(contentType == type ? I18n.Settings.Content.selectedPreference : I18n.Settings.Content.unselectedPreference)
.accessibilityAddTraits(contentType == type ? .isSelected : [])
.accessibilityIdentifier("authContentSettings.preference.\(type.rawValue)")
}
}
private struct AuthContentSettingsToggleStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
Button {
configuration.isOn.toggle()
} label: {
Capsule()
.fill(configuration.isOn ? Color.soda400 : Color.gray500)
.frame(width: AuthContentSettingsLayout.switchWidth, height: AuthContentSettingsLayout.switchHeight)
.overlay(alignment: configuration.isOn ? .trailing : .leading) {
Circle()
.fill(Color.white)
.frame(width: SodaSpacing.s20, height: SodaSpacing.s20)
.padding(AuthContentSettingsLayout.switchInset)
}
.frame(minHeight: AuthContentSettingsLayout.touchHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityRepresentation {
Toggle(isOn: configuration.$isOn) {
configuration.label
}
.toggleStyle(.switch)
}
}
}
private struct AuthContentSettingsModifier: ViewModifier {
@ObservedObject var viewModel: MyPageViewModel
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isHostPresented = false
@State private var panelProgress: CGFloat = 0
private var callerErrorIsPresented: Binding<Bool> {
Binding(
get: { viewModel.isShowPopup && !viewModel.isShowAuthContentSettings },
set: { viewModel.isShowPopup = $0 }
)
}
func body(content: Content) -> some View {
content
.disabled(viewModel.isSubmittingAuth)
.overlay {
if viewModel.isSubmittingAuth {
LoadingView()
}
}
.sodaToast(isPresented: callerErrorIsPresented, message: viewModel.errorMessage, autohideIn: 2)
.fullScreenCover(isPresented: $isHostPresented, onDismiss: {
panelProgress = 0
viewModel.submitPendingAuthVerification()
}) {
GeometryReader { proxy in
ZStack(alignment: .bottom) {
Color.black.opacity(0.6)
.ignoresSafeArea()
AuthContentSettingsSheet(
isAdultContentVisible: $viewModel.authIsAdultContentVisible,
contentType: $viewModel.authContentType,
onConfirm: viewModel.confirmAuthContentSettings
)
.padding(.leading, proxy.safeAreaInsets.leading)
.padding(.trailing, proxy.safeAreaInsets.trailing)
.padding(.bottom, proxy.safeAreaInsets.bottom)
.background(Color.gray900)
.clipShape(RoundedCorner(radius: SodaRadius.r14, corners: [.topLeft, .topRight]))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("authContentSettings.panel")
.disabled(viewModel.isSubmittingAuth)
.overlay {
if viewModel.isSubmittingAuth {
LoadingView()
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.modifier(AuthContentSettingsPanelMotion(
progress: panelProgress,
reduceMotion: reduceMotion,
onHidden: closeHostIfHidden
))
}
.ignoresSafeArea(.container, edges: [.horizontal, .bottom])
}
.interactiveDismissDisabled(true)
.presentationBackground(.clear)
.onAppear {
DispatchQueue.main.async {
updatePanel()
}
}
}
.onAppear {
if viewModel.isShowAuthContentSettings {
setHostPresented(true)
}
}
.onChange(of: viewModel.isShowAuthContentSettings) { isPresented in
if isPresented && !isHostPresented {
setHostPresented(true)
} else if isHostPresented {
updatePanel()
}
}
.onChange(of: reduceMotion) { _ in
if isHostPresented {
updatePanel()
}
}
}
private func setHostPresented(_ isPresented: Bool) {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isHostPresented = isPresented
}
}
private func updatePanel() {
let progress: CGFloat = viewModel.isShowAuthContentSettings ? 1 : 0
if reduceMotion || panelProgress == progress {
panelProgress = progress
closeHostIfHidden()
} else {
withAnimation(.easeInOut(duration: 0.25)) {
panelProgress = progress
}
}
}
private func closeHostIfHidden() {
guard isHostPresented, panelProgress == 0, !viewModel.isShowAuthContentSettings else { return }
setHostPresented(false)
}
}
private struct AuthContentSettingsPanelMotion: GeometryEffect {
var progress: CGFloat
let reduceMotion: Bool
let onHidden: () -> Void
var animatableData: CGFloat {
get { progress }
set {
progress = newValue
}
}
func effectValue(size: CGSize) -> ProjectionTransform {
if progress == 0 {
DispatchQueue.main.async(execute: onHidden)
}
return ProjectionTransform(CGAffineTransform(translationX: 0, y: reduceMotion ? 0 : size.height * (1 - progress)))
}
}
extension View {
func authContentSettings(viewModel: MyPageViewModel) -> some View {
modifier(AuthContentSettingsModifier(viewModel: viewModel))
}
}
struct AuthContentSettingsSheet_Previews: PreviewProvider {
static var previews: some View {
Group {
AuthContentSettingsSheet(isAdultContentVisible: .constant(true), contentType: .constant(.ALL), onConfirm: {})
.frame(width: 402)
.previewDisplayName("ON / ALL")
AuthContentSettingsSheet(isAdultContentVisible: .constant(false), contentType: .constant(.ALL), onConfirm: {})
.frame(width: 402)
.previewDisplayName("OFF / ALL")
}
.padding(.bottom, 34)
.background(Color.gray900)
.clipShape(RoundedCorner(radius: SodaRadius.r14, corners: [.topLeft, .topRight]))
.environment(\.locale, Locale(identifier: "ko"))
.preferredColorScheme(.dark)
.previewLayout(.sizeThatFits)
}
}
@@ -0,0 +1,46 @@
import SwiftUI
import Bootpay
#if !AUTH_VERIFICATION_MOCK
import BootpayUI
#endif
struct AuthVerificationView: View {
let payload: Payload
let onCancel: () -> Void
let onError: () -> Void
let onDone: ([String: Any]) -> Void
let onClose: () -> Void
#if AUTH_VERIFICATION_MOCK
@State private var hasCompleted = false
#endif
var body: some View {
#if AUTH_VERIFICATION_MOCK
LoadingView()
.background(Color.white.ignoresSafeArea())
.task {
guard !hasCompleted else { return }
do {
try await Task.sleep(nanoseconds: 500_000_000)
} catch is CancellationError {
return
} catch {
onError()
return
}
guard !Task.isCancelled, !hasCompleted else { return }
hasCompleted = true
onDone(["data": ["receipt_id": "dev-mock-auth-receipt"]])
}
#else
BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION)
.onConfirm { _ in true }
.onCancel { _ in onCancel() }
.onError { _ in onError() }
.onDone(onDone)
.onClose(onClose)
#endif
}
}
@@ -1,6 +1,5 @@
import SwiftUI
import Bootpay
import BootpayUI
struct CreatorChannelView: View {
let creatorId: Int
@@ -256,9 +255,10 @@ struct CreatorChannelView: View {
ActivityViewController(activityItems: [viewModel.shareMessage])
}
)
.fullScreenCover(isPresented: $isShowAuthView) {
.fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) {
authView
}
.authContentSettings(viewModel: mypageViewModel)
.onAppear {
configurePayload()
if viewModel.hasLoaded == false {
@@ -490,26 +490,25 @@ struct CreatorChannelView: View {
}
private var authView: some View {
BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION)
.onConfirm { _ in true }
.onCancel { _ in isShowAuthView = false }
.onError { _ in
AuthVerificationView(
payload: payload,
onCancel: { isShowAuthView = false },
onError: {
AppState.shared.errorMessage = I18n.Chat.Auth.authenticationError
AppState.shared.isShowErrorPopup = true
isShowAuthView = false
}
.onDone {
DEBUG_LOG("onDone: \($0)")
},
onDone: {
let action = pendingAction
mypageViewModel.authVerify($0) {
auth = true
isShowAuthView = false
if let action = pendingAction {
pendingAction = nil
action()
}
pendingAction = nil
action?()
}
}
.onClose { isShowAuthView = false }
isShowAuthView = false
},
onClose: { isShowAuthView = false }
)
}
private func configurePayload() {
+15 -16
View File
@@ -6,7 +6,6 @@
import SwiftUI
import Bootpay
import BootpayUI
import Kingfisher
struct MainView: View {
@@ -148,7 +147,7 @@ struct MainView: View {
ContentPlayerView(isShowing: $isShowPlayer, playlist: [])
}
}
.fullScreenCover(isPresented: $isShowAuthView) {
.fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) {
authView
}
.valueChanged(value: appState.pushRoomId) { handlePushRoomId($0) }
@@ -175,6 +174,7 @@ struct MainView: View {
autohideIn: 2
)
}
.authContentSettings(viewModel: mypageViewModel)
}
private var shouldShowCreatorActionMenu: Bool {
@@ -383,26 +383,25 @@ struct MainView: View {
}
private var authView: some View {
BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION)
.onConfirm { _ in true }
.onCancel { _ in isShowAuthView = false }
.onError { _ in
AuthVerificationView(
payload: payload,
onCancel: { isShowAuthView = false },
onError: {
appState.errorMessage = I18n.Main.Auth.authenticationError
appState.isShowErrorPopup = true
isShowAuthView = false
}
.onDone {
DEBUG_LOG("onDone: \($0)")
},
onDone: {
let action = pendingAction
mypageViewModel.authVerify($0) {
auth = true
isShowAuthView = false
if let action = pendingAction {
pendingAction = nil
action()
}
pendingAction = nil
action?()
}
}
.onClose { isShowAuthView = false }
isShowAuthView = false
},
onClose: { isShowAuthView = false }
)
}
private func configurePayload() {