ios_app_v2/yobble/Views/Login/LoginView.swift
2025-12-04 02:55:00 +03:00

885 lines
33 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// LoginView.swift
// VolnahubApp
//
// Created by cheykrym on 09/06/2025.
//
import SwiftUI
struct LoginView: View {
@ObservedObject var viewModel: LoginViewModel
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
@State private var isShowingMessengerPrompt: Bool = true
@State private var pendingMessengerMode: Bool = UserDefaults.standard.bool(forKey: "messengerModeEnabled")
@State private var showLegacySupportNotice = false
var body: some View {
ZStack {
content
.animation(.easeInOut(duration: 0.25), value: viewModel.loginFlowStep)
.allowsHitTesting(!isAnyBlockingOverlayPresented)
.blur(radius: isAnyBlockingOverlayPresented ? 3 : 0)
if showLegacySupportNotice {
LegacySupportNoticeView(isPresented: $showLegacySupportNotice)
.transition(.opacity)
}
if isShowingMessengerPrompt && !showLegacySupportNotice {
Color.black.opacity(0.35)
.ignoresSafeArea()
.transition(.opacity)
MessengerModePrompt(
selection: $pendingMessengerMode,
onAccept: applyMessengerModeSelection,
onSkip: dismissMessengerPrompt
)
.padding(.horizontal, 24)
.transition(.scale.combined(with: .opacity))
}
}
.onAppear {
showModePrompt()
showLegacySupportNoticeIfNeeded()
}
}
private var content: some View {
ZStack {
switch viewModel.loginFlowStep {
case .passwordlessRequest:
PasswordlessRequestView(
viewModel: viewModel,
shouldAutofocus: !isShowingMessengerPrompt,
onShowModePrompt: showModePrompt
)
.transition(unifiedTransition)
case .passwordlessVerify:
PasswordlessVerifyView(
viewModel: viewModel,
shouldAutofocus: !isShowingMessengerPrompt,
onShowModePrompt: showModePrompt
)
.transition(unifiedTransition)
case .password:
PasswordLoginView(viewModel: viewModel, onShowModePrompt: showModePrompt)
.transition(unifiedTransition)
case .registration:
RegistrationView(viewModel: viewModel, onShowModePrompt: showModePrompt)
.transition(unifiedTransition)
}
}
}
private func showModePrompt() {
pendingMessengerMode = isMessengerModeEnabled
withAnimation {
isShowingMessengerPrompt = true
}
}
private var isAnyBlockingOverlayPresented: Bool {
isShowingMessengerPrompt || showLegacySupportNotice
}
private var unifiedTransition: AnyTransition {
.opacity.combined(with: .scale(scale: 0.98, anchor: .center))
}
private func showLegacySupportNoticeIfNeeded() {
guard shouldShowLegacySupportNotice else { return }
withAnimation {
showLegacySupportNotice = true
}
}
private var shouldShowLegacySupportNotice: Bool {
#if os(iOS)
let requiredVersion = OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0)
return !ProcessInfo.processInfo.isOperatingSystemAtLeast(requiredVersion)
#else
return false
#endif
}
private func applyMessengerModeSelection() {
isMessengerModeEnabled = pendingMessengerMode
dismissMessengerPrompt()
}
private func dismissMessengerPrompt() {
withAnimation {
isShowingMessengerPrompt = false
}
}
}
struct PasswordLoginView: View {
@ObservedObject var viewModel: LoginViewModel
let onShowModePrompt: () -> Void
@EnvironmentObject private var themeManager: ThemeManager
@Environment(\.colorScheme) private var colorScheme
private let themeOptions = ThemeOption.ordered
@AppStorage("messengerModeEnabled") private var isMessengerModeEnabled: Bool = false
@State private var isShowingTerms = false
@State private var hasResetTermsOnAppear = false
@State private var isShowingForgotPassword = false
@FocusState private var focusedField: Field?
private enum Field: Hashable {
case username
case password
}
private var isUsernameValid: Bool {
LoginViewModel.isLoginValid(viewModel.passwordlessLogin)
}
private var isPasswordValid: Bool {
return viewModel.password.count >= 8 && viewModel.password.count <= 128
}
private var isLoginButtonEnabled: Bool {
// !viewModel.isLoading && isUsernameValid && isPasswordValid && viewModel.hasAcceptedTerms
!viewModel.isLoading && isUsernameValid && isPasswordValid
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 24) {
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
Button {
focusedField = nil
withAnimation {
viewModel.showPasswordlessRequest()
}
} label: {
HStack(spacing: 6) {
Image(systemName: "arrow.left")
Text(NSLocalizedString("Назад", comment: ""))
}
.font(.footnote)
.foregroundColor(.blue)
}
VStack(alignment: .leading, spacing: 8) {
Text(NSLocalizedString("Вход по паролю", comment: ""))
.font(.largeTitle).bold()
// Text(NSLocalizedString("Если предпочитаете классический вход, используйте логин и пароль.", comment: ""))
// .foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Text("@")
.foregroundColor(.secondary)
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.passwordlessLogin)
.autocapitalization(.none)
.disableAutocorrection(true)
.focused($focusedField, equals: .username)
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
if !isUsernameValid && !viewModel.passwordlessLogin.isEmpty {
Text(NSLocalizedString("Неверный логин", comment: "Неверный логин"))
.foregroundColor(.red)
.font(.caption)
}
SecureField(NSLocalizedString("Введите пароль", comment: ""), text: $viewModel.password)
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
.autocapitalization(.none)
.focused($focusedField, equals: .password)
.onChange(of: viewModel.password) { newValue in
if newValue.count > 32 {
viewModel.password = String(newValue.prefix(32))
}
}
if !isPasswordValid && !viewModel.password.isEmpty {
Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль"))
.foregroundColor(.red)
.font(.caption)
}
}
// VStack(alignment: .leading, spacing: 4) {
// Toggle(NSLocalizedString("Режим мессенжера", comment: ""), isOn: $isMessengerModeEnabled)
// .toggleStyle(SwitchToggleStyle(tint: .accentColor))
// Text(isMessengerModeEnabled
// ? "Мессенджер-режим сейчас проработан примерно на 60%."
// : "Основной режим находится в ранней разработке (около 10%).")
// .font(.footnote)
// .foregroundColor(.secondary)
// }
Button(action: {
viewModel.login()
}) {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.frame(maxWidth: .infinity)
.padding()
} else {
Text(NSLocalizedString("Войти", comment: ""))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
}
}
.background(isLoginButtonEnabled ? Color.blue : Color.gray)
.cornerRadius(12)
.disabled(!isLoginButtonEnabled)
Button(action: {
isShowingForgotPassword = true
}) {
Text(NSLocalizedString("Забыли пароль? Сбросить", comment: ""))
.foregroundColor(.blue)
.frame(maxWidth: .infinity)
}
.padding(.top, 4)
Spacer(minLength: 0)
}
.padding(.vertical, 32)
}
.padding(.horizontal, 24)
.background(Color(.systemBackground).ignoresSafeArea())
.contentShape(Rectangle())
.onTapGesture {
focusedField = nil
}
.loginErrorAlert(viewModel: viewModel)
.onAppear {
if !hasResetTermsOnAppear {
viewModel.hasAcceptedTerms = false
hasResetTermsOnAppear = true
}
}
.fullScreenCover(isPresented: $isShowingTerms) {
TermsFullScreenView(
isPresented: $isShowingTerms,
title: NSLocalizedString("Правила сервиса", comment: ""),
content: viewModel.termsContent,
isLoading: viewModel.isLoadingTerms,
errorMessage: viewModel.termsErrorMessage,
onRetry: {
viewModel.reloadTerms()
}
)
.onAppear {
if viewModel.termsContent.isEmpty {
viewModel.loadTermsIfNeeded()
}
}
}
.sheet(isPresented: $isShowingForgotPassword) {
ForgotPasswordInfoView {
isShowingForgotPassword = false
withAnimation {
viewModel.showPasswordlessRequest()
}
} onDismiss: {
isShowingForgotPassword = false
}
}
}
private var themeIconName: String {
switch themeManager.theme {
case .system:
return colorScheme == .dark ? "moon.fill" : "sun.max.fill"
case .light:
return "sun.max.fill"
case .oledDark:
return "moon.fill"
}
}
private func hideKeyboardAndShowModePrompt() {
focusedField = nil
onShowModePrompt()
}
private func openLanguageSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
private var selectedThemeOption: ThemeOption {
ThemeOption.option(for: themeManager.theme)
}
private func themeMenuContent(for option: ThemeOption) -> some View {
let isSelected = option == selectedThemeOption
return HStack(spacing: 8) {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.foregroundColor(isSelected ? .accentColor : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(option.title)
if let note = option.note {
Text(note)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
private func selectTheme(_ option: ThemeOption) {
guard let mappedTheme = option.mappedTheme else { return }
themeManager.setTheme(mappedTheme)
}
}
private struct PasswordlessRequestView: View {
@ObservedObject var viewModel: LoginViewModel
let shouldAutofocus: Bool
let onShowModePrompt: () -> Void
@FocusState private var isFieldFocused: Bool
private var isLoginValid: Bool {
LoginViewModel.isLoginValid(viewModel.passwordlessLogin)
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 24) {
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
VStack(alignment: .leading, spacing: 8) {
Text(NSLocalizedString("Yobble Passport", comment: ""))
.font(.largeTitle).bold()
}
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text("@")
.foregroundColor(.secondary)
TextField(NSLocalizedString("Введите логин", comment: ""), text: $viewModel.passwordlessLogin)
.textContentType(.username)
.keyboardType(.default)
.autocapitalization(.none)
.disableAutocorrection(true)
.focused($isFieldFocused)
.onChange(of: viewModel.passwordlessLogin) { newValue in
if newValue.count > 32 {
viewModel.passwordlessLogin = String(newValue.prefix(32))
}
}
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(12)
}
Button {
withAnimation {
viewModel.requestPasswordlessCode()
}
} label: {
if viewModel.isSendingCode {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
} else {
Text(NSLocalizedString("Войти", comment: ""))
.bold()
.frame(maxWidth: .infinity)
.padding()
}
}
.foregroundColor(.white)
.background(viewModel.canRequestPasswordlessCode ? Color.blue : Color.gray)
.cornerRadius(12)
.disabled(!viewModel.canRequestPasswordlessCode)
Divider()
Button {
viewModel.hasAcceptedTerms = false
withAnimation {
viewModel.showRegistration()
}
} label: {
Text(NSLocalizedString("Нет аккаунта? Регистрация", comment: ""))
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 32)
}
.padding(.horizontal, 24)
.background(Color(.systemBackground).ignoresSafeArea())
.contentShape(Rectangle())
.onTapGesture {
isFieldFocused = false
}
.onAppear(perform: scheduleFocusIfNeeded)
.onChange(of: shouldAutofocus) { newValue in
if newValue {
scheduleFocusIfNeeded()
} else {
isFieldFocused = false
}
}
.loginErrorAlert(viewModel: viewModel)
}
private func hideKeyboardAndShowModePrompt() {
isFieldFocused = false
onShowModePrompt()
}
private func openLanguageSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
private func scheduleFocusIfNeeded() {
guard shouldAutofocus else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if shouldAutofocus {
isFieldFocused = true
}
}
}
}
private struct LegacySupportNoticeView: View {
@Binding var isPresented: Bool
var body: some View {
ZStack {
Color.black.opacity(0.5)
.ignoresSafeArea()
.onTapGesture {
isPresented = false
}
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40, weight: .bold))
.foregroundColor(.yellow)
Text("Экспериментальная поддержка iOS 15")
.font(.headline)
.multilineTextAlignment(.center)
Text("Поддержка iOS 15 работает в экспериментальном режиме. Для лучшей совместимости требуется iOS 16+.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button {
isPresented = false
} label: {
Text("Понятно")
.bold()
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
}
.padding(24)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(.systemBackground))
)
.frame(maxWidth: 320)
.shadow(radius: 10)
}
}
}
private struct PasswordlessVerifyView: View {
@ObservedObject var viewModel: LoginViewModel
let shouldAutofocus: Bool
let onShowModePrompt: () -> Void
@FocusState private var isCodeFieldFocused: Bool
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 24) {
LoginTopBar(openLanguageSettings: openLanguageSettings, onShowModePrompt: hideKeyboardAndShowModePrompt)
Button {
// focusedField = nil
withAnimation {
viewModel.showPasswordlessRequest()
}
} label: {
HStack(spacing: 6) {
Image(systemName: "arrow.left")
Text(NSLocalizedString("Назад", comment: ""))
}
.font(.footnote)
.foregroundColor(.blue)
}
VStack(alignment: .leading, spacing: 8) {
Text(NSLocalizedString("Вход в аккаунт", comment: ""))
.font(.largeTitle).bold()
Text(String(format: NSLocalizedString("@%@", comment: ""), viewModel.passwordlessLogin))
.foregroundColor(.secondary)
// Text(NSLocalizedString("Введите код", comment: ""))
// .font(.largeTitle).bold()
//
// Text(String(format: NSLocalizedString("Код отправлен. Аккаунт: @%@", comment: ""), viewModel.passwordlessLogin))
// .foregroundColor(.secondary)
}
OTPInputView(code: $viewModel.verificationCode, isFocused: $isCodeFieldFocused)
if viewModel.isVerifyingCode {
HStack(spacing: 8) {
ProgressView()
Text(NSLocalizedString("Проверяем код…", comment: ""))
.font(.subheadline)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
Text(NSLocalizedString("Не получили код?", comment: ""))
.font(.subheadline)
if viewModel.resendSecondsRemaining > 0 {
Text(String(format: NSLocalizedString("Попробовать снова можно через %d сек", comment: ""), viewModel.resendSecondsRemaining))
.foregroundColor(.secondary)
}
Button {
withAnimation {
viewModel.resendPasswordlessCode()
}
} label: {
if viewModel.isSendingCode {
ProgressView()
.padding(.vertical, 8)
} else {
Text(NSLocalizedString("Отправить код ещё раз", comment: ""))
}
}
.disabled(viewModel.resendSecondsRemaining > 0 || viewModel.isSendingCode)
}
Divider()
// Button {
// withAnimation {
// viewModel.backToPasswordlessRequest()
// }
// } label: {
// Text(NSLocalizedString("Изменить способ входа", comment: ""))
// .frame(maxWidth: .infinity)
// }
Button {
withAnimation {
viewModel.showPasswordLogin()
}
} label: {
Text(NSLocalizedString("Войти по паролю", comment: ""))
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 32)
}
.padding(.horizontal, 24)
.background(Color(.systemBackground).ignoresSafeArea())
.contentShape(Rectangle())
.onTapGesture {
isCodeFieldFocused = true
}
.onAppear(perform: scheduleFocusIfNeeded)
.onChange(of: shouldAutofocus) { newValue in
if newValue {
scheduleFocusIfNeeded()
} else {
isCodeFieldFocused = false
}
}
.onAppear {
triggerAutoVerificationIfNeeded()
}
.onChange(of: viewModel.verificationCode) { _ in
triggerAutoVerificationIfNeeded()
}
.loginErrorAlert(viewModel: viewModel)
}
private func hideKeyboardAndShowModePrompt() {
isCodeFieldFocused = false
onShowModePrompt()
}
private func openLanguageSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
private func scheduleFocusIfNeeded() {
guard shouldAutofocus else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if shouldAutofocus {
isCodeFieldFocused = true
}
}
}
private func triggerAutoVerificationIfNeeded() {
guard viewModel.canVerifyPasswordlessCode else { return }
viewModel.verifyPasswordlessCode()
}
}
private struct OTPInputView: View {
@Binding var code: String
var length: Int = 6
let isFocused: FocusState<Bool>.Binding
var body: some View {
ZStack {
HStack(spacing: 12) {
ForEach(0..<length, id: \.self) { index in
Text(symbol(at: index))
.font(.title2.monospacedDigit())
.frame(width: 48, height: 56)
.background(
RoundedRectangle(cornerRadius: 12)
.stroke(borderColor(for: index), lineWidth: 1.5)
)
}
}
TextField("", text: textBinding)
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
.focused(isFocused)
.frame(width: 0, height: 0)
.opacity(0.01)
}
.padding(.vertical, 8)
.contentShape(Rectangle())
.onTapGesture {
isFocused.wrappedValue = true
}
}
private var textBinding: Binding<String> {
Binding(
get: { code },
set: { newValue in
let filtered = newValue.filter { $0.isNumber }
let trimmed = String(filtered.prefix(length))
// избегаем nested updates
if code != trimmed {
// отключаем анимации и делаем обновление вне view update фазы
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
code = trimmed
}
}
}
)
}
private func symbol(at index: Int) -> String {
guard index < code.count else { return "" }
let idx = code.index(code.startIndex, offsetBy: index)
return String(code[idx])
}
private func borderColor(for index: Int) -> Color {
if index == code.count && code.count < length {
return .blue
}
return .gray.opacity(0.6)
}
}
private struct MessengerModePrompt: View {
@Binding var selection: Bool
let onAccept: () -> Void
let onSkip: () -> Void
var body: some View {
VStack(spacing: 20) {
Text(NSLocalizedString("Какой режим попробовать?", comment: ""))
.font(.title3.bold())
.multilineTextAlignment(.center)
Text(NSLocalizedString("По умолчанию это полноценная соцсеть с лентой, историями и подписками. Если нужно только общение без лишнего контента, переключитесь на режим “Только чаты”. Переключить режим можно в любой момент.", comment: ""))
.font(.callout)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
VStack(spacing: 12) {
optionButton(
title: NSLocalizedString("Соцсеть (готово 10%)", comment: ""),
subtitle: NSLocalizedString("Лента, истории, подписки", comment: ""),
isMessenger: false
)
optionButton(
title: NSLocalizedString("Только чаты (готово 60%)", comment: ""),
subtitle: NSLocalizedString("Минимум отвлечений, чистый мессенджер", comment: ""),
isMessenger: true
)
}
HStack(spacing: 12) {
// Button(action: onSkip) {
// Text(NSLocalizedString("Позже", comment: ""))
// .font(.callout)
// .frame(maxWidth: .infinity)
// .padding()
// .background(
// RoundedRectangle(cornerRadius: 14, style: .continuous)
// .stroke(Color.secondary.opacity(0.3))
// )
// }
Button(action: onAccept) {
Text(NSLocalizedString("Применить", comment: ""))
.font(.callout.bold())
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.accentColor)
)
}
}
}
.padding(24)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color(.systemBackground))
)
.shadow(color: Color.black.opacity(0.2), radius: 30, x: 0, y: 12)
}
private func optionButton(title: String, subtitle: String, isMessenger: Bool) -> some View {
Button {
selection = isMessenger
} label: {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(title)
.font(.headline)
Spacer()
if selection == isMessenger {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
}
}
Text(subtitle)
.font(.footnote)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(selection == isMessenger ? Color.accentColor.opacity(0.15) : Color(.secondarySystemBackground))
)
}
.buttonStyle(.plain)
}
}
private extension View {
func loginErrorAlert(viewModel: LoginViewModel) -> some View {
alert(isPresented: Binding(
get: { viewModel.showError },
set: { viewModel.showError = $0 }
)) {
Alert(
title: Text(NSLocalizedString("Ошибка авторизации", comment: "")),
message: Text(viewModel.errorMessage.isEmpty ? NSLocalizedString("Произошла ошибка.", comment: "") : viewModel.errorMessage),
dismissButton: .default(Text(NSLocalizedString("OK", comment: "")))
)
}
}
}
struct LoginView_Previews: PreviewProvider {
static var previews: some View {
Group {
preview(step: .passwordlessRequest)
preview(step: .passwordlessVerify)
preview(step: .password)
preview(step: .registration)
}
.environmentObject(ThemeManager())
}
private static func preview(step: LoginViewModel.LoginFlowStep) -> some View {
let viewModel = LoginViewModel()
viewModel.isLoading = false
viewModel.isInitialLoading = false
viewModel.loginFlowStep = step
viewModel.passwordlessLogin = "preview@yobble.app"
viewModel.verificationCode = "123456"
return LoginView(viewModel: viewModel)
}
}
private struct ForgotPasswordInfoView: View {
let onUseCode: () -> Void
let onDismiss: () -> Void
var body: some View {
NavigationView {
VStack(alignment: .leading, spacing: 16) {
Text(NSLocalizedString("Сброс пароля", comment: ""))
.font(.title2.bold())
Text(NSLocalizedString("Прямого сброса пароля нет: сменить его можно только из настроек, уже будучи в аккаунте. Если привязана почта или другое 2FA-устройство, воспользуйтесь входом по коду - он подтвердит вашу личность и пустит в аккаунт. После входа откройте настройки → безопасность и задайте новый пароль.", comment: ""))
.foregroundColor(.secondary)
Button(action: onUseCode) {
Text(NSLocalizedString("Войти", comment: ""))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.cornerRadius(12)
}
Button(action: onDismiss) {
Text(NSLocalizedString("Закрыть", comment: ""))
.frame(maxWidth: .infinity)
.padding()
}
Spacer()
}
.padding()
.navigationBarHidden(true)
}
}
}