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