diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 94bafbb..877c556 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -1,9 +1,6 @@ { "sourceLanguage" : "ru", "strings" : { - "(не работает) Отправить предложение" : { - - }, "@%@" : { "localizations" : { "en" : { @@ -34,6 +31,9 @@ } } }, + "%d символов" : { + "comment" : "feedback: character count" + }, "%lld" : { }, @@ -203,8 +203,8 @@ } } }, - "Ваше предложение" : { - + "Ваш e-mail" : { + "comment" : "feedback: email placeholder" }, "Версия:" : { "localizations" : { @@ -289,8 +289,8 @@ } } }, - "Вы предложили: %@" : { - + "Выберите оценку — это поможет нам понять настроение." : { + "comment" : "feedback: rating hint" }, "Выйти из аккаунта" : { "localizations" : { @@ -460,6 +460,9 @@ "Здесь появится информация о собеседнике и существующих чатах." : { "comment" : "Search placeholder description" }, + "Идея" : { + "comment" : "feedback category: idea" + }, "Избранные сообщения" : { }, @@ -503,9 +506,6 @@ }, "Как связаться с поддержкой?" : { "comment" : "FAQ question: support" - }, - "Какая вкладка вам нужна?" : { - }, "Кастомная" : { "localizations" : { @@ -530,6 +530,9 @@ } } }, + "Контент" : { + "comment" : "feedback category: content" + }, "Конфиденциальность" : { "localizations" : { "en" : { @@ -693,14 +696,35 @@ } } }, - "Мы планируем заменить вкладку. Поделитесь, что бы вы хотели видеть здесь чаще всего." : { - + "Мы используем адрес только для ответа на ваш запрос." : { + "comment" : "feedback: email hint" + }, + "Мы постараемся всё исправить. Напишите, что смутило." : { + "comment" : "feedback: rating description 2" + }, + "Мы свяжемся с вами по адресу %@, как только ответим." : { + "comment" : "feedback: success email" + }, + "Мы читаем каждый отзыв и используем его, чтобы сделать Yobble полезнее для вас." : { + "comment" : "feedback: header subtitle" }, "Напишите нам через форму обратной связи в разделе \"Поддержка\"." : { "comment" : "FAQ answer: support" }, - "Например: закладки, друзья, активность..." : { - + "Например: заметил неточную информацию в статье..." : { + "comment" : "feedback placeholder: content" + }, + "Например: понравилась новая лента, потому что..." : { + "comment" : "feedback placeholder: praise" + }, + "Например: приложение вылетает, когда я открываю профиль..." : { + "comment" : "feedback placeholder: bug" + }, + "Например: хотелось бы видеть подборку по интересам..." : { + "comment" : "feedback placeholder: idea" + }, + "Насколько вам нравится Yobble?" : { + "comment" : "feedback: rating title" }, "Настройки" : { "comment" : "Settings", @@ -924,6 +948,9 @@ }, "Необходимо авторизоваться заново." : { + }, + "Неплохо, но можно лучше — что добавить?" : { + "comment" : "feedback: rating description 3" }, "Нет аккаунта? Регистрация" : { "comment" : "Регистрация", @@ -970,6 +997,12 @@ } } }, + "Нужно ли вам ответить?" : { + "comment" : "feedback: contact title" + }, + "О каком контенте идёт речь?" : { + "comment" : "feedback prompt: content" + }, "О приложении" : { "localizations" : { "en" : { @@ -991,6 +1024,7 @@ } }, "Обратная связь" : { + "comment" : "feedback: navigation title", "localizations" : { "en" : { "stringUnit" : { @@ -1012,12 +1046,24 @@ }, "Описание" : { + }, + "Опишите идею" : { + "comment" : "feedback prompt: idea" }, "Отображаемое имя" : { + }, + "Отправить отзыв" : { + "comment" : "feedback: submit button" }, "Отправляем..." : { - + "comment" : "feedback: sending state" + }, + "Оценка %d" : { + "comment" : "feedback: rating accessibility" + }, + "Оценка: %d из 5" : { + "comment" : "feedback: success rating" }, "Ошибка" : { "comment" : "Profile update error title", @@ -1200,6 +1246,12 @@ } } }, + "Поделитесь идеями, сообщите об ошибке или расскажите, что работает отлично." : { + "comment" : "feedback: info detail" + }, + "Поделитесь, что понравилось" : { + "comment" : "feedback category subtitle: praise" + }, "Подтверждение пароля" : { "comment" : "Подтверждение пароля", "localizations" : { @@ -1211,6 +1263,9 @@ } } }, + "Пожалуйста, введите корректный e-mail." : { + "comment" : "feedback: email error" + }, "Поиск" : { }, @@ -1257,6 +1312,9 @@ } } }, + "Получить ответ от команды" : { + "comment" : "feedback: contact toggle" + }, "Пользователь Системы 1" : { "comment" : "Тестовая подмена офф аккаунта", "extractionState" : "manual", @@ -1282,6 +1340,15 @@ }, "Попробуйте изменить запрос поиска." : { + }, + "Похвала" : { + "comment" : "feedback category: praise" + }, + "Предложите, что добавить" : { + "comment" : "feedback category subtitle: idea" + }, + "Прекрасно! Расскажите, что понравилось больше всего." : { + "comment" : "feedback: rating description 5" }, "Приватные чаты" : { "localizations" : { @@ -1373,6 +1440,9 @@ } } }, + "Проблема" : { + "comment" : "feedback category: bug" + }, "Проверьте данные и повторите попытку." : { "localizations" : { "en" : { @@ -1452,6 +1522,12 @@ } } }, + "Расскажите о своём опыте" : { + "comment" : "feedback: header title" + }, + "Расскажите, что не работает" : { + "comment" : "feedback category subtitle: bug" + }, "Регистрация" : { "comment" : "Регистрация", "localizations" : { @@ -1635,6 +1711,9 @@ }, "Сообщение" : { + }, + "Сообщите о материалах" : { + "comment" : "feedback category subtitle: content" }, "Сохранить изменения" : { "localizations" : { @@ -1646,8 +1725,11 @@ } } }, - "Спасибо!" : { - + "Спасибо! Мы получили ваш отзыв" : { + "comment" : "feedback: success title" + }, + "Спасибо! Что поможет нам добраться до пятёрки?" : { + "comment" : "feedback: rating description 4" }, "Старый пароль" : { "comment" : "Старый пароль", @@ -1670,6 +1752,9 @@ } } }, + "Тема: %@" : { + "comment" : "feedback: success category" + }, "Тёмная" : { "localizations" : { "en" : { @@ -1806,6 +1891,18 @@ "Черновики доступны в боковом меню в разделе Drafts." : { "comment" : "FAQ answer: drafts" }, + "Что вам понравилось?" : { + "comment" : "feedback prompt: praise" + }, + "Что вы хотите обсудить?" : { + "comment" : "feedback: category title" + }, + "Что случилось?" : { + "comment" : "feedback prompt: bug" + }, + "Что-то пошло не так. Расскажите подробности ниже." : { + "comment" : "feedback: rating description 1" + }, "Экран чата в разработке" : { }, diff --git a/yobble/Views/Tab/Settings/FeedbackView.swift b/yobble/Views/Tab/Settings/FeedbackView.swift index 47e9069..540a26a 100644 --- a/yobble/Views/Tab/Settings/FeedbackView.swift +++ b/yobble/Views/Tab/Settings/FeedbackView.swift @@ -5,127 +5,364 @@ import UIKit struct FeedbackView: View { @State private var suggestion: String = "" - @State private var submittedSuggestion: String? = nil + @State private var submittedFeedback: SubmittedFeedback? = nil @State private var isSubmitting: Bool = false @State private var showSubmissionMessage: Bool = false - @FocusState private var isSuggestionFocused: Bool + @State private var feedbackCategory: FeedbackCategory = .idea + @State private var rating: Int = 0 + @State private var wantsResponse: Bool = false + @State private var contactEmail: String = "" + @FocusState private var focusedField: Field? + + private let gridColumns = [GridItem(.adaptive(minimum: 120), spacing: 12)] var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 20) { - Text(NSLocalizedString("Какая вкладка вам нужна?", comment: "")) - .font(.title2) - .fontWeight(.semibold) - - Text(NSLocalizedString( - "Мы планируем заменить вкладку. Поделитесь, что бы вы хотели видеть здесь чаще всего.", - comment: "" - )) - .foregroundColor(.secondary) - - VStack(alignment: .leading, spacing: 12) { - Text(NSLocalizedString("Ваше предложение", comment: "")) - .font(.headline) - - TextEditor(text: $suggestion) - .frame(minHeight: 120) - .padding(12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(.systemGray6)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color(.systemGray4)) - ) - .overlay( - Group { - if suggestion.isEmpty { - Text(NSLocalizedString("Например: закладки, друзья, активность...", comment: "")) - .foregroundColor(.secondary) - .padding(18) - .allowsHitTesting(false) - } - } - ) - .disableAutocorrection(true) - .focused($isSuggestionFocused) - } + VStack(alignment: .leading, spacing: 24) { + headerSection + infoSection + categorySection + ratingSection + suggestionSection + contactSection Button(action: submitSuggestion) { - HStack { + HStack(spacing: 10) { if isSubmitting { ProgressView() .progressViewStyle(CircularProgressViewStyle()) } Text(isSubmitting - ? NSLocalizedString("Отправляем...", comment: "") - : NSLocalizedString("(не работает) Отправить предложение", comment: "")) + ? NSLocalizedString("Отправляем...", comment: "feedback: sending state") + : NSLocalizedString("Отправить отзыв", comment: "feedback: submit button")) .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding() - .background(suggestionIsValid ? Color.accentColor : Color(.systemGray4)) + .background(buttonBackgroundColor) .foregroundColor(.white) - .cornerRadius(14) + .cornerRadius(16) } - .disabled(!suggestionIsValid || isSubmitting) + .disabled(!canSubmit) - if let submittedSuggestion, showSubmissionMessage { - VStack(alignment: .leading, spacing: 8) { - Text(NSLocalizedString("Спасибо!", comment: "")) - .font(.headline) - Text(String(format: NSLocalizedString("Вы предложили: %@", comment: ""), submittedSuggestion)) - .foregroundColor(.secondary) - } - .transition(.opacity) + if let submission = submittedFeedback, showSubmissionMessage { + successSection(for: submission) + .transition(.move(edge: .top).combined(with: .opacity)) } - Spacer(minLength: 24) - -// Text(NSLocalizedString( -// "Позже мы добавим отправку на сервер, чтобы собрать статистику, и расскажем о результатах в обновлениях.", -// comment: "" -// )) -// .font(.footnote) -// .foregroundColor(.secondary) + Spacer(minLength: 16) } - .padding(.horizontal, 20) .padding(.vertical, 32) + .padding(.horizontal, 20) } + .background(Color(.systemGroupedBackground).ignoresSafeArea()) + .navigationTitle(NSLocalizedString("Обратная связь", comment: "feedback: navigation title")) + .navigationBarTitleDisplayMode(.inline) .simultaneousGesture( TapGesture().onEnded { dismissKeyboardIfNeeded() } ) + .onChange(of: wantsResponse) { wants in + if !wants { + contactEmail = "" + } + } + } + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("Расскажите о своём опыте", comment: "feedback: header title")) + .font(.title2) + .fontWeight(.bold) + Text(NSLocalizedString("Мы читаем каждый отзыв и используем его, чтобы сделать Yobble полезнее для вас.", comment: "feedback: header subtitle")) + .foregroundColor(.secondary) + } + } + + private var infoSection: some View { + VStack(alignment: .leading, spacing: 8) { + Label { + Text(NSLocalizedString("Поделитесь идеями, сообщите об ошибке или расскажите, что работает отлично.", comment: "feedback: info detail")) + } icon: { + Image(systemName: "bubble.left.and.bubble.right.fill") + .foregroundColor(.accentColor) + } + .font(.callout) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.accentColor.opacity(0.08)) + ) + } + + private var categorySection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionTitle(NSLocalizedString("Что вы хотите обсудить?", comment: "feedback: category title")) + LazyVGrid(columns: gridColumns, alignment: .leading, spacing: 12) { + ForEach(FeedbackCategory.allCases) { category in + categoryButton(for: category) + } + } + } + } + + private var ratingSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionTitle(NSLocalizedString("Насколько вам нравится Yobble?", comment: "feedback: rating title")) + HStack(spacing: 8) { + ForEach(1...5, id: \.self) { value in + Button { + rating = value + } label: { + Image(systemName: rating >= value ? "star.fill" : "star") + .foregroundColor(rating >= value ? Color.yellow : Color(.systemGray3)) + .font(.title3) + .padding(.vertical, 4) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(String(format: NSLocalizedString("Оценка %d", comment: "feedback: rating accessibility"), value))) + .accessibilityAddTraits(rating == value ? .isSelected : []) + } + } + + if rating > 0 { + Text(ratingDescription(for: rating)) + .font(.caption) + .foregroundColor(.secondary) + } else { + Text(NSLocalizedString("Выберите оценку — это поможет нам понять настроение.", comment: "feedback: rating hint")) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + private var suggestionSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionTitle(feedbackCategory.promptTitle) + + TextEditor(text: $suggestion) + .frame(minHeight: 140) + .padding(12) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color(.systemGray4), lineWidth: 1) + ) + .overlay( + Group { + if suggestion.isEmpty { + Text(feedbackCategory.placeholder) + .foregroundColor(.secondary) + .padding(.horizontal, 18) + .padding(.vertical, 16) + .allowsHitTesting(false) + } + } + ) + .focused($focusedField, equals: .suggestion) + .autocorrectionDisabled(false) + + Text(String(format: NSLocalizedString("%d символов", comment: "feedback: character count"), suggestion.count)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + + private var contactSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionTitle(NSLocalizedString("Нужно ли вам ответить?", comment: "feedback: contact title")) + + Toggle(NSLocalizedString("Получить ответ от команды", comment: "feedback: contact toggle"), isOn: $wantsResponse) + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + + if wantsResponse { + VStack(alignment: .leading, spacing: 6) { + TextField(NSLocalizedString("Ваш e-mail", comment: "feedback: email placeholder"), text: $contactEmail) + .textContentType(.emailAddress) + .textInputAutocapitalization(.never) + .keyboardType(.emailAddress) + .autocorrectionDisabled(true) + .padding(12) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(emailBorderColor, lineWidth: 1) + ) + .focused($focusedField, equals: .email) + + if !contactEmail.isEmpty && !emailIsValid { + Text(NSLocalizedString("Пожалуйста, введите корректный e-mail.", comment: "feedback: email error")) + .font(.caption) + .foregroundColor(.red) + } else { + Text(NSLocalizedString("Мы используем адрес только для ответа на ваш запрос.", comment: "feedback: email hint")) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + } + + private var canSubmit: Bool { + suggestionIsValid && emailIsValid && !isSubmitting } private var suggestionIsValid: Bool { !suggestion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + private var emailIsValid: Bool { + guard wantsResponse else { return true } + let trimmed = contactEmail.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + return trimmed.contains("@") && trimmed.contains(".") + } + + private var buttonBackgroundColor: Color { + suggestionIsValid && emailIsValid ? Color.accentColor : Color(.systemGray4) + } + + private var emailBorderColor: Color { + if contactEmail.isEmpty { return Color(.systemGray4) } + return emailIsValid ? Color.accentColor : .red + } + + private func sectionTitle(_ text: String) -> some View { + Text(text) + .font(.headline) + } + + private func categoryButton(for category: FeedbackCategory) -> some View { + let isSelected = feedbackCategory == category + + return Button { + feedbackCategory = category + } label: { + VStack(alignment: .leading, spacing: 6) { + Image(systemName: category.iconName) + .font(.subheadline) + .foregroundColor(isSelected ? .accentColor : .secondary) + Text(category.title) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text(category.subtitle) + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 14) + .padding(.horizontal, 16) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(isSelected ? Color.accentColor.opacity(0.12) : Color(.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(isSelected ? Color.accentColor : Color(.systemGray4), lineWidth: isSelected ? 2 : 1) + ) + } + .buttonStyle(.plain) + } + + private func ratingDescription(for rating: Int) -> String { + switch rating { + case 1: + return NSLocalizedString("Что-то пошло не так. Расскажите подробности ниже.", comment: "feedback: rating description 1") + case 2: + return NSLocalizedString("Мы постараемся всё исправить. Напишите, что смутило.", comment: "feedback: rating description 2") + case 3: + return NSLocalizedString("Неплохо, но можно лучше — что добавить?", comment: "feedback: rating description 3") + case 4: + return NSLocalizedString("Спасибо! Что поможет нам добраться до пятёрки?", comment: "feedback: rating description 4") + case 5: + return NSLocalizedString("Прекрасно! Расскажите, что понравилось больше всего.", comment: "feedback: rating description 5") + default: + return "" + } + } + private func submitSuggestion() { - guard suggestionIsValid else { return } - let trimmed = suggestion.trimmingCharacters(in: .whitespacesAndNewlines) + guard canSubmit else { return } + + let trimmedSuggestion = suggestion.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedEmail = contactEmail.trimmingCharacters(in: .whitespacesAndNewlines) dismissKeyboardIfNeeded() isSubmitting = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { // имитируем сетевой вызов - submittedSuggestion = trimmed + showSubmissionMessage = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { + submittedFeedback = SubmittedFeedback( + suggestion: trimmedSuggestion, + category: feedbackCategory, + rating: rating, + email: wantsResponse ? trimmedEmail : nil + ) suggestion = "" + rating = 0 + if !wantsResponse { + contactEmail = "" + } withAnimation { showSubmissionMessage = true } isSubmitting = false } } -} -private extension FeedbackView { - func dismissKeyboardIfNeeded() { - guard isSuggestionFocused else { return } - isSuggestionFocused = false + private func successSection(for submission: SubmittedFeedback) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label { + Text(NSLocalizedString("Спасибо! Мы получили ваш отзыв", comment: "feedback: success title")) + } icon: { + Image(systemName: "checkmark.seal.fill") + .foregroundColor(.accentColor) + } + .font(.headline) + + Text(String(format: NSLocalizedString("Тема: %@", comment: "feedback: success category"), submission.category.title)) + .font(.subheadline) + + if submission.rating > 0 { + Text(String(format: NSLocalizedString("Оценка: %d из 5", comment: "feedback: success rating"), submission.rating)) + .font(.subheadline) + } + + if !submission.suggestion.isEmpty { + Text(submission.suggestion) + .font(.callout) + .foregroundColor(.secondary) + } + + if let email = submission.email, !email.isEmpty { + Text(String(format: NSLocalizedString("Мы свяжемся с вами по адресу %@, как только ответим.", comment: "feedback: success email"), email)) + .font(.footnote) + .foregroundColor(.secondary) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.accentColor.opacity(0.12)) + ) + } + + private func dismissKeyboardIfNeeded() { + guard focusedField != nil else { return } + focusedField = nil #if canImport(UIKit) UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) @@ -133,9 +370,99 @@ private extension FeedbackView { } } -struct FeedbackView_Previews: PreviewProvider { - static var previews: some View { - FeedbackView() - .environmentObject(ThemeManager()) +private extension FeedbackView { + enum Field: Hashable { + case suggestion + case email + } + + struct SubmittedFeedback { + let suggestion: String + let category: FeedbackCategory + let rating: Int + let email: String? + } + + enum FeedbackCategory: String, CaseIterable, Identifiable { + case idea + case bug + case praise + case content + + var id: String { rawValue } + + var title: String { + switch self { + case .idea: + return NSLocalizedString("Идея", comment: "feedback category: idea") + case .bug: + return NSLocalizedString("Проблема", comment: "feedback category: bug") + case .praise: + return NSLocalizedString("Похвала", comment: "feedback category: praise") + case .content: + return NSLocalizedString("Контент", comment: "feedback category: content") + } + } + + var subtitle: String { + switch self { + case .idea: + return NSLocalizedString("Предложите, что добавить", comment: "feedback category subtitle: idea") + case .bug: + return NSLocalizedString("Расскажите, что не работает", comment: "feedback category subtitle: bug") + case .praise: + return NSLocalizedString("Поделитесь, что понравилось", comment: "feedback category subtitle: praise") + case .content: + return NSLocalizedString("Сообщите о материалах", comment: "feedback category subtitle: content") + } + } + + var iconName: String { + switch self { + case .idea: + return "lightbulb" + case .bug: + return "ant" + case .praise: + return "heart.fill" + case .content: + return "doc.richtext" + } + } + + var promptTitle: String { + switch self { + case .idea: + return NSLocalizedString("Опишите идею", comment: "feedback prompt: idea") + case .bug: + return NSLocalizedString("Что случилось?", comment: "feedback prompt: bug") + case .praise: + return NSLocalizedString("Что вам понравилось?", comment: "feedback prompt: praise") + case .content: + return NSLocalizedString("О каком контенте идёт речь?", comment: "feedback prompt: content") + } + } + + var placeholder: String { + switch self { + case .idea: + return NSLocalizedString("Например: хотелось бы видеть подборку по интересам...", comment: "feedback placeholder: idea") + case .bug: + return NSLocalizedString("Например: приложение вылетает, когда я открываю профиль...", comment: "feedback placeholder: bug") + case .praise: + return NSLocalizedString("Например: понравилась новая лента, потому что...", comment: "feedback placeholder: praise") + case .content: + return NSLocalizedString("Например: заметил неточную информацию в статье...", comment: "feedback placeholder: content") + } + } + } +} + +struct FeedbackView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + FeedbackView() + .environmentObject(ThemeManager()) + } } }