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:
@@ -23,6 +23,7 @@ enum APIError: Error, LocalizedError {
|
||||
class APIService {
|
||||
static let shared = APIService()
|
||||
let baseURL = "https://api.digital-home.site"
|
||||
weak var authManager: AuthManager?
|
||||
|
||||
private func makeRequest(_ path: String, method: String = "GET", token: String? = nil, body: Data? = nil) -> URLRequest {
|
||||
var req = URLRequest(url: URL(string: "\(baseURL)\(path)")!)
|
||||
@@ -38,6 +39,27 @@ class APIService {
|
||||
let req = makeRequest(path, method: method, token: token, body: body)
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else { throw APIError.networkError("Нет ответа") }
|
||||
if http.statusCode == 401, let auth = authManager, !auth.refreshToken.isEmpty, !path.contains("/auth/refresh") {
|
||||
// Try to refresh the token
|
||||
do {
|
||||
let refreshResp = try await refreshToken(refreshToken: auth.refreshToken)
|
||||
let newToken = refreshResp.authToken
|
||||
guard !newToken.isEmpty else { throw APIError.unauthorized }
|
||||
await MainActor.run { auth.updateTokens(accessToken: newToken, refreshToken: refreshResp.refreshToken) }
|
||||
// Retry original request with new token
|
||||
let retryReq = makeRequest(path, method: method, token: newToken, body: body)
|
||||
let (retryData, retryResp) = try await URLSession.shared.data(for: retryReq)
|
||||
guard let retryHttp = retryResp as? HTTPURLResponse else { throw APIError.networkError("Нет ответа") }
|
||||
if retryHttp.statusCode == 401 { throw APIError.unauthorized }
|
||||
if retryHttp.statusCode >= 400 {
|
||||
let msg = String(data: retryData, encoding: .utf8) ?? "Unknown"
|
||||
throw APIError.serverError(retryHttp.statusCode, msg)
|
||||
}
|
||||
return try JSONDecoder().decode(T.self, from: retryData)
|
||||
} catch {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
}
|
||||
if http.statusCode == 401 { throw APIError.unauthorized }
|
||||
if http.statusCode >= 400 {
|
||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown"
|
||||
@@ -46,7 +68,6 @@ class APIService {
|
||||
let decoder = JSONDecoder()
|
||||
do { return try decoder.decode(T.self, from: data) }
|
||||
catch {
|
||||
// Debug: print first 200 chars of response
|
||||
let snippet = String(data: data, encoding: .utf8)?.prefix(200) ?? ""
|
||||
throw APIError.decodingError("\(error.localizedDescription) | Response: \(snippet)")
|
||||
}
|
||||
@@ -68,6 +89,11 @@ class APIService {
|
||||
return try await fetch("/auth/me", token: token)
|
||||
}
|
||||
|
||||
func refreshToken(refreshToken: String) async throws -> RefreshResponse {
|
||||
let body = try JSONEncoder().encode(RefreshRequest(refreshToken: refreshToken))
|
||||
return try await fetch("/auth/refresh", method: "POST", body: body)
|
||||
}
|
||||
|
||||
// MARK: - Profile
|
||||
|
||||
func getProfile(token: String) async throws -> UserProfile {
|
||||
@@ -136,7 +162,7 @@ class APIService {
|
||||
|
||||
func logHabit(token: String, id: Int, date: String? = nil) async throws {
|
||||
var params: [String: Any] = [:]
|
||||
if let d = date { params["completed_at"] = d }
|
||||
if let d = date { params["date"] = d }
|
||||
let body = try JSONSerialization.data(withJSONObject: params)
|
||||
let _: EmptyResponse = try await fetch("/habits/\(id)/log", method: "POST", token: token, body: body)
|
||||
}
|
||||
@@ -229,6 +255,78 @@ class APIService {
|
||||
func deleteSavingsTransaction(token: String, id: Int) async throws {
|
||||
let _: EmptyResponse = try await fetch("/savings/transactions/\(id)", method: "DELETE", token: token)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateSavingsTransaction(token: String, id: Int, request: CreateSavingsTransactionRequest) async throws -> SavingsTransaction {
|
||||
let body = try JSONEncoder().encode(request)
|
||||
return try await fetch("/savings/transactions/\(id)", method: "PUT", token: token, body: body)
|
||||
}
|
||||
|
||||
// MARK: - Savings Recurring Plans
|
||||
|
||||
func getRecurringPlans(token: String, categoryId: Int) async throws -> [SavingsRecurringPlan] {
|
||||
return try await fetch("/savings/categories/\(categoryId)/recurring-plans", token: token)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func createRecurringPlan(token: String, categoryId: Int, request: CreateRecurringPlanRequest) async throws -> SavingsRecurringPlan {
|
||||
let body = try JSONEncoder().encode(request)
|
||||
return try await fetch("/savings/categories/\(categoryId)/recurring-plans", method: "POST", token: token, body: body)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateRecurringPlan(token: String, planId: Int, request: UpdateRecurringPlanRequest) async throws -> SavingsRecurringPlan {
|
||||
let body = try JSONEncoder().encode(request)
|
||||
return try await fetch("/savings/recurring-plans/\(planId)", method: "PUT", token: token, body: body)
|
||||
}
|
||||
|
||||
func deleteRecurringPlan(token: String, planId: Int) async throws {
|
||||
let _: EmptyResponse = try await fetch("/savings/recurring-plans/\(planId)", method: "DELETE", token: token)
|
||||
}
|
||||
|
||||
// MARK: - Habit Freezes
|
||||
|
||||
func getHabitFreezes(token: String, habitId: Int) async throws -> [HabitFreeze] {
|
||||
return try await fetch("/habits/\(habitId)/freezes", token: token)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func createHabitFreeze(token: String, habitId: Int, startDate: String, endDate: String, reason: String? = nil) async throws -> HabitFreeze {
|
||||
var params: [String: Any] = ["start_date": startDate, "end_date": endDate]
|
||||
if let r = reason { params["reason"] = r }
|
||||
let body = try JSONSerialization.data(withJSONObject: params)
|
||||
return try await fetch("/habits/\(habitId)/freezes", method: "POST", token: token, body: body)
|
||||
}
|
||||
|
||||
func deleteHabitFreeze(token: String, habitId: Int, freezeId: Int) async throws {
|
||||
let _: EmptyResponse = try await fetch("/habits/\(habitId)/freezes/\(freezeId)", method: "DELETE", token: token)
|
||||
}
|
||||
|
||||
// MARK: - Finance Categories CRUD
|
||||
|
||||
@discardableResult
|
||||
func createFinanceCategory(token: String, request: CreateFinanceCategoryRequest) async throws -> FinanceCategory {
|
||||
let body = try JSONEncoder().encode(request)
|
||||
return try await fetch("/finance/categories", method: "POST", token: token, body: body)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateFinanceCategory(token: String, id: Int, request: CreateFinanceCategoryRequest) async throws -> FinanceCategory {
|
||||
let body = try JSONEncoder().encode(request)
|
||||
return try await fetch("/finance/categories/\(id)", method: "PUT", token: token, body: body)
|
||||
}
|
||||
|
||||
func deleteFinanceCategory(token: String, id: Int) async throws {
|
||||
let _: EmptyResponse = try await fetch("/finance/categories/\(id)", method: "DELETE", token: token)
|
||||
}
|
||||
|
||||
// MARK: - Finance Transaction Update
|
||||
|
||||
@discardableResult
|
||||
func updateTransaction(token: String, id: Int, request: CreateTransactionRequest) async throws -> FinanceTransaction {
|
||||
let body = try JSONEncoder().encode(request)
|
||||
return try await fetch("/finance/transactions/\(id)", method: "PUT", token: token, body: body)
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyResponse: Codable {}
|
||||
|
||||
@@ -15,6 +15,7 @@ class HealthKitService: ObservableObject {
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
HKQuantityType(.oxygenSaturation),
|
||||
HKQuantityType(.distanceWalkingRunning),
|
||||
HKQuantityType(.respiratoryRate),
|
||||
HKCategoryType(.sleepAnalysis),
|
||||
]
|
||||
|
||||
@@ -116,15 +117,136 @@ class HealthKitService: ObservableObject {
|
||||
])
|
||||
}
|
||||
|
||||
// Respiratory Rate (дыхание)
|
||||
let rrValues = await fetchAllSamples(.respiratoryRate, unit: HKUnit.count().unitDivided(by: .minute()), predicate: predicate)
|
||||
if !rrValues.isEmpty {
|
||||
let rrData = rrValues.map { sample -> [String: Any] in
|
||||
["date": dateFormatter.string(from: sample.startDate), "qty": sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute()))]
|
||||
}
|
||||
metrics.append([
|
||||
"name": "respiratory_rate",
|
||||
"units": "breaths/min",
|
||||
"data": rrData
|
||||
])
|
||||
}
|
||||
|
||||
// Sleep Analysis — расширенный диапазон (последние 24 ча<EFBFBD><EFBFBD>а, чтобы захватить ночной сон)
|
||||
let sleepData = await fetchSleepData(dateFormatter: dateFormatter)
|
||||
if !sleepData.isEmpty {
|
||||
metrics.append([
|
||||
"name": "sleep_analysis",
|
||||
"units": "hr",
|
||||
"data": sleepData
|
||||
])
|
||||
}
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
// MARK: - Sleep Data Collection
|
||||
|
||||
private func fetchSleepData(dateFormatter: DateFormatter) async -> [[String: Any]] {
|
||||
guard let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return [] }
|
||||
|
||||
// Берём последние 24 часа, чтобы захватить ночной сон
|
||||
let now = Date()
|
||||
let yesterday = Calendar.current.date(byAdding: .hour, value: -24, to: now)!
|
||||
let sleepPredicate = HKQuery.predicateForSamples(withStart: yesterday, end: now)
|
||||
|
||||
return await withCheckedContinuation { cont in
|
||||
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
||||
let query = HKSampleQuery(sampleType: sleepType, predicate: sleepPredicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, _ in
|
||||
guard let samples = samples as? [HKCategorySample], !samples.isEmpty else {
|
||||
cont.resume(returning: [])
|
||||
return
|
||||
}
|
||||
|
||||
// Считаем время по каждой стадии сна в часах
|
||||
var deepSeconds = 0.0
|
||||
var remSeconds = 0.0
|
||||
var coreSeconds = 0.0
|
||||
var awakeSeconds = 0.0
|
||||
var asleepSeconds = 0.0
|
||||
var inBedStart: Date?
|
||||
var inBedEnd: Date?
|
||||
|
||||
for sample in samples {
|
||||
let duration = sample.endDate.timeIntervalSince(sample.startDate)
|
||||
|
||||
switch sample.value {
|
||||
case HKCategoryValueSleepAnalysis.inBed.rawValue:
|
||||
if inBedStart == nil || sample.startDate < inBedStart! { inBedStart = sample.startDate }
|
||||
if inBedEnd == nil || sample.endDate > inBedEnd! { inBedEnd = sample.endDate }
|
||||
case HKCategoryValueSleepAnalysis.asleepDeep.rawValue:
|
||||
deepSeconds += duration
|
||||
asleepSeconds += duration
|
||||
case HKCategoryValueSleepAnalysis.asleepREM.rawValue:
|
||||
remSeconds += duration
|
||||
asleepSeconds += duration
|
||||
case HKCategoryValueSleepAnalysis.asleepCore.rawValue:
|
||||
coreSeconds += duration
|
||||
asleepSeconds += duration
|
||||
case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue:
|
||||
asleepSeconds += duration
|
||||
case HKCategoryValueSleepAnalysis.awake.rawValue:
|
||||
awakeSeconds += duration
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Если нет inBed, берём из asleep-сэмплов
|
||||
if inBedStart == nil {
|
||||
let asleepSamples = samples.filter { $0.value != HKCategoryValueSleepAnalysis.awake.rawValue && $0.value != HKCategoryValueSleepAnalysis.inBed.rawValue }
|
||||
inBedStart = asleepSamples.first?.startDate
|
||||
inBedEnd = asleepSamples.last?.endDate
|
||||
}
|
||||
|
||||
let totalSleep = asleepSeconds / 3600.0
|
||||
guard totalSleep > 0 else {
|
||||
cont.resume(returning: [])
|
||||
return
|
||||
}
|
||||
|
||||
let startOfDay = Calendar.current.startOfDay(for: now)
|
||||
|
||||
// Формат, который ожидает health-webhook API
|
||||
var entry: [String: Any] = [
|
||||
"date": dateFormatter.string(from: startOfDay),
|
||||
"totalSleep": round(totalSleep * 1000) / 1000,
|
||||
"deep": round((deepSeconds / 3600.0) * 1000) / 1000,
|
||||
"rem": round((remSeconds / 3600.0) * 1000) / 1000,
|
||||
"core": round((coreSeconds / 3600.0) * 1000) / 1000,
|
||||
"awake": round((awakeSeconds / 3600.0) * 1000) / 1000,
|
||||
"asleep": round(totalSleep * 1000) / 1000,
|
||||
"source": "Apple Watch"
|
||||
]
|
||||
|
||||
if let start = inBedStart {
|
||||
entry["inBedStart"] = dateFormatter.string(from: start)
|
||||
entry["sleepStart"] = dateFormatter.string(from: start)
|
||||
}
|
||||
if let end = inBedEnd {
|
||||
entry["inBedEnd"] = dateFormatter.string(from: end)
|
||||
entry["sleepEnd"] = dateFormatter.string(from: end)
|
||||
}
|
||||
|
||||
cont.resume(returning: [entry])
|
||||
}
|
||||
self.healthStore.execute(query)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Send to Server
|
||||
|
||||
func syncToServer(apiKey: String) async throws {
|
||||
await MainActor.run { isSyncing = true }
|
||||
defer { Task { @MainActor in isSyncing = false } }
|
||||
|
||||
guard isAvailable else {
|
||||
throw HealthKitError.notAvailable
|
||||
}
|
||||
|
||||
try await requestAuthorization()
|
||||
|
||||
let metrics = await collectAllMetrics()
|
||||
@@ -151,11 +273,12 @@ class HealthKitService: ObservableObject {
|
||||
request.httpBody = jsonData
|
||||
request.timeoutInterval = 30
|
||||
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
(200...299).contains(httpResponse.statusCode) else {
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
throw HealthKitError.serverError(code)
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw HealthKitError.serverError(code, body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,20 +316,57 @@ class HealthKitService: ObservableObject {
|
||||
healthStore.execute(query)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sleep Segments (timeline)
|
||||
|
||||
func fetchSleepSegments() async -> [SleepSegment] {
|
||||
guard let sleepType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis) else { return [] }
|
||||
let now = Date()
|
||||
let yesterday = Calendar.current.date(byAdding: .hour, value: -24, to: now)!
|
||||
let predicate = HKQuery.predicateForSamples(withStart: yesterday, end: now)
|
||||
|
||||
return await withCheckedContinuation { cont in
|
||||
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
||||
let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [sort]) { _, samples, _ in
|
||||
guard let samples = samples as? [HKCategorySample] else {
|
||||
cont.resume(returning: [])
|
||||
return
|
||||
}
|
||||
|
||||
let segments: [SleepSegment] = samples.compactMap { sample in
|
||||
let phase: SleepPhaseType?
|
||||
switch sample.value {
|
||||
case HKCategoryValueSleepAnalysis.asleepDeep.rawValue: phase = .deep
|
||||
case HKCategoryValueSleepAnalysis.asleepREM.rawValue: phase = .rem
|
||||
case HKCategoryValueSleepAnalysis.asleepCore.rawValue: phase = .core
|
||||
case HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue: phase = .core
|
||||
case HKCategoryValueSleepAnalysis.awake.rawValue: phase = .awake
|
||||
default: phase = nil
|
||||
}
|
||||
guard let p = phase else { return nil }
|
||||
return SleepSegment(phase: p, start: sample.startDate, end: sample.endDate)
|
||||
}
|
||||
cont.resume(returning: segments)
|
||||
}
|
||||
self.healthStore.execute(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum HealthKitError: Error, LocalizedError {
|
||||
case notAvailable
|
||||
case noData
|
||||
case invalidURL
|
||||
case serverError(Int)
|
||||
case serverError(Int, String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noData: return "Нет данных HealthKit за сегодня"
|
||||
case .notAvailable: return "HealthKit недоступен на этом устройстве"
|
||||
case .noData: return "Нет данных HealthKit за сегодня. Убедитесь, что Apple Watch синхронизированы"
|
||||
case .invalidURL: return "Неверный URL сервера"
|
||||
case .serverError(let code): return "Ошибка сервера: \(code)"
|
||||
case .serverError(let code, let body): return "Ошибка сервера (\(code)): \(body.prefix(100))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
169
PulseHealth/Services/NotificationService.swift
Normal file
169
PulseHealth/Services/NotificationService.swift
Normal 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]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user