fix: API field mapping, HealthKit entitlement, profile tab, forgot password

This commit is contained in:
Cosmo
2026-03-25 12:07:08 +00:00
parent bfb9a07d2d
commit 74805bc9d1
12 changed files with 266 additions and 46 deletions

View File

@@ -26,14 +26,16 @@ struct FinanceCategory: Codable, Identifiable {
struct FinanceSummary: Codable { struct FinanceSummary: Codable {
var totalIncome: Double? var totalIncome: Double?
var totalExpenses: Double? var totalExpense: Double?
var balance: Double? var balance: Double?
var carriedOver: Double?
var month: String? var month: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case totalIncome = "total_income"
case totalExpenses = "total_expenses"
case balance, month case balance, month
case totalIncome = "total_income"
case totalExpense = "total_expense"
case carriedOver = "carried_over"
} }
} }

View File

@@ -19,7 +19,8 @@ struct Habit: Codable, Identifiable {
var color: String? var color: String?
var frequency: HabitFrequency var frequency: HabitFrequency
var reminderTime: String? var reminderTime: String?
var targetDays: Int? var targetDays: [Int]?
var targetCount: Int?
var currentStreak: Int? var currentStreak: Int?
var longestStreak: Int? var longestStreak: Int?
var completedToday: Bool? var completedToday: Bool?
@@ -29,6 +30,7 @@ struct Habit: Codable, Identifiable {
case id, name, description, icon, color, frequency case id, name, description, icon, color, frequency
case reminderTime = "reminder_time" case reminderTime = "reminder_time"
case targetDays = "target_days" case targetDays = "target_days"
case targetCount = "target_count"
case currentStreak = "current_streak" case currentStreak = "current_streak"
case longestStreak = "longest_streak" case longestStreak = "longest_streak"
case completedToday = "completed_today" case completedToday = "completed_today"

View File

@@ -1,37 +1,38 @@
import Foundation 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 { struct PulseTask: Codable, Identifiable {
let id: Int let id: Int
var title: String var title: String
var description: String? var description: String?
var done: Bool var completed: Bool
var priority: TaskPriority? var priority: Int?
var icon: String?
var color: String?
var dueDate: String? var dueDate: String?
var reminderTime: String? var reminderTime: String?
var createdAt: 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 { enum CodingKeys: String, CodingKey {
case id, title, description, done, priority case id, title, description, completed, priority, icon, color
case dueDate = "due_date" case dueDate = "due_date"
case reminderTime = "reminder_time" case reminderTime = "reminder_time"
case createdAt = "created_at" case createdAt = "created_at"
@@ -41,7 +42,7 @@ struct PulseTask: Codable, Identifiable {
struct CreateTaskRequest: Codable { struct CreateTaskRequest: Codable {
var title: String var title: String
var description: String? var description: String?
var priority: TaskPriority? var priority: Int?
var dueDate: String? var dueDate: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {

View File

@@ -4,5 +4,9 @@
<dict> <dict>
<key>com.apple.developer.healthkit</key> <key>com.apple.developer.healthkit</key>
<true/> <true/>
<key>com.apple.developer.healthkit.access</key>
<array>
<string>health-records</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -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 completedHabitsToday: Int { habits.filter { $0.completedToday == true }.count }
var body: some View { var body: some View {
@@ -67,7 +67,7 @@ struct DashboardView: View {
StatCard(icon: "checkmark.circle.fill", value: "\(pendingTasks.count)", label: "Задач", color: "00d4aa") StatCard(icon: "checkmark.circle.fill", value: "\(pendingTasks.count)", label: "Задач", color: "00d4aa")
StatCard(icon: "flame.fill", value: "\(completedHabitsToday)/\(habits.count)", label: "Привычек", color: "ffa502") StatCard(icon: "flame.fill", value: "\(completedHabitsToday)/\(habits.count)", label: "Привычек", color: "ffa502")
if let s = summary, let balance = s.balance { 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) .padding(.horizontal)

View File

@@ -85,7 +85,7 @@ struct FinanceSummaryCard: View {
Spacer() Spacer()
VStack(spacing: 4) { VStack(spacing: 4) {
Text("Расходы").font(.caption).foregroundColor(Color(hex: "8888aa")) 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"))
} }
} }
} }

View File

@@ -9,6 +9,7 @@ struct LoginView: View {
@State private var errorMessage = "" @State private var errorMessage = ""
@State private var showPassword = false @State private var showPassword = false
@State private var isRegistering = false @State private var isRegistering = false
@State private var showForgotSheet = false
var body: some View { var body: some View {
ZStack { ZStack {
@@ -83,6 +84,14 @@ struct LoginView: View {
.cornerRadius(12) .cornerRadius(12)
.disabled(isLoading || email.isEmpty || password.isEmpty) .disabled(isLoading || email.isEmpty || password.isEmpty)
if !isRegistering {
Button(action: { showForgotSheet = true }) {
Text("Забыли пароль?")
.font(.footnote)
.foregroundColor(Color(hex: "00d4aa"))
}
}
// Toggle login/register // Toggle login/register
HStack { HStack {
Text(isRegistering ? "Уже есть аккаунт?" : "Нет аккаунта?") Text(isRegistering ? "Уже есть аккаунт?" : "Нет аккаунта?")
@@ -103,6 +112,9 @@ struct LoginView: View {
Spacer() Spacer()
} }
} }
.sheet(isPresented: $showForgotSheet) {
ForgotPasswordView(isPresented: $showForgotSheet)
}
} }
func login() { 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 }
}
}
}

View File

@@ -19,6 +19,9 @@ struct MainTabView: View {
FinanceView() FinanceView()
.tabItem { Label("Финансы", systemImage: "rublesign.circle.fill") } .tabItem { Label("Финансы", systemImage: "rublesign.circle.fill") }
ProfileView()
.tabItem { Label("Профиль", systemImage: "person.fill") }
} }
.accentColor(Color(hex: "00d4aa")) .accentColor(Color(hex: "00d4aa"))
.preferredColorScheme(.dark) .preferredColorScheme(.dark)

View 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
}
}
}
}

View File

@@ -7,9 +7,11 @@ struct AddTaskView: View {
@State private var title = "" @State private var title = ""
@State private var description = "" @State private var description = ""
@State private var priority: TaskPriority = .medium @State private var priority: Int = 2
@State private var isLoading = false @State private var isLoading = false
private let priorities = [(1, "Низкий"), (2, "Средний"), (3, "Высокий"), (4, "Срочный")]
var body: some View { var body: some View {
NavigationView { NavigationView {
ZStack { ZStack {
@@ -20,7 +22,7 @@ struct AddTaskView: View {
TextField("Описание (необязательно)", text: $description) TextField("Описание (необязательно)", text: $description)
.padding().background(Color.white.opacity(0.08)).cornerRadius(12).foregroundColor(.white) .padding().background(Color.white.opacity(0.08)).cornerRadius(12).foregroundColor(.white)
Picker("Приоритет", selection: $priority) { 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) .pickerStyle(.segmented)
Spacer() Spacer()

View File

@@ -5,25 +5,20 @@ struct TaskRowView: View {
let onComplete: () async -> Void let onComplete: () async -> Void
var priorityColor: Color { var priorityColor: Color {
switch task.priority { Color(hex: task.priorityColor)
case .urgent: return Color(hex: "ff0000")
case .high: return Color(hex: "ff4757")
case .medium: return Color(hex: "ffa502")
default: return Color(hex: "8888aa")
}
} }
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Button(action: { Task { await onComplete() } }) { Button(action: { Task { await onComplete() } }) {
Image(systemName: task.done ? "checkmark.circle.fill" : "circle") Image(systemName: task.completed ? "checkmark.circle.fill" : "circle")
.font(.title3) .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) { VStack(alignment: .leading, spacing: 4) {
Text(task.title) Text(task.title)
.foregroundColor(task.done ? Color(hex: "8888aa") : .white) .foregroundColor(task.completed ? Color(hex: "8888aa") : .white)
.strikethrough(task.done) .strikethrough(task.completed)
.font(.callout) .font(.callout)
if let desc = task.description, !desc.isEmpty { if let desc = task.description, !desc.isEmpty {
Text(desc).font(.caption).foregroundColor(Color(hex: "8888aa")).lineLimit(1) Text(desc).font(.caption).foregroundColor(Color(hex: "8888aa")).lineLimit(1)
@@ -33,7 +28,7 @@ struct TaskRowView: View {
} }
} }
Spacer() Spacer()
if let priority = task.priority, priority != .low { if let priority = task.priority, priority > 1 {
Circle().fill(priorityColor).frame(width: 8, height: 8) Circle().fill(priorityColor).frame(width: 8, height: 8)
} }
} }

View File

@@ -15,8 +15,8 @@ struct TasksView: View {
var filteredTasks: [PulseTask] { var filteredTasks: [PulseTask] {
switch filter { switch filter {
case .pending: return tasks.filter { !$0.done } case .pending: return tasks.filter { !$0.completed }
case .completed: return tasks.filter { $0.done } case .completed: return tasks.filter { $0.completed }
case .all: return tasks case .all: return tasks
} }
} }