feat: full Pulse Mobile implementation - all modules
- Phase 0: project.yml fixes (CODE_SIGN_ENTITLEMENTS confirmed) - Phase 1: Enhanced models (HabitModels, TaskModels, FinanceModels, SavingsModels, UserModels) - Phase 1: Enhanced APIService with all endpoints (habits/log/stats, tasks/uncomplete, finance/analytics, savings/*) - Phase 2: DashboardView rewrite - day progress bar, 4 stat cards, habit/task lists with Undo (3 sec) - Phase 3: TrackerView - HabitListView (streak badge, swipe delete, archive), TaskListView (priority, overdue), StatisticsView (heatmap 84 days, line chart, bar chart via Swift Charts) - Phase 4: FinanceView rewrite - month picker, summary card, top expenses progress bars, pie chart, line chart, transactions by day, analytics tab with bar chart + month comparison - Phase 5: SavingsView rewrite - overview with overdue block, categories tab with type icons, operations tab with category filter + add sheet - Phase 6: SettingsView - dark/light theme, profile edit, telegram chat id, notifications toggle + time, timezone picker, logout - Added: AddHabitView with weekly day selector + interval days - Added: AddTaskView with icon/color/due date picker - Haptic feedback on all toggle actions
This commit is contained in:
293
PulseHealth/Views/Settings/SettingsView.swift
Normal file
293
PulseHealth/Views/Settings/SettingsView.swift
Normal file
@@ -0,0 +1,293 @@
|
||||
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 morningNotification = true
|
||||
@State private var eveningNotification = true
|
||||
@State private var morningTime = "09:00"
|
||||
@State private var eveningTime = "21:00"
|
||||
@State private var timezone = "Europe/Moscow"
|
||||
@State private var username = ""
|
||||
|
||||
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: "0a0a1a").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: Telegram
|
||||
SettingsSection(title: "Telegram Бот") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Chat ID", systemImage: "paperplane.fill")
|
||||
.font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||
TextField("Например: 123456789", text: $telegramChatId)
|
||||
.keyboardType(.numbersAndPunctuation)
|
||||
.foregroundColor(.white).padding(14)
|
||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "info.circle").font(.caption2).foregroundColor(Color(hex: "0D9488"))
|
||||
Text("Напишите /start боту @pulse_tracking_bot, чтобы получить Chat ID")
|
||||
.font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
SettingsSection(title: "Уведомления") {
|
||||
VStack(spacing: 12) {
|
||||
SettingsToggle(icon: "sunrise.fill", title: "Утренние уведомления", color: "ffa502", isOn: morningNotification) {
|
||||
morningNotification.toggle()
|
||||
}
|
||||
if morningNotification {
|
||||
HStack {
|
||||
Text("Время").font(.callout).foregroundColor(Color(hex: "8888aa"))
|
||||
Spacer()
|
||||
TextField("09:00", text: $morningTime)
|
||||
.keyboardType(.numbersAndPunctuation)
|
||||
.foregroundColor(.white)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 60)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
Divider().background(Color.white.opacity(0.08))
|
||||
|
||||
SettingsToggle(icon: "moon.stars.fill", title: "Вечерние уведомления", color: "6366f1", isOn: eveningNotification) {
|
||||
eveningNotification.toggle()
|
||||
}
|
||||
if eveningNotification {
|
||||
HStack {
|
||||
Text("Время").font(.callout).foregroundColor(Color(hex: "8888aa"))
|
||||
Spacer()
|
||||
TextField("21:00", text: $eveningTime)
|
||||
.keyboardType(.numbersAndPunctuation)
|
||||
.foregroundColor(.white)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 60)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Timezone
|
||||
SettingsSection(title: "Часовой пояс") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Выберите часовой пояс", systemImage: "clock.fill")
|
||||
.font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||
Picker("Часовой пояс", selection: $timezone) {
|
||||
ForEach(timezones, id: \.self) { tz in Text(tz).tag(tz) }
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
.frame(height: 120)
|
||||
.clipped()
|
||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.04)))
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
Text("Pulse v1.1 • Made with ❤️").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: "0a0a1a"))
|
||||
}
|
||||
}
|
||||
|
||||
func loadProfile() async {
|
||||
isLoading = true
|
||||
username = authManager.userName
|
||||
if let p = try? await APIService.shared.getProfile(token: authManager.token) {
|
||||
profile = p
|
||||
telegramChatId = p.telegramChatId ?? ""
|
||||
morningNotification = p.morningNotification ?? true
|
||||
eveningNotification = p.eveningNotification ?? true
|
||||
morningTime = p.morningTime ?? "09:00"
|
||||
eveningTime = p.eveningTime ?? "21:00"
|
||||
timezone = p.timezone ?? "Europe/Moscow"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func saveProfile() async {
|
||||
isSaving = true
|
||||
let req = UpdateProfileRequest(
|
||||
telegramChatId: telegramChatId.isEmpty ? nil : telegramChatId,
|
||||
morningNotification: morningNotification,
|
||||
eveningNotification: eveningNotification,
|
||||
morningTime: morningTime,
|
||||
eveningTime: eveningTime,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
|
||||
.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) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(Color(hex: color).opacity(0.2)).frame(width: 36, height: 36)
|
||||
Image(systemName: icon).foregroundColor(Color(hex: color)).font(.subheadline)
|
||||
}
|
||||
Text(title).font(.callout).foregroundColor(.white)
|
||||
Spacer()
|
||||
Toggle("", isOn: Binding(get: { isOn }, set: { _ in onToggle() }))
|
||||
.tint(Color(hex: "0D9488"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(Color(hex: color).opacity(0.2)).frame(width: 36, height: 36)
|
||||
Image(systemName: icon).foregroundColor(Color(hex: color)).font(.subheadline)
|
||||
}
|
||||
Text(title).font(.callout).foregroundColor(.white)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").foregroundColor(Color(hex: "8888aa")).font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user