432 lines
17 KiB
Swift
432 lines
17 KiB
Swift
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
|
|
let onClose: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
|
|
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)
|
|
.accessibilityAction(.escape, onClose)
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 0) {
|
|
Color.clear
|
|
.frame(width: AuthContentSettingsLayout.touchHeight, height: AuthContentSettingsLayout.touchHeight)
|
|
.accessibilityHidden(true)
|
|
|
|
Text(I18n.Settings.contentViewSettings)
|
|
.appFont(size: SodaTypography.heading4.size, weight: .medium)
|
|
.foregroundColor(.white)
|
|
.multilineTextAlignment(.center)
|
|
.frame(maxWidth: .infinity)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.accessibilityAddTraits(.isHeader)
|
|
|
|
Button(action: onClose) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: SodaTypography.body2.size, weight: .medium))
|
|
.foregroundColor(.gray400)
|
|
.frame(width: AuthContentSettingsLayout.touchHeight, height: AuthContentSettingsLayout.touchHeight)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.accessibilityLabel(I18n.Settings.Content.authExitTitle)
|
|
.accessibilityIdentifier("authContentSettings.close")
|
|
}
|
|
.frame(minHeight: AuthContentSettingsLayout.headerHeight)
|
|
.padding(.top, AuthContentSettingsLayout.topInset)
|
|
.padding(.horizontal, SodaSpacing.s14)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
private var settingsContent: some View {
|
|
VStack(spacing: 0) {
|
|
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(.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
|
|
let onCancel: () -> Void
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@State private var isHostPresented = false
|
|
@State private var panelProgress: CGFloat = 0
|
|
@State private var isExitDialogPresented = false
|
|
@AccessibilityFocusState private var isExitDialogFocused: Bool
|
|
|
|
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: {
|
|
dismissExitDialog()
|
|
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,
|
|
onClose: showExitDialog
|
|
)
|
|
.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)
|
|
.disabled(isExitDialogPresented)
|
|
.accessibilityHidden(isExitDialogPresented)
|
|
.modifier(AuthContentSettingsPanelMotion(
|
|
progress: panelProgress,
|
|
reduceMotion: reduceMotion,
|
|
onHidden: closeHostIfHidden
|
|
))
|
|
}
|
|
.ignoresSafeArea(.container, edges: [.horizontal, .bottom])
|
|
}
|
|
.overlay {
|
|
if isExitDialogPresented {
|
|
exitDialog
|
|
}
|
|
}
|
|
.interactiveDismissDisabled(true)
|
|
.presentationBackground(.clear)
|
|
.onAppear {
|
|
DispatchQueue.main.async {
|
|
updatePanel()
|
|
}
|
|
}
|
|
.onDisappear(perform: dismissExitDialog)
|
|
}
|
|
.onAppear {
|
|
if viewModel.isShowAuthContentSettings {
|
|
setHostPresented(true)
|
|
}
|
|
}
|
|
.onChange(of: viewModel.isShowAuthContentSettings) { isPresented in
|
|
if !isPresented {
|
|
dismissExitDialog()
|
|
}
|
|
if isPresented && !isHostPresented {
|
|
setHostPresented(true)
|
|
} else if isHostPresented {
|
|
updatePanel()
|
|
}
|
|
}
|
|
.onChange(of: reduceMotion) { _ in
|
|
if isHostPresented {
|
|
updatePanel()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var exitDialog: some View {
|
|
SodaV2ActionModal(
|
|
title: I18n.Settings.Content.authExitTitle,
|
|
message: I18n.Settings.Content.authExitMessage,
|
|
button1: SodaV2ActionModalButton(
|
|
label: I18n.Settings.Content.authExitConfirm,
|
|
action: confirmExit
|
|
),
|
|
button2: SodaV2ActionModalButton(
|
|
label: I18n.Settings.Content.authExitCancel,
|
|
action: dismissExitDialog
|
|
),
|
|
onDimmedTap: dismissExitDialog
|
|
)
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityFocused($isExitDialogFocused)
|
|
.accessibilityAction(.escape, dismissExitDialog)
|
|
.onAppear {
|
|
isExitDialogFocused = true
|
|
}
|
|
}
|
|
|
|
private func showExitDialog() {
|
|
guard isHostPresented, viewModel.isShowAuthContentSettings, !viewModel.isSubmittingAuth else { return }
|
|
isExitDialogPresented = true
|
|
}
|
|
|
|
private func dismissExitDialog() {
|
|
isExitDialogPresented = false
|
|
isExitDialogFocused = false
|
|
}
|
|
|
|
private func confirmExit() {
|
|
guard isExitDialogPresented, isHostPresented, viewModel.isShowAuthContentSettings, !viewModel.isSubmittingAuth else { return }
|
|
dismissExitDialog()
|
|
viewModel.cancelAuthContentSettings()
|
|
onCancel()
|
|
}
|
|
|
|
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, onCancel: @escaping () -> Void = {}) -> some View {
|
|
modifier(AuthContentSettingsModifier(viewModel: viewModel, onCancel: onCancel))
|
|
}
|
|
}
|
|
|
|
struct AuthContentSettingsSheet_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
Group {
|
|
AuthContentSettingsSheet(isAdultContentVisible: .constant(true), contentType: .constant(.ALL), onConfirm: {}, onClose: {})
|
|
.frame(width: 402)
|
|
.previewDisplayName("ON / ALL")
|
|
|
|
AuthContentSettingsSheet(isAdultContentVisible: .constant(false), contentType: .constant(.ALL), onConfirm: {}, onClose: {})
|
|
.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)
|
|
}
|
|
}
|