ios_app_v2/yobble/Views/Tab/Settings/FeedbackView.swift
2025-10-26 03:34:37 +03:00

488 lines
20 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.

import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct FeedbackView: View {
@State private var suggestion: String = ""
@State private var submittedFeedback: SubmittedFeedback? = nil
@State private var isSubmitting: Bool = false
@State private var showSubmissionMessage: Bool = false
@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: 24) {
headerSection
infoSection
categorySection
ratingSection
suggestionSection
contactSection
infoSection2
Button(action: submitSuggestion) {
HStack(spacing: 10) {
if isSubmitting {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
Text(isSubmitting
? NSLocalizedString("Отправляем...", comment: "feedback: sending state")
: NSLocalizedString("Отправить отзыв", comment: "feedback: submit button"))
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(buttonBackgroundColor)
.foregroundColor(.white)
.cornerRadius(16)
}
.disabled(!canSubmit)
if let submission = submittedFeedback, showSubmissionMessage {
successSection(for: submission)
.transition(.move(edge: .top).combined(with: .opacity))
}
Spacer(minLength: 16)
}
.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 infoSection2: some View {
VStack(alignment: .leading, spacing: 8) {
Label {
Text(NSLocalizedString("Ваш отзыв создаст чат с командой поддержки, который появится в общем списке чатов.", comment: "feedback: info detail chat"))
} icon: {
Image(systemName: "lock.shield.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("Уведомить об ответе по e-mail", 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 canSubmit else { return }
let trimmedSuggestion = suggestion.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedEmail = contactEmail.trimmingCharacters(in: .whitespacesAndNewlines)
dismissKeyboardIfNeeded()
isSubmitting = true
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 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)
#endif
}
}
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())
}
}
}