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>
This commit is contained in:
2026-04-05 23:15:36 +03:00
parent 1146965bcb
commit 28fca1de89
38 changed files with 3608 additions and 1031 deletions

View File

@@ -0,0 +1,169 @@
import UserNotifications
import Foundation
class NotificationService {
static let shared = NotificationService()
// MARK: - Permission
func requestPermission() async -> Bool {
do {
let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])
return granted
} catch {
return false
}
}
func isAuthorized() async -> Bool {
let settings = await UNUserNotificationCenter.current().notificationSettings()
return settings.authorizationStatus == .authorized
}
// MARK: - Morning Reminder
func scheduleMorningReminder(hour: Int, minute: Int) {
let content = UNMutableNotificationContent()
content.title = "Доброе утро!"
content.body = "Посмотри свои привычки и задачи на сегодня"
content.sound = .default
var components = DateComponents()
components.hour = hour
components.minute = minute
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
let request = UNNotificationRequest(identifier: "morning_reminder", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
}
// MARK: - Evening Reminder
func scheduleEveningReminder(hour: Int, minute: Int) {
let content = UNMutableNotificationContent()
content.title = "Итоги дня"
content.body = "Проверь, все ли привычки выполнены сегодня"
content.sound = .default
var components = DateComponents()
components.hour = hour
components.minute = minute
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
let request = UNNotificationRequest(identifier: "evening_reminder", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
}
// MARK: - Task Deadline Reminder
func scheduleTaskReminder(taskId: Int, title: String, dueDate: Date) {
let content = UNMutableNotificationContent()
content.title = "Задача скоро"
content.body = title
content.sound = .default
// За 1 час до дедлайна
let reminderDate = dueDate.addingTimeInterval(-3600)
guard reminderDate > Date() else { return }
let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: reminderDate)
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
let request = UNNotificationRequest(identifier: "task_\(taskId)", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
}
// MARK: - Habit Reminder
func scheduleHabitReminder(habitId: Int, name: String, hour: Int, minute: Int) {
let content = UNMutableNotificationContent()
content.title = "Привычка"
content.body = name
content.sound = .default
var components = DateComponents()
components.hour = hour
components.minute = minute
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
let request = UNNotificationRequest(identifier: "habit_\(habitId)", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request)
}
// MARK: - Cancel
func cancelReminder(_ identifier: String) {
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier])
}
func cancelAllReminders() {
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
}
// MARK: - Payment Reminders
func schedulePaymentReminders(payments: [MonthlyPaymentDetail]) {
cancelPaymentReminders()
let cal = Calendar.current
let now = Date()
for payment in payments {
let day = payment.day
guard day >= 1, day <= 28 else { continue }
// Build due date for this month
var components = cal.dateComponents([.year, .month], from: now)
components.day = day
components.hour = 11
components.minute = 0
guard let dueDate = cal.date(from: components) else { continue }
let offsets = [(-5, "через 5 дней"), (-1, "завтра"), (0, "сегодня")]
for (offset, label) in offsets {
guard let notifDate = cal.date(byAdding: .day, value: offset, to: dueDate),
notifDate > now else { continue }
let content = UNMutableNotificationContent()
content.title = "Платёж \(label)"
content.body = "\(payment.categoryName): \(Int(payment.amount)) ₽ — \(day) числа"
content.sound = .default
let trigger = UNCalendarNotificationTrigger(
dateMatching: cal.dateComponents([.year, .month, .day, .hour, .minute], from: notifDate),
repeats: false
)
let id = "payment_\(payment.categoryId)_\(offset)"
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: id, content: content, trigger: trigger))
}
}
}
func cancelPaymentReminders() {
let center = UNUserNotificationCenter.current()
center.getPendingNotificationRequests { requests in
let ids = requests.filter { $0.identifier.hasPrefix("payment_") }.map(\.identifier)
center.removePendingNotificationRequests(withIdentifiers: ids)
}
}
// MARK: - Update from settings
func updateSchedule(morning: Bool, morningTime: String, evening: Bool, eveningTime: String) {
cancelReminder("morning_reminder")
cancelReminder("evening_reminder")
if morning {
let parts = morningTime.split(separator: ":").compactMap { Int($0) }
if parts.count == 2 { scheduleMorningReminder(hour: parts[0], minute: parts[1]) }
}
if evening {
let parts = eveningTime.split(separator: ":").compactMap { Int($0) }
if parts.count == 2 { scheduleEveningReminder(hour: parts[0], minute: parts[1]) }
}
}
}