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

@@ -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)

View File

@@ -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"))
}
}
}

View File

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

View File

@@ -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)

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 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()

View File

@@ -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)
}
}

View File

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