Files
pulse-mobile/PulseHealth/Views/Settings/SettingsView.swift
Daniil Klimov 44c759c190 fix: security hardening — Keychain, no hardcoded creds, safe URLs
- 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>
2026-04-06 14:11:10 +03:00

378 lines
18 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
// 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)
}
}
}
}