- Add KeychainService for encrypted token storage (auth, refresh, health JWT, API key) - Remove hardcoded email/password from HealthAPIService, store in Keychain - Move all tokens from UserDefaults to Keychain - API key sent via X-API-Key header instead of URL query parameter - Replace force unwrap URL(string:)! with guard let + throws - Fix force unwrap Calendar.date() in HealthKitService - Mark HealthKitService @MainActor for thread-safe @Published - Use withTaskGroup for parallel habit log fetching in TrackerView - Check notification permission before scheduling reminders - Add input validation (title max 200 chars) - Add privacy policy and terms links in Settings - Update CLAUDE.md with security section Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
378 lines
18 KiB
Swift
378 lines
18 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - SettingsView
|
||
|
||
struct SettingsView: View {
|
||
@EnvironmentObject var authManager: AuthManager
|
||
@AppStorage("colorScheme") private var colorSchemeRaw: String = "dark"
|
||
@State private var profile: UserProfile?
|
||
@State private var isLoading = true
|
||
@State private var isSaving = false
|
||
@State private var showPasswordChange = false
|
||
|
||
// Profile fields
|
||
@State private var telegramChatId = ""
|
||
@State private var timezone = "Europe/Moscow"
|
||
@State private var username = ""
|
||
|
||
// Local notifications
|
||
@AppStorage("notif_morning") private var morningNotif = false
|
||
@AppStorage("notif_evening") private var eveningNotif = false
|
||
@AppStorage("notif_morning_hour") private var morningHour = 8
|
||
@AppStorage("notif_morning_min") private var morningMin = 0
|
||
@AppStorage("notif_evening_hour") private var eveningHour = 21
|
||
@AppStorage("notif_evening_min") private var eveningMin = 0
|
||
@AppStorage("notif_payments") private var paymentNotif = true
|
||
@State private var notifAuthorized = false
|
||
@State private var morningDate = Calendar.current.date(from: DateComponents(hour: 8, minute: 0))!
|
||
@State private var eveningDate = Calendar.current.date(from: DateComponents(hour: 21, minute: 0))!
|
||
|
||
var isDark: Bool { colorSchemeRaw != "light" }
|
||
|
||
let timezones = [
|
||
"Europe/Moscow", "Europe/Kaliningrad", "Europe/Samara", "Asia/Yekaterinburg",
|
||
"Asia/Omsk", "Asia/Krasnoyarsk", "Asia/Irkutsk", "Asia/Yakutsk",
|
||
"Asia/Vladivostok", "Asia/Magadan", "Asia/Kamchatka",
|
||
"UTC", "Europe/London", "Europe/Berlin", "Europe/Paris", "America/New_York"
|
||
]
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Color(hex: "06060f").ignoresSafeArea()
|
||
ScrollView {
|
||
VStack(spacing: 0) {
|
||
// Header / Avatar
|
||
VStack(spacing: 12) {
|
||
ZStack {
|
||
Circle().fill(Color(hex: "0D9488").opacity(0.2)).frame(width: 80, height: 80)
|
||
Text(String(authManager.userName.prefix(1)).uppercased())
|
||
.font(.largeTitle.bold()).foregroundColor(Color(hex: "0D9488"))
|
||
}
|
||
Text(authManager.userName).font(.title2.bold()).foregroundColor(.white)
|
||
Text("ID: \(authManager.userId)").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||
}
|
||
.padding(.top, 24)
|
||
.padding(.bottom, 28)
|
||
|
||
if isLoading {
|
||
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 20)
|
||
} else {
|
||
VStack(spacing: 20) {
|
||
// MARK: Appearance
|
||
SettingsSection(title: "Внешний вид") {
|
||
SettingsToggle(icon: "moon.fill", title: isDark ? "Тёмная тема" : "Светлая тема", color: "6366f1", isOn: isDark) {
|
||
colorSchemeRaw = isDark ? "light" : "dark"
|
||
}
|
||
}
|
||
|
||
// MARK: Profile
|
||
SettingsSection(title: "Профиль") {
|
||
VStack(spacing: 8) {
|
||
Label("Имя пользователя", systemImage: "person.fill")
|
||
.font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
TextField("Username", text: $username)
|
||
.foregroundColor(.white).padding(14)
|
||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
||
}
|
||
.padding(.horizontal, 4)
|
||
|
||
SettingsButton(icon: "lock.fill", title: "Сменить пароль", color: "7c3aed") {
|
||
showPasswordChange = true
|
||
}
|
||
}
|
||
|
||
// MARK: Notifications
|
||
SettingsSection(title: "Уведомления") {
|
||
if !notifAuthorized {
|
||
Button(action: {
|
||
Task { notifAuthorized = await NotificationService.shared.requestPermission() }
|
||
}) {
|
||
HStack(spacing: 14) {
|
||
GlowIcon(systemName: "bell.badge.fill", color: Theme.orange, size: 36, iconSize: .subheadline)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Включить уведомления").font(.callout).foregroundColor(.white)
|
||
Text("Нажми, чтобы разрешить").font(.caption).foregroundColor(Theme.textSecondary)
|
||
}
|
||
Spacer()
|
||
Image(systemName: "arrow.right.circle.fill").foregroundColor(Theme.teal)
|
||
}
|
||
}
|
||
} else {
|
||
VStack(spacing: 14) {
|
||
// Morning
|
||
NotifRow(icon: "sunrise.fill", title: "Утреннее", color: Theme.orange,
|
||
isOn: $morningNotif, date: $morningDate)
|
||
Divider().background(Color.white.opacity(0.06))
|
||
// Evening
|
||
NotifRow(icon: "moon.stars.fill", title: "Вечернее", color: Theme.indigo,
|
||
isOn: $eveningNotif, date: $eveningDate)
|
||
Divider().background(Color.white.opacity(0.06))
|
||
// Payments
|
||
HStack(spacing: 14) {
|
||
GlowIcon(systemName: "creditcard.fill", color: Theme.purple, size: 36, iconSize: .subheadline)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Платежи").font(.callout).foregroundColor(.white)
|
||
Text("За 5 дн, 1 день и в день оплаты").font(.caption2).foregroundColor(Theme.textSecondary)
|
||
}
|
||
Spacer()
|
||
Toggle("", isOn: $paymentNotif).tint(Theme.teal).labelsHidden()
|
||
}
|
||
}
|
||
.onChange(of: morningNotif) { applyNotifSchedule() }
|
||
.onChange(of: eveningNotif) { applyNotifSchedule() }
|
||
.onChange(of: morningDate) { saveTimes(); applyNotifSchedule() }
|
||
.onChange(of: eveningDate) { saveTimes(); applyNotifSchedule() }
|
||
.onChange(of: paymentNotif) { Task { await schedulePaymentNotifs() } }
|
||
}
|
||
}
|
||
|
||
// MARK: Timezone
|
||
SettingsSection(title: "Часовой пояс") {
|
||
HStack(spacing: 14) {
|
||
GlowIcon(systemName: "clock.fill", color: Theme.blue, size: 36, iconSize: .subheadline)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Часовой пояс").font(.callout).foregroundColor(.white)
|
||
Text(timezoneDisplay(timezone)).font(.caption).foregroundColor(Theme.textSecondary)
|
||
}
|
||
Spacer()
|
||
Menu {
|
||
ForEach(timezones, id: \.self) { tz in
|
||
Button(action: { timezone = tz }) {
|
||
HStack {
|
||
Text(timezoneDisplay(tz))
|
||
if timezone == tz { Image(systemName: "checkmark") }
|
||
}
|
||
}
|
||
}
|
||
} label: {
|
||
HStack(spacing: 4) {
|
||
Text(timezoneShort(timezone)).font(.callout.bold()).foregroundColor(Theme.teal)
|
||
Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundColor(Theme.teal)
|
||
}
|
||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||
.background(RoundedRectangle(cornerRadius: 10).fill(Theme.teal.opacity(0.15)))
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: Save Button
|
||
Button(action: { Task { await saveProfile() } }) {
|
||
HStack {
|
||
if isSaving { ProgressView().tint(.black).scaleEffect(0.8) }
|
||
else { Text("Сохранить изменения").font(.headline).foregroundColor(.black) }
|
||
}
|
||
.frame(maxWidth: .infinity).padding()
|
||
.background(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .leading, endPoint: .trailing))
|
||
.cornerRadius(14)
|
||
}
|
||
.disabled(isSaving)
|
||
.padding(.horizontal)
|
||
|
||
// MARK: Logout
|
||
Button(action: { authManager.logout() }) {
|
||
HStack {
|
||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||
Text("Выйти из аккаунта")
|
||
}
|
||
.font(.callout.bold())
|
||
.foregroundColor(Color(hex: "ff4757"))
|
||
.frame(maxWidth: .infinity).padding()
|
||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "ff4757").opacity(0.1)))
|
||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "ff4757").opacity(0.3), lineWidth: 1))
|
||
}
|
||
.padding(.horizontal)
|
||
|
||
// Legal
|
||
HStack(spacing: 16) {
|
||
if let url = URL(string: "https://pulse.digital-home.site/privacy") {
|
||
Link("Политика конфиденциальности", destination: url)
|
||
.font(.caption).foregroundColor(Theme.textSecondary)
|
||
}
|
||
if let url = URL(string: "https://pulse.digital-home.site/terms") {
|
||
Link("Условия", destination: url)
|
||
.font(.caption).foregroundColor(Theme.textSecondary)
|
||
}
|
||
}
|
||
|
||
Text("Pulse v1.1").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||
.padding(.bottom, 20)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.task { await loadProfile() }
|
||
.sheet(isPresented: $showPasswordChange) {
|
||
ChangePasswordView(isPresented: $showPasswordChange)
|
||
.presentationDetents([.medium])
|
||
.presentationDragIndicator(.visible)
|
||
.presentationBackground(Color(hex: "06060f"))
|
||
}
|
||
}
|
||
|
||
func loadProfile() async {
|
||
isLoading = true
|
||
username = authManager.userName
|
||
notifAuthorized = await NotificationService.shared.isAuthorized()
|
||
morningDate = Calendar.current.date(from: DateComponents(hour: morningHour, minute: morningMin)) ?? morningDate
|
||
eveningDate = Calendar.current.date(from: DateComponents(hour: eveningHour, minute: eveningMin)) ?? eveningDate
|
||
if let p = try? await APIService.shared.getProfile(token: authManager.token) {
|
||
profile = p
|
||
telegramChatId = p.telegramChatId ?? ""
|
||
timezone = p.timezone ?? "Europe/Moscow"
|
||
}
|
||
isLoading = false
|
||
}
|
||
|
||
func saveProfile() async {
|
||
isSaving = true
|
||
let req = UpdateProfileRequest(
|
||
telegramChatId: telegramChatId.isEmpty ? nil : telegramChatId,
|
||
timezone: timezone
|
||
)
|
||
_ = try? await APIService.shared.updateProfile(token: authManager.token, request: req)
|
||
// Update username if changed
|
||
if username != authManager.userName {
|
||
var req2 = URLRequest(url: URL(string: "https://api.digital-home.site/auth/me")!)
|
||
req2.httpMethod = "PUT"
|
||
req2.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||
req2.setValue("Bearer \(authManager.token)", forHTTPHeaderField: "Authorization")
|
||
req2.httpBody = try? JSONEncoder().encode(["username": username])
|
||
_ = try? await URLSession.shared.data(for: req2)
|
||
await MainActor.run { authManager.userName = username; UserDefaults.standard.set(username, forKey: "userName") }
|
||
}
|
||
isSaving = false
|
||
}
|
||
|
||
func applyNotifSchedule() {
|
||
let cal = Calendar.current
|
||
let mh = cal.component(.hour, from: morningDate)
|
||
let mm = cal.component(.minute, from: morningDate)
|
||
let eh = cal.component(.hour, from: eveningDate)
|
||
let em = cal.component(.minute, from: eveningDate)
|
||
NotificationService.shared.updateSchedule(
|
||
morning: morningNotif, morningTime: "\(mh):\(String(format: "%02d", mm))",
|
||
evening: eveningNotif, eveningTime: "\(eh):\(String(format: "%02d", em))"
|
||
)
|
||
}
|
||
|
||
func saveTimes() {
|
||
let cal = Calendar.current
|
||
morningHour = cal.component(.hour, from: morningDate)
|
||
morningMin = cal.component(.minute, from: morningDate)
|
||
eveningHour = cal.component(.hour, from: eveningDate)
|
||
eveningMin = cal.component(.minute, from: eveningDate)
|
||
}
|
||
|
||
func schedulePaymentNotifs() async {
|
||
guard paymentNotif else {
|
||
NotificationService.shared.cancelPaymentReminders()
|
||
return
|
||
}
|
||
let stats = try? await APIService.shared.getSavingsStats(token: authManager.token)
|
||
guard let details = stats?.monthlyPaymentDetails else { return }
|
||
NotificationService.shared.schedulePaymentReminders(payments: details)
|
||
}
|
||
|
||
func timezoneDisplay(_ tz: String) -> String {
|
||
guard let zone = TimeZone(identifier: tz) else { return tz }
|
||
let offset = zone.secondsFromGMT() / 3600
|
||
let sign = offset >= 0 ? "+" : ""
|
||
let city = tz.split(separator: "/").last?.replacingOccurrences(of: "_", with: " ") ?? tz
|
||
return "\(city) (UTC\(sign)\(offset))"
|
||
}
|
||
|
||
func timezoneShort(_ tz: String) -> String {
|
||
guard let zone = TimeZone(identifier: tz) else { return tz }
|
||
let offset = zone.secondsFromGMT() / 3600
|
||
let sign = offset >= 0 ? "+" : ""
|
||
return "UTC\(sign)\(offset)"
|
||
}
|
||
}
|
||
|
||
// MARK: - SettingsSection
|
||
|
||
struct SettingsSection<Content: View>: View {
|
||
let title: String
|
||
@ViewBuilder let content: () -> Content
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text(title).font(.subheadline.bold()).foregroundColor(Color(hex: "8888aa")).padding(.horizontal)
|
||
VStack(spacing: 8) {
|
||
content()
|
||
}
|
||
.padding(16)
|
||
.glassCard(cornerRadius: 16)
|
||
.padding(.horizontal)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - SettingsToggle
|
||
|
||
struct SettingsToggle: View {
|
||
let icon: String
|
||
let title: String
|
||
let color: String
|
||
let isOn: Bool
|
||
let onToggle: () -> Void
|
||
var body: some View {
|
||
HStack(spacing: 14) {
|
||
GlowIcon(systemName: icon, color: Color(hex: color), size: 36, iconSize: .subheadline)
|
||
Text(title).font(.callout).foregroundColor(.white)
|
||
Spacer()
|
||
Toggle("", isOn: Binding(get: { isOn }, set: { _ in onToggle() }))
|
||
.tint(Theme.teal)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - SettingsButton
|
||
|
||
struct SettingsButton: View {
|
||
let icon: String
|
||
let title: String
|
||
let color: String
|
||
let action: () -> Void
|
||
var body: some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 14) {
|
||
GlowIcon(systemName: icon, color: Color(hex: color), size: 36, iconSize: .subheadline)
|
||
Text(title).font(.callout).foregroundColor(.white)
|
||
Spacer()
|
||
Image(systemName: "chevron.right").foregroundColor(Theme.textSecondary).font(.caption)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - NotifRow
|
||
|
||
struct NotifRow: View {
|
||
let icon: String
|
||
let title: String
|
||
let color: Color
|
||
@Binding var isOn: Bool
|
||
@Binding var date: Date
|
||
|
||
var body: some View {
|
||
VStack(spacing: 8) {
|
||
HStack(spacing: 14) {
|
||
GlowIcon(systemName: icon, color: color, size: 36, iconSize: .subheadline)
|
||
Text(title).font(.callout).foregroundColor(.white)
|
||
Spacer()
|
||
Toggle("", isOn: $isOn).tint(Theme.teal).labelsHidden()
|
||
}
|
||
if isOn {
|
||
DatePicker("", selection: $date, displayedComponents: .hourAndMinute)
|
||
.datePickerStyle(.wheel)
|
||
.labelsHidden()
|
||
.frame(height: 100)
|
||
.clipped()
|
||
.environment(\.colorScheme, .dark)
|
||
}
|
||
}
|
||
}
|
||
}
|