feat(auth): 인증 후 콘텐츠 설정과 개발용 모의 인증을 추가한다
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user