change feedback

This commit is contained in:
cheykrym 2025-10-08 04:27:13 +03:00
parent d63391fadf
commit 02be9062d0
2 changed files with 518 additions and 94 deletions

View File

@ -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"
},
"Экран чата в разработке" : {
},

View File

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