fix: API field mapping, HealthKit entitlement, profile tab, forgot password
This commit is contained in:
@@ -26,14 +26,16 @@ struct FinanceCategory: Codable, Identifiable {
|
||||
|
||||
struct FinanceSummary: Codable {
|
||||
var totalIncome: Double?
|
||||
var totalExpenses: Double?
|
||||
var totalExpense: Double?
|
||||
var balance: Double?
|
||||
var carriedOver: Double?
|
||||
var month: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case totalIncome = "total_income"
|
||||
case totalExpenses = "total_expenses"
|
||||
case balance, month
|
||||
case totalIncome = "total_income"
|
||||
case totalExpense = "total_expense"
|
||||
case carriedOver = "carried_over"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ struct Habit: Codable, Identifiable {
|
||||
var color: String?
|
||||
var frequency: HabitFrequency
|
||||
var reminderTime: String?
|
||||
var targetDays: Int?
|
||||
var targetDays: [Int]?
|
||||
var targetCount: Int?
|
||||
var currentStreak: Int?
|
||||
var longestStreak: Int?
|
||||
var completedToday: Bool?
|
||||
@@ -29,6 +30,7 @@ struct Habit: Codable, Identifiable {
|
||||
case id, name, description, icon, color, frequency
|
||||
case reminderTime = "reminder_time"
|
||||
case targetDays = "target_days"
|
||||
case targetCount = "target_count"
|
||||
case currentStreak = "current_streak"
|
||||
case longestStreak = "longest_streak"
|
||||
case completedToday = "completed_today"
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
enum TaskPriority: String, Codable, CaseIterable {
|
||||
case low, medium, high, urgent
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .low: return "Низкий"
|
||||
case .medium: return "Средний"
|
||||
case .high: return "Высокий"
|
||||
case .urgent: return "Срочный"
|
||||
}
|
||||
}
|
||||
var color: String {
|
||||
switch self {
|
||||
case .low: return "8888aa"
|
||||
case .medium: return "ffa502"
|
||||
case .high: return "ff4757"
|
||||
case .urgent: return "ff0000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PulseTask: Codable, Identifiable {
|
||||
let id: Int
|
||||
var title: String
|
||||
var description: String?
|
||||
var done: Bool
|
||||
var priority: TaskPriority?
|
||||
var completed: Bool
|
||||
var priority: Int?
|
||||
var icon: String?
|
||||
var color: String?
|
||||
var dueDate: String?
|
||||
var reminderTime: String?
|
||||
var createdAt: String?
|
||||
|
||||
var priorityColor: String {
|
||||
switch priority {
|
||||
case 4: return "ff0000"
|
||||
case 3: return "ff4757"
|
||||
case 2: return "ffa502"
|
||||
default: return "8888aa"
|
||||
}
|
||||
}
|
||||
|
||||
var priorityDisplayName: String {
|
||||
switch priority {
|
||||
case 1: return "Низкий"
|
||||
case 2: return "Средний"
|
||||
case 3: return "Высокий"
|
||||
case 4: return "Срочный"
|
||||
default: return "Без приоритета"
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, description, done, priority
|
||||
case id, title, description, completed, priority, icon, color
|
||||
case dueDate = "due_date"
|
||||
case reminderTime = "reminder_time"
|
||||
case createdAt = "created_at"
|
||||
@@ -41,7 +42,7 @@ struct PulseTask: Codable, Identifiable {
|
||||
struct CreateTaskRequest: Codable {
|
||||
var title: String
|
||||
var description: String?
|
||||
var priority: TaskPriority?
|
||||
var priority: Int?
|
||||
var dueDate: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
|
||||
@@ -4,5 +4,9 @@
|
||||
<dict>
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.healthkit.access</key>
|
||||
<array>
|
||||
<string>health-records</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -18,7 +18,7 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var pendingTasks: [PulseTask] { tasks.filter { !$0.done } }
|
||||
var pendingTasks: [PulseTask] { tasks.filter { !$0.completed } }
|
||||
var completedHabitsToday: Int { habits.filter { $0.completedToday == true }.count }
|
||||
|
||||
var body: some View {
|
||||
@@ -67,7 +67,7 @@ struct DashboardView: View {
|
||||
StatCard(icon: "checkmark.circle.fill", value: "\(pendingTasks.count)", label: "Задач", color: "00d4aa")
|
||||
StatCard(icon: "flame.fill", value: "\(completedHabitsToday)/\(habits.count)", label: "Привычек", color: "ffa502")
|
||||
if let s = summary, let balance = s.balance {
|
||||
StatCard(icon: "rublesign.circle.fill", value: "\(Int(balance))₽", label: "Баланс", color: "7c3aed")
|
||||
StatCard(icon: "rublesign.circle.fill", value: "\(Int(balance))₽", label: "Финансы", color: "7c3aed")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
@@ -85,7 +85,7 @@ struct FinanceSummaryCard: View {
|
||||
Spacer()
|
||||
VStack(spacing: 4) {
|
||||
Text("Расходы").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||
Text("-\(Int(summary.totalExpenses ?? 0))₽").font(.headline).foregroundColor(Color(hex: "ff4757"))
|
||||
Text("-\(Int(summary.totalExpense ?? 0))₽").font(.headline).foregroundColor(Color(hex: "ff4757"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ struct LoginView: View {
|
||||
@State private var errorMessage = ""
|
||||
@State private var showPassword = false
|
||||
@State private var isRegistering = false
|
||||
@State private var showForgotSheet = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -83,6 +84,14 @@ struct LoginView: View {
|
||||
.cornerRadius(12)
|
||||
.disabled(isLoading || email.isEmpty || password.isEmpty)
|
||||
|
||||
if !isRegistering {
|
||||
Button(action: { showForgotSheet = true }) {
|
||||
Text("Забыли пароль?")
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color(hex: "00d4aa"))
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle login/register
|
||||
HStack {
|
||||
Text(isRegistering ? "Уже есть аккаунт?" : "Нет аккаунта?")
|
||||
@@ -103,6 +112,9 @@ struct LoginView: View {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showForgotSheet) {
|
||||
ForgotPasswordView(isPresented: $showForgotSheet)
|
||||
}
|
||||
}
|
||||
|
||||
func login() {
|
||||
@@ -144,3 +156,54 @@ struct LoginView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ForgotPasswordView
|
||||
|
||||
struct ForgotPasswordView: View {
|
||||
@Binding var isPresented: Bool
|
||||
@State private var email = ""
|
||||
@State private var isSent = false
|
||||
@State private var isLoading = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
||||
VStack(spacing: 24) {
|
||||
Text("Сброс пароля").font(.title2.bold()).foregroundColor(.white)
|
||||
if isSent {
|
||||
VStack(spacing: 12) {
|
||||
Text("✅").font(.system(size: 50))
|
||||
Text("Письмо отправлено!\nПроверьте \(email)")
|
||||
.foregroundColor(.white).multilineTextAlignment(.center)
|
||||
}
|
||||
Button("Закрыть") { isPresented = false }.foregroundColor(Color(hex: "00d4aa"))
|
||||
} else {
|
||||
Text("Введите email — пришлём ссылку для сброса пароля")
|
||||
.foregroundColor(.white.opacity(0.6)).multilineTextAlignment(.center)
|
||||
TextField("Email", text: $email)
|
||||
.keyboardType(.emailAddress).autocapitalization(.none)
|
||||
.padding().background(Color.white.opacity(0.1)).cornerRadius(12).foregroundColor(.white)
|
||||
Button(action: send) {
|
||||
if isLoading { ProgressView().tint(.black) }
|
||||
else { Text("Отправить").font(.headline).foregroundColor(.black) }
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding()
|
||||
.background(Color(hex: "00d4aa")).cornerRadius(12).disabled(email.isEmpty || isLoading)
|
||||
Button("Отмена") { isPresented = false }.foregroundColor(.white.opacity(0.5))
|
||||
}
|
||||
}.padding(32)
|
||||
}
|
||||
}
|
||||
|
||||
func send() {
|
||||
isLoading = true
|
||||
Task {
|
||||
var req = URLRequest(url: URL(string: "https://api.digital-home.site/auth/forgot-password")!)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try? JSONEncoder().encode(["email": email])
|
||||
_ = try? await URLSession.shared.data(for: req)
|
||||
await MainActor.run { isSent = true; isLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ struct MainTabView: View {
|
||||
|
||||
FinanceView()
|
||||
.tabItem { Label("Финансы", systemImage: "rublesign.circle.fill") }
|
||||
|
||||
ProfileView()
|
||||
.tabItem { Label("Профиль", systemImage: "person.fill") }
|
||||
}
|
||||
.accentColor(Color(hex: "00d4aa"))
|
||||
.preferredColorScheme(.dark)
|
||||
|
||||
148
PulseHealth/Views/Profile/ProfileView.swift
Normal file
148
PulseHealth/Views/Profile/ProfileView.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ProfileView: View {
|
||||
@EnvironmentObject var authManager: AuthManager
|
||||
@State private var showChangePassword = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
VStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle().fill(Color(hex: "00d4aa").opacity(0.2)).frame(width: 80, height: 80)
|
||||
Text(String(authManager.userName.prefix(1)).uppercased())
|
||||
.font(.largeTitle.bold()).foregroundColor(Color(hex: "00d4aa"))
|
||||
}
|
||||
Text(authManager.userName).font(.title2.bold()).foregroundColor(.white)
|
||||
}
|
||||
.padding(.top, 40).padding(.bottom, 32)
|
||||
|
||||
// Settings list
|
||||
VStack(spacing: 2) {
|
||||
ProfileRow(icon: "lock.fill", title: "Сменить пароль", color: "7c3aed") {
|
||||
showChangePassword = true
|
||||
}
|
||||
ProfileRow(icon: "heart.fill", title: "Health API ключ", subtitle: authManager.healthApiKey, color: "ff4757") {}
|
||||
Divider().background(Color.white.opacity(0.1)).padding(.vertical, 8)
|
||||
ProfileRow(icon: "rectangle.portrait.and.arrow.right", title: "Выйти", color: "ff4757", isDestructive: true) {
|
||||
authManager.logout()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("Pulse v1.0").font(.caption).foregroundColor(Color(hex: "8888aa")).padding(.bottom, 20)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showChangePassword) {
|
||||
ChangePasswordView(isPresented: $showChangePassword)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfileRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
let color: String
|
||||
var isDestructive: Bool = false
|
||||
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)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).foregroundColor(isDestructive ? Color(hex: "ff4757") : .white).font(.callout)
|
||||
if let sub = subtitle {
|
||||
Text(sub).font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if !isDestructive {
|
||||
Image(systemName: "chevron.right").foregroundColor(Color(hex: "8888aa")).font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChangePasswordView: View {
|
||||
@Binding var isPresented: Bool
|
||||
@EnvironmentObject var authManager: AuthManager
|
||||
@State private var oldPassword = ""
|
||||
@State private var newPassword = ""
|
||||
@State private var confirm = ""
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage = ""
|
||||
@State private var success = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
||||
VStack(spacing: 16) {
|
||||
if success {
|
||||
VStack(spacing: 12) {
|
||||
Text("✅").font(.system(size: 50))
|
||||
Text("Пароль изменён!").font(.title2.bold()).foregroundColor(.white)
|
||||
}.padding(.top, 40)
|
||||
Button("Закрыть") { isPresented = false }.foregroundColor(Color(hex: "00d4aa"))
|
||||
} else {
|
||||
SecureField("Текущий пароль", text: $oldPassword)
|
||||
.padding().background(Color.white.opacity(0.08)).cornerRadius(12).foregroundColor(.white)
|
||||
SecureField("Новый пароль", text: $newPassword)
|
||||
.padding().background(Color.white.opacity(0.08)).cornerRadius(12).foregroundColor(.white)
|
||||
SecureField("Подтвердите пароль", text: $confirm)
|
||||
.padding().background(Color.white.opacity(0.08)).cornerRadius(12).foregroundColor(.white)
|
||||
if !errorMessage.isEmpty {
|
||||
Text(errorMessage).foregroundColor(.red).font(.caption)
|
||||
}
|
||||
Button(action: change) {
|
||||
if isLoading { ProgressView().tint(.black) }
|
||||
else { Text("Сменить").font(.headline).foregroundColor(.black) }
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding()
|
||||
.background(Color(hex: "00d4aa")).cornerRadius(12)
|
||||
.disabled(oldPassword.isEmpty || newPassword.isEmpty || isLoading)
|
||||
}
|
||||
Spacer()
|
||||
}.padding()
|
||||
}
|
||||
.navigationTitle("Смена пароля")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Отмена") { isPresented = false } } }
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
|
||||
func change() {
|
||||
guard newPassword == confirm else { errorMessage = "Пароли не совпадают"; return }
|
||||
guard newPassword.count >= 8 else { errorMessage = "Минимум 8 символов"; return }
|
||||
isLoading = true
|
||||
Task {
|
||||
var req = URLRequest(url: URL(string: "https://api.digital-home.site/auth/password")!)
|
||||
req.httpMethod = "PUT"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("Bearer \(authManager.token)", forHTTPHeaderField: "Authorization")
|
||||
req.httpBody = try? JSONEncoder().encode(["old_password": oldPassword, "new_password": newPassword])
|
||||
let (_, response) = (try? await URLSession.shared.data(for: req)) ?? (Data(), nil)
|
||||
await MainActor.run {
|
||||
if (response as? HTTPURLResponse)?.statusCode == 200 {
|
||||
success = true
|
||||
} else {
|
||||
errorMessage = "Неверный текущий пароль"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ struct AddTaskView: View {
|
||||
|
||||
@State private var title = ""
|
||||
@State private var description = ""
|
||||
@State private var priority: TaskPriority = .medium
|
||||
@State private var priority: Int = 2
|
||||
@State private var isLoading = false
|
||||
|
||||
private let priorities = [(1, "Низкий"), (2, "Средний"), (3, "Высокий"), (4, "Срочный")]
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
@@ -20,7 +22,7 @@ struct AddTaskView: View {
|
||||
TextField("Описание (необязательно)", text: $description)
|
||||
.padding().background(Color.white.opacity(0.08)).cornerRadius(12).foregroundColor(.white)
|
||||
Picker("Приоритет", selection: $priority) {
|
||||
ForEach(TaskPriority.allCases, id: \.self) { p in Text(p.displayName).tag(p) }
|
||||
ForEach(priorities, id: \.0) { p in Text(p.1).tag(p.0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
Spacer()
|
||||
|
||||
@@ -5,25 +5,20 @@ struct TaskRowView: View {
|
||||
let onComplete: () async -> Void
|
||||
|
||||
var priorityColor: Color {
|
||||
switch task.priority {
|
||||
case .urgent: return Color(hex: "ff0000")
|
||||
case .high: return Color(hex: "ff4757")
|
||||
case .medium: return Color(hex: "ffa502")
|
||||
default: return Color(hex: "8888aa")
|
||||
}
|
||||
Color(hex: task.priorityColor)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: { Task { await onComplete() } }) {
|
||||
Image(systemName: task.done ? "checkmark.circle.fill" : "circle")
|
||||
Image(systemName: task.completed ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title3)
|
||||
.foregroundColor(task.done ? Color(hex: "00d4aa") : Color(hex: "8888aa"))
|
||||
.foregroundColor(task.completed ? Color(hex: "00d4aa") : Color(hex: "8888aa"))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(task.title)
|
||||
.foregroundColor(task.done ? Color(hex: "8888aa") : .white)
|
||||
.strikethrough(task.done)
|
||||
.foregroundColor(task.completed ? Color(hex: "8888aa") : .white)
|
||||
.strikethrough(task.completed)
|
||||
.font(.callout)
|
||||
if let desc = task.description, !desc.isEmpty {
|
||||
Text(desc).font(.caption).foregroundColor(Color(hex: "8888aa")).lineLimit(1)
|
||||
@@ -33,7 +28,7 @@ struct TaskRowView: View {
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if let priority = task.priority, priority != .low {
|
||||
if let priority = task.priority, priority > 1 {
|
||||
Circle().fill(priorityColor).frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ struct TasksView: View {
|
||||
|
||||
var filteredTasks: [PulseTask] {
|
||||
switch filter {
|
||||
case .pending: return tasks.filter { !$0.done }
|
||||
case .completed: return tasks.filter { $0.done }
|
||||
case .pending: return tasks.filter { !$0.completed }
|
||||
case .completed: return tasks.filter { $0.completed }
|
||||
case .all: return tasks
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user