Files
pulse-mobile/PulseHealth/Views/Habits/EditHabitView.swift
Daniil Klimov 28fca1de89 feat: major app overhaul — API fixes, glassmorphism UI, health dashboard, notifications
API Integration:
- Fix logHabit: send "date" instead of "completed_at"
- Fix FinanceCategory: "icon" → "emoji" to match API
- Fix task priorities: remove level 4, keep 1-3 matching API
- Fix habit frequencies: map monthly/interval → "custom" for API
- Add token refresh (401 → auto retry with new token)
- Add proper error handling (remove try? in save functions, show errors in UI)
- Add date field to savings transactions
- Add MonthlyPaymentDetail and OverduePayment models
- Fix habit completedToday: compute on client from logs (API doesn't return it)
- Filter habits by day of week on client (daily/weekly/monthly/interval)

Design System (glassmorphism):
- New DesignSystem.swift: Theme colors, GlassCard modifier, GlowIcon, GlowStatCard
- Custom tab bar with per-tab glow colors (VStack layout, not ZStack overlay)
- Deep dark background #06060f across all views
- Glass cards with gradient fill + stroke throughout app
- App icon: glassmorphism style with teal glow

Health Dashboard:
- Compact ReadinessBanner with recommendation text
- 8 metric tiles: sleep, HR, HRV, steps, SpO2, respiratory rate, energy, distance
- Each tile with status indicator (good/ok/bad) and hint text
- Heart rate card (min/avg/max)
- Weekly trends card (averages)
- Recovery score (weighted: 40% sleep, 35% HRV, 25% RHR)
- Tips card with actionable recommendations
- Sleep detail view with hypnogram (step chart of phases)
- Sleep segments timeline from HealthKit (deep/rem/core/awake with exact times)
- Line chart replacing bar chart for weekly data
- Collect respiratory_rate and sleep phases with timestamps from HealthKit
- Background sync every ~30min via BGProcessingTask

Notifications:
- NotificationService for local push notifications
- Morning/evening reminders with native DatePicker (wheel)
- Payment reminders: 5 days, 1 day, and day-of for recurring savings
- Notification settings in Settings tab

UI Fixes:
- Fix color picker overflow: HStack → LazyVGrid 5 columns
- Fix sheet headers: shorter text, proper padding
- Fix task/habit toggle: separate tap zones (checkbox vs edit)
- Fix deprecated onChange syntax for iOS 17+
- Savings overview: real monthly payments and detailed overdues from API
- Settings: timezone as Menu picker, removed Telegram/server notifications sections
- All sheets use .presentationDetents([.large])

Config:
- project.yml: real DEVELOPMENT_TEAM, HealthKit + BackgroundModes capabilities
- Info.plist: BGTaskScheduler + UIBackgroundModes
- Assets.xcassets with AppIcon
- CLAUDE.md project documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:15:36 +03:00

333 lines
19 KiB
Swift
Raw Permalink 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
struct EditHabitView: View {
@Binding var isPresented: Bool
@EnvironmentObject var authManager: AuthManager
let habit: Habit
let onSaved: () async -> Void
@State private var name: String
@State private var selectedIcon: String
@State private var selectedColor: String
@State private var frequency: HabitFrequency
@State private var selectedWeekdays: Set<Int>
@State private var intervalDays: String
@State private var isLoading = false
@State private var showArchiveConfirm = false
@State private var freezes: [HabitFreeze] = []
@State private var showAddFreeze = false
@State private var freezeStartDate = Date()
@State private var freezeEndDate = Date().addingTimeInterval(86400 * 7)
@State private var freezeReason = ""
let weekdayNames = ["Вс","Пн","Вт","Ср","Чт","Пт","Сб"]
let icons = ["🔥", "💪", "🏃", "📚", "💧", "🧘", "🎯", "⭐️", "🌟", "",
"🏋️", "🚴", "🍎", "😴", "🧠", "🎨", "🎵", "💊", "🌿", "💰",
"✍️", "🧹", "🏊", "🚶", "🎮", "📝", "🌅", "🥗", "🧃", "🫁"]
let colors = ["#0D9488", "#7c3aed", "#ff4757", "#ffa502", "#6366f1",
"#ec4899", "#14b8a6", "#f59e0b", "#10b981", "#3b82f6"]
let frequencies: [(HabitFrequency, String, String)] = [
(.daily, "Каждый день", "calendar"),
(.weekly, "По дням недели", "calendar.badge.clock"),
(.interval, "Каждые N дней", "repeat"),
(.monthly, "Каждый месяц", "calendar.badge.plus")
]
init(isPresented: Binding<Bool>, habit: Habit, onSaved: @escaping () async -> Void) {
self._isPresented = isPresented
self.habit = habit
self.onSaved = onSaved
self._name = State(initialValue: habit.name)
self._selectedIcon = State(initialValue: habit.icon ?? "🔥")
self._selectedColor = State(initialValue: habit.color ?? "#0D9488")
self._frequency = State(initialValue: habit.frequency)
self._selectedWeekdays = State(initialValue: Set(habit.targetDays ?? [1,2,3,4,5]))
self._intervalDays = State(initialValue: String(habit.targetCount ?? 2))
}
var body: some View {
ZStack {
Color(hex: "06060f").ignoresSafeArea()
VStack(spacing: 0) {
RoundedRectangle(cornerRadius: 3)
.fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
HStack {
Button("Отмена") { isPresented = false }
.font(.callout).foregroundColor(Color(hex: "8888aa"))
Spacer()
Text("Редактировать").font(.headline).foregroundColor(.white)
Spacer()
Button(action: save) {
if isLoading { ProgressView().tint(Theme.teal).scaleEffect(0.8) }
else { Text("Готово").font(.callout.bold()).foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Theme.teal) }
}.disabled(name.isEmpty || isLoading)
}
.padding(.horizontal, 16).padding(.vertical, 14)
Divider().background(Color.white.opacity(0.1))
ScrollView {
VStack(spacing: 20) {
// Preview
HStack(spacing: 16) {
ZStack {
Circle()
.fill(Color(hex: String(selectedColor.dropFirst())).opacity(0.25))
.frame(width: 56, height: 56)
Text(selectedIcon).font(.title2)
}
VStack(alignment: .leading, spacing: 4) {
Text(name.isEmpty ? "Название привычки" : name)
.font(.callout.bold())
.foregroundColor(name.isEmpty ? Color(hex: "8888aa") : .white)
Text(frequencies.first { $0.0 == frequency }?.1 ?? "")
.font(.caption).foregroundColor(Color(hex: "8888aa"))
}
Spacer()
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.05)))
// Name
VStack(alignment: .leading, spacing: 8) {
Label("Название", systemImage: "pencil").font(.caption).foregroundColor(Color(hex: "8888aa"))
TextField("Например: Читать 30 минут", text: $name)
.foregroundColor(.white).padding(14)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
}
// Frequency
VStack(alignment: .leading, spacing: 8) {
Label("Периодичность", systemImage: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa"))
VStack(spacing: 6) {
ForEach(frequencies, id: \.0) { f in
Button(action: { frequency = f.0 }) {
HStack {
Image(systemName: f.2).foregroundColor(frequency == f.0 ? Color(hex: "0D9488") : Color(hex: "8888aa"))
Text(f.1).foregroundColor(frequency == f.0 ? .white : Color(hex: "8888aa"))
Spacer()
if frequency == f.0 { Image(systemName: "checkmark").foregroundColor(Color(hex: "0D9488")) }
}
.padding(14)
.background(RoundedRectangle(cornerRadius: 12).fill(frequency == f.0 ? Color(hex: "0D9488").opacity(0.15) : Color.white.opacity(0.05)))
}
}
}
if frequency == .weekly {
HStack(spacing: 8) {
ForEach(0..<7) { i in
Button(action: {
if selectedWeekdays.contains(i) { if selectedWeekdays.count > 1 { selectedWeekdays.remove(i) } }
else { selectedWeekdays.insert(i) }
}) {
Text(weekdayNames[i])
.font(.caption.bold())
.foregroundColor(selectedWeekdays.contains(i) ? .black : .white)
.frame(width: 32, height: 32)
.background(Circle().fill(selectedWeekdays.contains(i) ? Color(hex: "0D9488") : Color.white.opacity(0.08)))
}
}
}
}
if frequency == .interval {
HStack {
Text("Каждые").foregroundColor(Color(hex: "8888aa")).font(.callout)
TextField("2", text: $intervalDays).keyboardType(.numberPad)
.foregroundColor(.white)
.frame(width: 50)
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07)))
Text("дней").foregroundColor(Color(hex: "8888aa")).font(.callout)
}
}
}
// Icon picker
VStack(alignment: .leading, spacing: 8) {
Label("Иконка", systemImage: "face.smiling").font(.caption).foregroundColor(Color(hex: "8888aa"))
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: 8) {
ForEach(icons, id: \.self) { icon in
Button(action: { selectedIcon = icon }) {
Text(icon).font(.title3)
.frame(width: 40, height: 40)
.background(Circle().fill(selectedIcon == icon ? Color(hex: "0D9488").opacity(0.25) : Color.white.opacity(0.05)))
.overlay(Circle().stroke(selectedIcon == icon ? Color(hex: "0D9488") : Color.clear, lineWidth: 2))
}
}
}
}
// Color picker
VStack(alignment: .leading, spacing: 8) {
Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa"))
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 10) {
ForEach(colors, id: \.self) { color in
Button(action: { selectedColor = color }) {
Circle()
.fill(Color(hex: String(color.dropFirst())))
.frame(width: 32, height: 32)
.overlay(Circle().stroke(.white, lineWidth: selectedColor == color ? 2 : 0))
.scaleEffect(selectedColor == color ? 1.15 : 1.0)
.animation(.easeInOut(duration: 0.15), value: selectedColor)
}
}
}
}
// Freezes
VStack(alignment: .leading, spacing: 10) {
HStack {
Label("Заморозки", systemImage: "snowflake").font(.caption).foregroundColor(Color(hex: "8888aa"))
Spacer()
Button(action: { showAddFreeze = true }) {
Image(systemName: "plus.circle.fill")
.foregroundColor(Color(hex: "0D9488"))
}
}
if freezes.isEmpty {
Text("Нет активных заморозок").font(.caption).foregroundColor(Color(hex: "8888aa"))
.padding(10)
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04)))
} else {
ForEach(freezes) { freeze in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("\(formatFreezeDate(freeze.startDate))\(formatFreezeDate(freeze.endDate))")
.font(.callout).foregroundColor(.white)
}
Spacer()
Button(action: { Task { await deleteFreeze(freeze) } }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(Color(hex: "ff4757").opacity(0.8))
}
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.05)))
}
}
if showAddFreeze {
VStack(spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Начало").font(.caption2).foregroundColor(Color(hex: "8888aa"))
DatePicker("", selection: $freezeStartDate, displayedComponents: .date)
.labelsHidden()
.colorInvert()
.colorMultiply(Color(hex: "0D9488"))
}
Spacer()
VStack(alignment: .leading, spacing: 4) {
Text("Конец").font(.caption2).foregroundColor(Color(hex: "8888aa"))
DatePicker("", selection: $freezeEndDate, in: freezeStartDate..., displayedComponents: .date)
.labelsHidden()
.colorInvert()
.colorMultiply(Color(hex: "0D9488"))
}
}
TextField("Причина (необязательно)", text: $freezeReason)
.foregroundColor(.white).padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07)))
HStack {
Button("Отмена") { showAddFreeze = false; freezeReason = "" }
.foregroundColor(Color(hex: "8888aa"))
Spacer()
Button("Добавить") { Task { await addFreeze() } }
.foregroundColor(Color(hex: "0D9488")).fontWeight(.semibold)
}
}
.padding(12)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.06)))
}
}
// Archive / Restore button
Button(action: { showArchiveConfirm = true }) {
HStack {
Image(systemName: habit.isArchived == true ? "arrow.uturn.backward" : "archivebox")
Text(habit.isArchived == true ? "Восстановить" : "Архивировать")
}
.foregroundColor(habit.isArchived == true ? Color(hex: "0D9488") : Color(hex: "ff4757"))
.frame(maxWidth: .infinity)
.padding(14)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05)))
}
}.padding(20)
}
}
}
.task { freezes = (try? await APIService.shared.getHabitFreezes(token: authManager.token, habitId: habit.id)) ?? [] }
.confirmationDialog(
habit.isArchived == true ? "Восстановить привычку?" : "Архивировать привычку?",
isPresented: $showArchiveConfirm,
titleVisibility: .visible
) {
Button(habit.isArchived == true ? "Восстановить" : "Архивировать",
role: habit.isArchived == true ? .none : .destructive) {
Task { await toggleArchive() }
}
Button("Отмена", role: .cancel) {}
}
}
func save() {
isLoading = true
Task {
let apiFrequency = (frequency == .interval || frequency == .monthly) ? "custom" : frequency.rawValue
var body: [String: Any] = [
"name": name,
"frequency": apiFrequency,
"icon": selectedIcon,
"color": selectedColor,
"target_count": 1
]
if frequency == .weekly {
body["target_days"] = Array(selectedWeekdays).sorted()
}
if frequency == .interval {
body["target_count"] = Int(intervalDays) ?? 2
}
if let reqBody = try? JSONSerialization.data(withJSONObject: body) {
try? await APIService.shared.updateHabit(token: authManager.token, id: habit.id, body: reqBody)
}
await onSaved()
await MainActor.run { isPresented = false }
}
}
func addFreeze() async {
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
let start = df.string(from: freezeStartDate)
let end = df.string(from: freezeEndDate)
if let freeze = try? await APIService.shared.createHabitFreeze(
token: authManager.token, habitId: habit.id,
startDate: start, endDate: end,
reason: freezeReason.isEmpty ? nil : freezeReason
) {
await MainActor.run {
freezes.append(freeze)
showAddFreeze = false
freezeReason = ""
}
}
}
func deleteFreeze(_ freeze: HabitFreeze) async {
try? await APIService.shared.deleteHabitFreeze(token: authManager.token, habitId: habit.id, freezeId: freeze.id)
await MainActor.run { freezes.removeAll { $0.id == freeze.id } }
}
func formatFreezeDate(_ s: String) -> String {
let parts = s.prefix(10).split(separator: "-")
guard parts.count == 3 else { return String(s.prefix(10)) }
return "\(parts[2]).\(parts[1]).\(parts[0])"
}
func toggleArchive() async {
let params: [String: Any] = ["is_archived": !(habit.isArchived == true)]
if let body = try? JSONSerialization.data(withJSONObject: params) {
try? await APIService.shared.updateHabit(token: authManager.token, id: habit.id, body: body)
}
await onSaved()
await MainActor.run { isPresented = false }
}
}