488 lines
20 KiB
Swift
488 lines
20 KiB
Swift
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())
|
||
}
|
||
}
|
||
}
|