diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index ae543f9..ffccf90 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -257,6 +257,9 @@ } } } + }, + "Включено" : { + }, "Включить автоудаление аккаунта" : { "localizations" : { @@ -331,6 +334,9 @@ } } } + }, + "Выключено" : { + }, "Где найти сохранённые черновики?" : { "comment" : "FAQ question: drafts" @@ -913,6 +919,9 @@ } } } + }, + "Не удалось загрузить текст правил." : { + }, "Не удалось загрузить чаты." : { "localizations" : { @@ -1243,6 +1252,9 @@ } } } + }, + "Открыть правила" : { + }, "Отображаемое имя" : { @@ -1623,6 +1635,9 @@ } } } + }, + "Правила сервиса" : { + }, "Предложите, что добавить" : { "comment" : "feedback category subtitle: idea", @@ -2031,6 +2046,9 @@ } } } + }, + "Согласиться с правилами" : { + }, "Сообщение" : { @@ -2298,6 +2316,9 @@ }, "Экспериментальная поддержка iOS 15" : { + }, + "Я ознакомился и принимаю правила сервиса" : { + }, "Язык" : { "localizations" : { diff --git a/yobble/ViewModels/LoginViewModel.swift b/yobble/ViewModels/LoginViewModel.swift index 6f01504..439f1d2 100644 --- a/yobble/ViewModels/LoginViewModel.swift +++ b/yobble/ViewModels/LoginViewModel.swift @@ -18,6 +18,14 @@ class LoginViewModel: ObservableObject { @Published var isLoggedIn: Bool = false @Published var socketState: SocketService.ConnectionState @Published var chatLoadingState: ChatLoadingState = .idle + @Published var hasAcceptedTerms: Bool { + didSet { + UserDefaults.standard.set(hasAcceptedTerms, forKey: DefaultsKeys.termsAccepted) + } + } + @Published var isLoadingTerms: Bool = false + @Published var termsContent: String = "" + @Published var termsErrorMessage: String? private let authService = AuthService() private let socketService = SocketService.shared @@ -31,10 +39,12 @@ class LoginViewModel: ObservableObject { private enum DefaultsKeys { static let currentUser = "currentUser" static let userId = "userId" + static let termsAccepted = "termsAccepted" } init() { socketState = socketService.currentConnectionState + hasAcceptedTerms = UserDefaults.standard.bool(forKey: DefaultsKeys.termsAccepted) observeSocketState() observeChatsReload() // loadStoredUser() @@ -169,4 +179,53 @@ class LoginViewModel: ObservableObject { if AppConfig.DEBUG{ print("username: \(username) | userId: \(userId)")} } + + func loadTermsIfNeeded() { + guard !isLoadingTerms else { return } + + if !termsContent.isEmpty { + termsErrorMessage = nil + return + } + + isLoadingTerms = true + termsErrorMessage = nil + + NetworkClient.shared.request( + path: "/legal/terms", + headers: ["Accept": "text/plain"], + requiresAuth: false, + callbackQueue: .main + ) { [weak self] result in + guard let self else { return } + + self.isLoadingTerms = false + + switch result { + case .success(let response): + if let content = String(data: response.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !content.isEmpty { + self.termsContent = content + return + } + + if let jsonObject = try? JSONSerialization.jsonObject(with: response.data, options: []), + let json = jsonObject as? [String: Any], + let content = (json["content"] as? String) ?? (json["text"] as? String), + !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.termsContent = content + } else { + self.termsErrorMessage = NSLocalizedString("Не удалось загрузить текст правил.", comment: "") + } + case .failure: + self.termsErrorMessage = NSLocalizedString("Не удалось загрузить текст правил.", comment: "") + } + } + } + + func reloadTerms() { + termsContent = "" + termsErrorMessage = nil + loadTermsIfNeeded() + } } diff --git a/yobble/Views/Login/LoginView.swift b/yobble/Views/Login/LoginView.swift index 488a696..66ceb08 100644 --- a/yobble/Views/Login/LoginView.swift +++ b/yobble/Views/Login/LoginView.swift @@ -15,6 +15,7 @@ struct LoginView: View { @State private var isShowingRegistration = false @State private var showLegacySupportNotice = false + @State private var isShowingTerms = false @FocusState private var focusedField: Field? private enum Field: Hashable { @@ -30,6 +31,10 @@ struct LoginView: View { private var isPasswordValid: Bool { return viewModel.password.count >= 8 && viewModel.password.count <= 128 } + + private var isLoginButtonEnabled: Bool { + !viewModel.isLoading && isUsernameValid && isPasswordValid && viewModel.hasAcceptedTerms + } var body: some View { @@ -99,7 +104,7 @@ struct LoginView: View { viewModel.password = String(newValue.prefix(32)) } } - + // Показываем ошибку для пароля if !isPasswordValid && !viewModel.password.isEmpty { Text(NSLocalizedString("Неверный пароль", comment: "Неверный пароль")) @@ -107,10 +112,15 @@ struct LoginView: View { .font(.caption) } - var isButtonEnabled: Bool { - !viewModel.isLoading && isUsernameValid && isPasswordValid - } - + TermsAgreementView( + isAccepted: $viewModel.hasAcceptedTerms, + openTerms: { + viewModel.loadTermsIfNeeded() + isShowingTerms = true + } + ) + .padding(.vertical, 12) + Button(action: { viewModel.login() }) { @@ -126,11 +136,11 @@ struct LoginView: View { .foregroundColor(.white) .padding() .frame(maxWidth: .infinity) - .background(isButtonEnabled ? Color.blue : Color.gray) + .background(isLoginButtonEnabled ? Color.blue : Color.gray) .cornerRadius(8) } } - .disabled(!isButtonEnabled) + .disabled(!isLoginButtonEnabled) // Spacer() @@ -171,6 +181,23 @@ struct LoginView: View { .zIndex(1) } } + .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() + } + } + } } private var themeIconName: String { switch themeManager.theme { @@ -245,6 +272,106 @@ struct LoginView: View { } } + private struct TermsAgreementView: View { + @Binding var isAccepted: Bool + var openTerms: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + Button { + isAccepted.toggle() + } label: { + Image(systemName: isAccepted ? "checkmark.square.fill" : "square") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(isAccepted ? .blue : .secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(NSLocalizedString("Согласиться с правилами", comment: "")) + .accessibilityValue(isAccepted ? NSLocalizedString("Включено", comment: "") : NSLocalizedString("Выключено", comment: "")) + + VStack(alignment: .leading, spacing: 6) { + Text(NSLocalizedString("Я ознакомился и принимаю правила сервиса", comment: "")) + .font(.subheadline) + .foregroundColor(.primary) + + Button(action: openTerms) { + HStack(spacing: 4) { + Text(NSLocalizedString("Открыть правила", comment: "")) + Image(systemName: "arrow.up.right") + .font(.caption) + } + } + .font(.footnote) + .foregroundColor(.blue) + .buttonStyle(.plain) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color(.secondarySystemBackground)) + .cornerRadius(14) + } + } + + private struct TermsFullScreenView: View { + @Binding var isPresented: Bool + var title: String + var content: String + var isLoading: Bool + var errorMessage: String? + var onRetry: () -> Void + + var body: some View { + NavigationView { + Group { + if isLoading { + ProgressView() + } else if let errorMessage { + VStack(spacing: 16) { + Text(errorMessage) + .multilineTextAlignment(.center) + .padding(.horizontal) + Button(action: onRetry) { + Text(NSLocalizedString("Повторить", comment: "")) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(12) + } + } + } else { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let attributed = try? AttributedString(markdown: content) { + Text(attributed) + } else { + Text(content) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemBackground)) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(action: { isPresented = false }) { + Text(NSLocalizedString("Закрыть", comment: "")) + } + } + } + } + .navigationViewStyle(StackNavigationViewStyle()) + } + } + private var selectedThemeOption: ThemeOption { ThemeOption.option(for: themeManager.theme) } @@ -277,6 +404,7 @@ struct LoginView_Previews: PreviewProvider { static var previews: some View { let viewModel = LoginViewModel() viewModel.isLoading = false // чтобы убрать спиннер + viewModel.hasAcceptedTerms = true return LoginView(viewModel: viewModel) } }