diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f930482 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,146 @@ +# Pulse Health — iOS App + +## Overview +iOS-приложение для управления жизнью: привычки, задачи, финансы, накопления, здоровье. Интегрируется с Apple Watch через HealthKit и серверным API. + +- **Bundle ID:** com.daniil.pulsehealth +- **Platform:** iOS 17+, SwiftUI, Swift 5.9 +- **Build:** XcodeGen (project.yml → .xcodeproj) +- **Team ID:** V9AG8JTFLC +- **Язык интерфейса:** Русский + +## Architecture + +### Project Structure +``` +PulseHealth/ +├── App.swift # Entry point, AuthManager, BGTask registration +├── Models/ +│ ├── AuthModels.swift # Login/Register/Refresh requests & responses +│ ├── UserModels.swift # UserProfile, UpdateProfileRequest +│ ├── HealthModels.swift # Readiness, Latest health, Heatmap, SleepSegment +│ ├── TaskModels.swift # PulseTask, CreateTask, UpdateTask (priorities 1-3) +│ ├── HabitModels.swift # Habit, HabitLog, HabitFreeze, HabitStats, HabitFrequency enum +│ ├── FinanceModels.swift # Transaction, Category (emoji field, not icon), Summary, Analytics +│ └── SavingsModels.swift # Category, Transaction, Stats, RecurringPlan, MonthlyPaymentDetail, OverduePayment +├── Services/ +│ ├── APIService.swift # Main REST client (api.digital-home.site), auto token refresh on 401 +│ ├── HealthAPIService.swift # Health API (health.digital-home.site), separate JWT auth +│ ├── HealthKitService.swift # HealthKit data collection & sync, sleep segments timeline +│ └── NotificationService.swift # Local push notifications (morning/evening, task deadlines) +├── Views/ +│ ├── DesignSystem.swift # Theme colors, GlassCard modifier, GlowIcon, GlowStatCard +│ ├── MainTabView.swift # Custom tab bar with glow effects (VStack layout, not ZStack) +│ ├── LoginView.swift # Auth + ForgotPassword +│ ├── Dashboard/ +│ │ └── DashboardView.swift # Home: progress, stats, habits, tasks, FAB +│ ├── Health/ +│ │ ├── HealthView.swift # Full health dashboard: readiness, metrics, HR, trends, recovery, tips +│ │ ├── MetricCardView.swift # SleepCard, StepsCard, SleepPhasesCard, InsightsCard, GradientIcon +│ │ ├── WeeklyChartView.swift # Line chart (not bar), animated +│ │ ├── SleepDetailView.swift # Detailed sleep timeline with phases from HealthKit +│ │ ├── ReadinessCardView.swift # (empty, replaced by ReadinessBanner in HealthView) +│ │ └── ToastView.swift # Toast notification modifier +│ ├── Tracker/ +│ │ └── TrackerView.swift # Tabs: Habits, Tasks, Statistics. Separate tap zones for edit vs toggle +│ ├── Tasks/ +│ │ ├── TasksView.swift +│ │ ├── AddTaskView.swift +│ │ ├── EditTaskView.swift +│ │ └── TaskRowView.swift +│ ├── Habits/ +│ │ ├── HabitsView.swift +│ │ ├── AddHabitView.swift +│ │ ├── EditHabitView.swift +│ │ └── HabitRowView.swift +│ ├── Finance/ +│ │ ├── FinanceView.swift # Overview, Transactions, Analytics, Categories tabs +│ │ └── AddTransactionView.swift +│ ├── Savings/ +│ │ ├── SavingsView.swift # Overview (monthly payments, overdues), Categories, Operations +│ │ └── EditSavingsCategoryView.swift +│ ├── Settings/ +│ │ └── SettingsView.swift # Appearance, Profile, Timezone (Menu picker) +│ └── Profile/ +│ └── ProfileView.swift # ChangePasswordView +``` + +## API Integration + +### Main API: `https://api.digital-home.site` +- Auth: JWT Bearer token with auto-refresh via `/auth/refresh` +- AuthManager stores token + refreshToken in UserDefaults +- APIService.authManager weak ref enables transparent 401 → refresh → retry + +**Endpoints used:** +- Auth: login, register, me, refresh +- Profile: GET/PUT /profile +- Tasks: CRUD + complete/uncomplete, priorities 1-3 (not 4!) +- Habits: CRUD + log (sends `date` not `completed_at`), freezes, stats +- Habits frequency: iOS uses daily/weekly/monthly/interval internally, sends `custom` to API for monthly/interval +- Finance: transactions, categories (field is `emoji` not `icon`), summary, analytics +- Savings: categories, transactions (date required!), stats (includes monthly_payment_details, overdues), recurring plans + +### Health API: `https://health.digital-home.site` +- Separate JWT auth (hardcoded credentials in HealthAPIService) +- API key for data sync: `health-cosmo-2026` +- Server code: `/Users/daniilklimov/Personal/health-webhook` (Node.js + SQLite) + +**Data flow:** +1. Apple Watch → HealthKit on iPhone +2. App collects from HealthKit → POST /api/health?key=API_KEY +3. health-webhook stores JSON files → parses on GET requests +4. App displays from GET /api/health/latest, /readiness, /heatmap + +**HealthKit metrics collected:** +- step_count, heart_rate, resting_heart_rate, heart_rate_variability +- active_energy (kcal→kJ), blood_oxygen_saturation, walking_running_distance +- respiratory_rate, sleep_analysis (with phase breakdown: deep/rem/core/awake + timestamps) + +**Sleep format for webhook:** +```json +{"totalSleep": 7.5, "deep": 1.2, "rem": 2.0, "core": 4.3, "awake": 0.5, + "inBedStart": "...", "sleepEnd": "...", "source": "Apple Watch"} +``` + +**API response field mapping (CodingKeys):** +- `spo2` → `bloodOxygen` (BloodOxygenData) +- `respiratoryRate` → as-is +- `distance` → as-is +- `activeEnergy` → as-is + +## Design System +- **Background:** `#06060f` (deep dark) +- **Accent:** `#0D9488` (teal) +- **Glass cards:** `.glassCard()` modifier — ultraThinMaterial + gradient fill + gradient stroke +- **Glow icons:** `GlowIcon` — circle with blur glow behind +- **Tab bar:** Custom VStack-based (not standard TabView), each tab has its own glow color +- **All sheets:** `.presentationDetents([.large])`, background `Color(hex: "06060f")` +- **Color pickers:** LazyVGrid 5 columns (not HStack — overflow on small screens) +- **App icon:** Glassmorphism style, Assets.xcassets/AppIcon.appiconset + +## Key Design Decisions & Gotchas +- **Buttons in ScrollView/List MUST have `.buttonStyle(.plain)`** — otherwise taps get swallowed +- **Tracker rows:** Separate tap zones — `.onTapGesture` on text area for edit, `Button` with `.buttonStyle(.plain)` for checkbox +- **`try?` is avoided in save functions** — errors are shown in UI via `@State errorMessage` +- **Tab bar uses VStack, not ZStack** — prevents content overlap +- **onChange uses iOS 17+ syntax:** `{ }` not `{ _ in }` +- **XcodeGen:** All capabilities must be in project.yml, manual Xcode changes get reset + +## Background Sync +- BGProcessingTask: `com.daniil.pulsehealth.healthsync` +- Scheduled every ~30 minutes +- Collects HealthKit data and POSTs to health-webhook +- Registered in App.init(), scheduled in .onAppear + +## External Services & Paths +- **Pulse API source:** `/Users/daniilklimov/digital-home/pulse-api` (Go) +- **Pulse Web source:** `/Users/daniilklimov/digital-home/pulse-web` (React) +- **Health webhook:** `/Users/daniilklimov/Personal/health-webhook` (Node.js) +- **Infrastructure docs:** `~/Obsidian/daniil/Инфраструктура` + +## Known Issues / TODO +- Finance tab is owner-only (user.id === 1) in web, no such restriction in iOS +- Savings members endpoints (multi-user) not implemented in iOS +- Auth: password change works via direct URLRequest, not through APIService +- Health readiness `activity.value` from API shows different step count than latest (different time periods) diff --git a/PulseHealth/App.swift b/PulseHealth/App.swift index a987a5b..5a35e06 100644 --- a/PulseHealth/App.swift +++ b/PulseHealth/App.swift @@ -1,4 +1,5 @@ import SwiftUI +import BackgroundTasks extension Color { init(hex: String) { @@ -20,14 +21,54 @@ extension Color { struct PulseApp: App { @StateObject private var authManager = AuthManager() + init() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.daniil.pulsehealth.healthsync", using: nil) { task in + Self.handleHealthSync(task: task as! BGProcessingTask) + } + } + var body: some Scene { WindowGroup { - if authManager.isLoggedIn { - MainTabView() - .environmentObject(authManager) - } else { - LoginView() - .environmentObject(authManager) + Group { + if authManager.isLoggedIn { + MainTabView() + } else { + LoginView() + } + } + .environmentObject(authManager) + .onAppear { + APIService.shared.authManager = authManager + Self.scheduleHealthSync() + } + } + } + + static func scheduleHealthSync() { + let request = BGProcessingTaskRequest(identifier: "com.daniil.pulsehealth.healthsync") + request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) // 30 минут + request.requiresNetworkConnectivity = true + try? BGTaskScheduler.shared.submit(request) + } + + static func handleHealthSync(task: BGProcessingTask) { + // Запланировать следующий синк + scheduleHealthSync() + + let syncTask = Task { + let service = HealthKitService() + let apiKey = UserDefaults.standard.string(forKey: "healthApiKey") ?? "health-cosmo-2026" + try await service.syncToServer(apiKey: apiKey) + } + + task.expirationHandler = { syncTask.cancel() } + + Task { + do { + try await syncTask.value + task.setTaskCompleted(success: true) + } catch { + task.setTaskCompleted(success: false) } } } @@ -36,31 +77,45 @@ struct PulseApp: App { class AuthManager: ObservableObject { @Published var isLoggedIn: Bool = false @Published var token: String = "" + @Published var refreshToken: String = "" @Published var userName: String = "" @Published var userId: Int = 0 @Published var healthApiKey: String = "health-cosmo-2026" init() { token = UserDefaults.standard.string(forKey: "pulseToken") ?? "" + refreshToken = UserDefaults.standard.string(forKey: "pulseRefreshToken") ?? "" userName = UserDefaults.standard.string(forKey: "userName") ?? "" userId = UserDefaults.standard.integer(forKey: "userId") healthApiKey = UserDefaults.standard.string(forKey: "healthApiKey") ?? "health-cosmo-2026" isLoggedIn = !token.isEmpty } - func login(token: String, user: UserInfo) { + func login(token: String, refreshToken: String? = nil, user: UserInfo) { self.token = token + self.refreshToken = refreshToken ?? "" self.userName = user.displayName self.userId = user.id UserDefaults.standard.set(token, forKey: "pulseToken") + if let rt = refreshToken { UserDefaults.standard.set(rt, forKey: "pulseRefreshToken") } UserDefaults.standard.set(user.displayName, forKey: "userName") UserDefaults.standard.set(user.id, forKey: "userId") isLoggedIn = true } + func updateTokens(accessToken: String, refreshToken: String?) { + self.token = accessToken + UserDefaults.standard.set(accessToken, forKey: "pulseToken") + if let rt = refreshToken { + self.refreshToken = rt + UserDefaults.standard.set(rt, forKey: "pulseRefreshToken") + } + } + func logout() { - token = ""; userName = ""; userId = 0 + token = ""; refreshToken = ""; userName = ""; userId = 0 UserDefaults.standard.removeObject(forKey: "pulseToken") + UserDefaults.standard.removeObject(forKey: "pulseRefreshToken") UserDefaults.standard.removeObject(forKey: "userName") UserDefaults.standard.removeObject(forKey: "userId") isLoggedIn = false diff --git a/PulseHealth/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/PulseHealth/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..7d5a135 Binary files /dev/null and b/PulseHealth/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/PulseHealth/Assets.xcassets/AppIcon.appiconset/Contents.json b/PulseHealth/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cefcc87 --- /dev/null +++ b/PulseHealth/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PulseHealth/Assets.xcassets/Contents.json b/PulseHealth/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/PulseHealth/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PulseHealth/Info.plist b/PulseHealth/Info.plist index 783e6c3..762b4c5 100644 --- a/PulseHealth/Info.plist +++ b/PulseHealth/Info.plist @@ -2,20 +2,40 @@ - CFBundleDevelopmentRegionru - CFBundleExecutable$(EXECUTABLE_NAME) - CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion6.0 - CFBundleName$(PRODUCT_NAME) - CFBundlePackageType$(PRODUCT_BUNDLE_TYPE) - CFBundleShortVersionString1.0 - CFBundleVersion1 - NSHealthShareUsageDescriptionДля отправки данных здоровья на ваш персональный дашборд - NSHealthUpdateUsageDescriptionДля записи данных тренировок - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UILaunchScreen + BGTaskSchedulerPermittedIdentifiers + + com.daniil.pulsehealth.healthsync + + CFBundleDevelopmentRegion + ru + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSHealthShareUsageDescription + Для отправки данных здоровья на ваш персональный дашборд + NSHealthUpdateUsageDescription + Для записи данных тренировок + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIBackgroundModes + + processing + + UILaunchScreen + diff --git a/PulseHealth/Models/AuthModels.swift b/PulseHealth/Models/AuthModels.swift index 7b8bf6c..e912c13 100644 --- a/PulseHealth/Models/AuthModels.swift +++ b/PulseHealth/Models/AuthModels.swift @@ -14,17 +14,41 @@ struct RegisterRequest: Codable { struct AuthResponse: Codable { let token: String? let accessToken: String? + let refreshToken: String? let user: UserInfo - + var authToken: String { token ?? accessToken ?? "" } - + enum CodingKeys: String, CodingKey { case token case accessToken = "access_token" + case refreshToken = "refresh_token" case user } } +struct RefreshRequest: Codable { + let refreshToken: String + + enum CodingKeys: String, CodingKey { + case refreshToken = "refresh_token" + } +} + +struct RefreshResponse: Codable { + let accessToken: String? + let refreshToken: String? + let token: String? + + var authToken: String { accessToken ?? token ?? "" } + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case token + } +} + struct UserInfo: Codable { let id: Int let email: String diff --git a/PulseHealth/Models/FinanceModels.swift b/PulseHealth/Models/FinanceModels.swift index c0da868..b131de5 100644 --- a/PulseHealth/Models/FinanceModels.swift +++ b/PulseHealth/Models/FinanceModels.swift @@ -10,6 +10,8 @@ struct FinanceTransaction: Codable, Identifiable { var type: String // "income" or "expense" var date: String? var createdAt: String? + var categoryName: String? + var categoryEmoji: String? var isIncome: Bool { type == "income" } @@ -26,6 +28,8 @@ struct FinanceTransaction: Codable, Identifiable { case id, amount, description, type, date case categoryId = "category_id" case createdAt = "created_at" + case categoryName = "category_name" + case categoryEmoji = "category_emoji" } } @@ -34,9 +38,16 @@ struct FinanceTransaction: Codable, Identifiable { struct FinanceCategory: Codable, Identifiable { let id: Int var name: String - var icon: String? + var emoji: String? var color: String? var type: String + var budget: Double? + var sortOrder: Int? + + enum CodingKeys: String, CodingKey { + case id, name, emoji, color, type, budget + case sortOrder = "sort_order" + } } // MARK: - FinanceSummary @@ -65,11 +76,11 @@ struct CategorySpend: Codable, Identifiable { var categoryId: Int? var categoryName: String? var total: Double? - var icon: String? + var emoji: String? var color: String? enum CodingKeys: String, CodingKey { - case total, icon, color + case total, emoji, color case categoryId = "category_id" case categoryName = "category_name" } @@ -111,3 +122,12 @@ struct CreateTransactionRequest: Codable { case categoryId = "category_id" } } + +// MARK: - CreateFinanceCategoryRequest + +struct CreateFinanceCategoryRequest: Codable { + var name: String + var type: String // "expense" or "income" + var emoji: String? + var budget: Double? +} diff --git a/PulseHealth/Models/HealthModels.swift b/PulseHealth/Models/HealthModels.swift index 88425fc..4b6a3d5 100644 --- a/PulseHealth/Models/HealthModels.swift +++ b/PulseHealth/Models/HealthModels.swift @@ -28,6 +28,33 @@ struct LatestHealthResponse: Codable { let hrv: HRVData? let steps: StepsData? let activeEnergy: EnergyData? + let bloodOxygen: BloodOxygenData? + let respiratoryRate: RespiratoryRateData? + let distance: DistanceData? + + enum CodingKeys: String, CodingKey { + case sleep, steps, hrv, distance + case heartRate = "heartRate" + case restingHeartRate = "restingHeartRate" + case activeEnergy = "activeEnergy" + case bloodOxygen = "spo2" + case respiratoryRate = "respiratoryRate" + } +} + +struct BloodOxygenData: Codable { + let avg: Double? + let min: Double? + let max: Double? +} + +struct RespiratoryRateData: Codable { + let avg: Double? +} + +struct DistanceData: Codable { + let total: Double? + let units: String? } struct SleepData: Codable { @@ -37,6 +64,42 @@ struct SleepData: Codable { let core: Double? } +// Local-only model for sleep timeline (not from API) +struct SleepSegment: Identifiable { + let id = UUID() + let phase: SleepPhaseType + let start: Date + let end: Date + var duration: TimeInterval { end.timeIntervalSince(start) } +} + +enum SleepPhaseType: String { + case deep = "Глубокий" + case rem = "REM" + case core = "Базовый" + case awake = "Пробуждение" + + var color: Color { + switch self { + case .deep: return Color(hex: "7c3aed") + case .rem: return Color(hex: "a78bfa") + case .core: return Color(hex: "c4b5fd") + case .awake: return Color(hex: "ff4757") + } + } + + var icon: String { + switch self { + case .deep: return "moon.zzz.fill" + case .rem: return "brain.head.profile" + case .core: return "moon.fill" + case .awake: return "eye.fill" + } + } +} + +import SwiftUI + struct HeartRateData: Codable { let avg: Int? let min: Int? diff --git a/PulseHealth/Models/SavingsModels.swift b/PulseHealth/Models/SavingsModels.swift index bc988c2..5b6c64f 100644 --- a/PulseHealth/Models/SavingsModels.swift +++ b/PulseHealth/Models/SavingsModels.swift @@ -107,8 +107,10 @@ struct SavingsStats: Codable { var totalWithdrawals: Double? var categoriesCount: Int? var monthlyPayments: Double? + var monthlyPaymentDetails: [MonthlyPaymentDetail]? var overdueAmount: Double? var overdueCount: Int? + var overdues: [OverduePayment]? enum CodingKeys: String, CodingKey { case totalBalance = "total_balance" @@ -116,8 +118,47 @@ struct SavingsStats: Codable { case totalWithdrawals = "total_withdrawals" case categoriesCount = "categories_count" case monthlyPayments = "monthly_payments" + case monthlyPaymentDetails = "monthly_payment_details" case overdueAmount = "overdue_amount" case overdueCount = "overdue_count" + case overdues + } +} + +struct MonthlyPaymentDetail: Codable, Identifiable { + var id: Int { categoryId } + var categoryId: Int + var categoryName: String + var amount: Double + var day: Int + + enum CodingKeys: String, CodingKey { + case categoryId = "category_id" + case categoryName = "category_name" + case amount, day + } +} + +struct OverduePayment: Codable, Identifiable { + var id: String { "\(categoryId)-\(month)" } + var categoryId: Int + var categoryName: String + var userId: Int? + var userName: String? + var amount: Double + var dueDay: Int + var daysOverdue: Int + var month: String + + enum CodingKeys: String, CodingKey { + case categoryId = "category_id" + case categoryName = "category_name" + case userId = "user_id" + case userName = "user_name" + case amount + case dueDay = "due_day" + case daysOverdue = "days_overdue" + case month } } @@ -133,3 +174,35 @@ struct CreateSavingsTransactionRequest: Codable { case categoryId = "category_id" } } + +// MARK: - SavingsRecurringPlan + +struct SavingsRecurringPlan: Codable, Identifiable { + let id: Int + var categoryId: Int? + var userId: Int? + var effective: String? + var amount: Double + var day: Int? + var createdAt: String? + + enum CodingKeys: String, CodingKey { + case id, amount, day + case categoryId = "category_id" + case userId = "user_id" + case effective + case createdAt = "created_at" + } +} + +struct CreateRecurringPlanRequest: Codable { + var effective: String + var amount: Double + var day: Int? +} + +struct UpdateRecurringPlanRequest: Codable { + var effective: String? + var amount: Double? + var day: Int? +} diff --git a/PulseHealth/Models/TaskModels.swift b/PulseHealth/Models/TaskModels.swift index 58e0210..bd5f286 100644 --- a/PulseHealth/Models/TaskModels.swift +++ b/PulseHealth/Models/TaskModels.swift @@ -16,10 +16,10 @@ struct PulseTask: Codable, Identifiable { var isRecurring: Bool? var recurrenceType: String? var recurrenceInterval: Int? + var recurrenceEndDate: String? var priorityColor: String { switch priority { - case 4: return "ff0000" case 3: return "ff4757" case 2: return "ffa502" default: return "8888aa" @@ -31,7 +31,6 @@ struct PulseTask: Codable, Identifiable { case 1: return "Низкий" case 2: return "Средний" case 3: return "Высокий" - case 4: return "Срочный" default: return "Без приоритета" } } @@ -64,6 +63,7 @@ struct PulseTask: Codable, Identifiable { case isRecurring = "is_recurring" case recurrenceType = "recurrence_type" case recurrenceInterval = "recurrence_interval" + case recurrenceEndDate = "recurrence_end_date" } } @@ -92,10 +92,18 @@ struct CreateTaskRequest: Codable { var dueDate: String? var icon: String? var color: String? + var isRecurring: Bool? + var recurrenceType: String? + var recurrenceInterval: Int? + var recurrenceEndDate: String? enum CodingKeys: String, CodingKey { case title, description, priority, icon, color case dueDate = "due_date" + case isRecurring = "is_recurring" + case recurrenceType = "recurrence_type" + case recurrenceInterval = "recurrence_interval" + case recurrenceEndDate = "recurrence_end_date" } } @@ -107,9 +115,19 @@ struct UpdateTaskRequest: Codable { var priority: Int? var dueDate: String? var completed: Bool? + var icon: String? + var color: String? + var isRecurring: Bool? + var recurrenceType: String? + var recurrenceInterval: Int? + var recurrenceEndDate: String? enum CodingKeys: String, CodingKey { - case title, description, priority, completed + case title, description, priority, completed, icon, color case dueDate = "due_date" + case isRecurring = "is_recurring" + case recurrenceType = "recurrence_type" + case recurrenceInterval = "recurrence_interval" + case recurrenceEndDate = "recurrence_end_date" } } diff --git a/PulseHealth/PulseHealth.entitlements b/PulseHealth/PulseHealth.entitlements index fe2df7e..54bc426 100644 --- a/PulseHealth/PulseHealth.entitlements +++ b/PulseHealth/PulseHealth.entitlements @@ -2,11 +2,9 @@ - com.apple.developer.healthkit - - com.apple.developer.healthkit.access - - health-records - + com.apple.developer.healthkit + + com.apple.developer.healthkit.background-delivery + diff --git a/PulseHealth/Services/APIService.swift b/PulseHealth/Services/APIService.swift index 3cf0e5f..04d61a4 100644 --- a/PulseHealth/Services/APIService.swift +++ b/PulseHealth/Services/APIService.swift @@ -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 {} diff --git a/PulseHealth/Services/HealthKitService.swift b/PulseHealth/Services/HealthKitService.swift index a1b7347..899f8f8 100644 --- a/PulseHealth/Services/HealthKitService.swift +++ b/PulseHealth/Services/HealthKitService.swift @@ -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 ча��а, чтобы захватить ночной сон) + 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))" } } } diff --git a/PulseHealth/Services/NotificationService.swift b/PulseHealth/Services/NotificationService.swift new file mode 100644 index 0000000..cbe3826 --- /dev/null +++ b/PulseHealth/Services/NotificationService.swift @@ -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]) } + } + } +} diff --git a/PulseHealth/Views/Dashboard/DashboardView.swift b/PulseHealth/Views/Dashboard/DashboardView.swift index 4eea994..d2ce136 100644 --- a/PulseHealth/Views/Dashboard/DashboardView.swift +++ b/PulseHealth/Views/Dashboard/DashboardView.swift @@ -41,8 +41,8 @@ struct DashboardView: View { var body: some View { ZStack(alignment: .bottomTrailing) { - Color(hex: "0a0a1a").ignoresSafeArea() - ScrollView { + Theme.bg.ignoresSafeArea() + ScrollView(showsIndicators: false) { VStack(spacing: 20) { // MARK: Header HStack { @@ -50,7 +50,7 @@ struct DashboardView: View { Text("\(greeting), \(authManager.userName)!") .font(.title2.bold()).foregroundColor(.white) Text(Date(), style: .date) - .font(.subheadline).foregroundColor(Color(hex: "8888aa")) + .font(.subheadline).foregroundColor(Theme.textSecondary) } Spacer() } @@ -58,51 +58,47 @@ struct DashboardView: View { .padding(.top) if isLoading { - ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) + ProgressView().tint(Theme.teal).padding(.top, 40) } else { // MARK: Day Progress - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 10) { HStack { Text("Прогресс дня") - .font(.subheadline).foregroundColor(Color(hex: "8888aa")) + .font(.subheadline.weight(.medium)).foregroundColor(Theme.textSecondary) Spacer() - Text("\(completedHabitsToday)/\(totalHabitsToday) привычек") - .font(.caption).foregroundColor(Color(hex: "0D9488")) + Text("\(completedHabitsToday)/\(totalHabitsToday)") + .font(.caption.bold()).foregroundColor(Theme.teal) } GeometryReader { geo in ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(Color.white.opacity(0.1)) - RoundedRectangle(cornerRadius: 4) - .fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .leading, endPoint: .trailing)) + RoundedRectangle(cornerRadius: 6) + .fill(Color.white.opacity(0.08)) + RoundedRectangle(cornerRadius: 6) + .fill(LinearGradient(colors: [Theme.teal, Theme.tealLight], startPoint: .leading, endPoint: .trailing)) .frame(width: geo.size.width * dayProgress) + .shadow(color: Theme.teal.opacity(0.5), radius: 8, y: 0) .animation(.easeInOut(duration: 0.5), value: dayProgress) } } .frame(height: 8) } + .padding(16) + .glassCard(cornerRadius: 16) .padding(.horizontal) // MARK: Stat Cards - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { - DashStatCard(icon: "checkmark.circle.fill", value: "\(completedHabitsToday)", label: "Выполнено сегодня", color: "0D9488") - DashStatCard(icon: "flame.fill", value: "\(habitsStats?.activeHabits ?? totalHabitsToday)", label: "Активных привычек", color: "ffa502") - DashStatCard(icon: "calendar", value: "\(todayTasks.count)", label: "Задач на сегодня", color: "6366f1") - DashStatCard(icon: "checkmark.seal.fill", value: "\(completedTodayTasksCount)", label: "Задач выполнено", color: "10b981") + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { + GlowStatCard(icon: "checkmark.circle.fill", value: "\(completedHabitsToday)", label: "Выполнено", color: Theme.teal) + GlowStatCard(icon: "flame.fill", value: "\(habitsStats?.activeHabits ?? totalHabitsToday)", label: "Активных", color: Theme.orange) + GlowStatCard(icon: "calendar", value: "\(todayTasks.count)", label: "Задач", color: Theme.indigo) + GlowStatCard(icon: "checkmark.seal.fill", value: "\(completedTodayTasksCount)", label: "Готово", color: Theme.green) } .padding(.horizontal) // MARK: Today's Habits if !todayHabits.isEmpty { VStack(alignment: .leading, spacing: 10) { - HStack { - Text("Привычки сегодня") - .font(.headline).foregroundColor(.white) - Spacer() - Text("\(completedHabitsToday)/\(totalHabitsToday)") - .font(.caption).foregroundColor(Color(hex: "8888aa")) - } - .padding(.horizontal) + SectionHeader(title: "Привычки", trailing: "\(completedHabitsToday)/\(totalHabitsToday)") ForEach(todayHabits) { habit in DashHabitRow( @@ -120,12 +116,12 @@ struct DashboardView: View { // MARK: Today's Tasks VStack(alignment: .leading, spacing: 10) { HStack { - Text("Задачи на сегодня") + Text("Задачи") .font(.headline).foregroundColor(.white) Spacer() Button(action: { addMode = .task; showAddSheet = true }) { Image(systemName: "plus.circle.fill") - .foregroundColor(Color(hex: "0D9488")) + .foregroundColor(Theme.teal).font(.title3) } } .padding(.horizontal) @@ -144,7 +140,7 @@ struct DashboardView: View { } } } - Spacer(minLength: 80) + Spacer(minLength: 100) } } .refreshable { await loadData(refresh: true) } @@ -153,27 +149,31 @@ struct DashboardView: View { Button(action: { addMode = .task; showAddSheet = true }) { ZStack { Circle() - .fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing)) + .fill(Theme.teal.opacity(0.3)) + .frame(width: 64, height: 64) + .blur(radius: 10) + Circle() + .fill(LinearGradient(colors: [Theme.teal, Theme.tealLight], startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(width: 56, height: 56) - .shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4) + .shadow(color: Theme.teal.opacity(0.5), radius: 12, y: 4) Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white) } } - .padding(.bottom, 90) + .padding(.bottom, 100) .padding(.trailing, 20) } .task { await loadData() } .sheet(isPresented: $showAddSheet) { if addMode == .task { AddTaskView(isPresented: $showAddSheet) { await loadData(refresh: true) } - .presentationDetents([.medium, .large]) + .presentationDetents([.large]) .presentationDragIndicator(.visible) - .presentationBackground(Color(hex: "0a0a1a")) + .presentationBackground(Theme.bg) } else { AddHabitView(isPresented: $showAddSheet) { await loadData(refresh: true) } .presentationDetents([.large]) .presentationDragIndicator(.visible) - .presentationBackground(Color(hex: "0a0a1a")) + .presentationBackground(Theme.bg) } } .alert("Ошибка", isPresented: $showError) { @@ -186,24 +186,63 @@ struct DashboardView: View { func loadData(refresh: Bool = false) async { if !refresh { isLoading = true } async let tasks = APIService.shared.getTodayTasks(token: authManager.token) - async let habits = APIService.shared.getHabits(token: authManager.token) + async let allHabits = APIService.shared.getHabits(token: authManager.token) async let stats = APIService.shared.getHabitsStats(token: authManager.token) todayTasks = (try? await tasks) ?? [] - todayHabits = (try? await habits) ?? [] habitsStats = try? await stats + + // Filter habits for today + check completion + var habits = ((try? await allHabits) ?? []).filter { !($0.isArchived ?? false) } + habits = filterHabitsForToday(habits) + + // Check which habits are completed today + let today = todayDateString() + for i in habits.indices { + let logs = (try? await APIService.shared.getHabitLogs(token: authManager.token, habitId: habits[i].id, days: 1)) ?? [] + habits[i].completedToday = logs.contains { $0.dateOnly == today } + } + todayHabits = habits isLoading = false } + func filterHabitsForToday(_ habits: [Habit]) -> [Habit] { + let weekday = Calendar.current.component(.weekday, from: Date()) - 1 // 0=Sun, 1=Mon...6=Sat + return habits.filter { habit in + switch habit.frequency { + case .daily: return true + case .weekly: + guard let days = habit.targetDays, !days.isEmpty else { return true } + return days.contains(weekday) + case .monthly: + let day = Calendar.current.component(.day, from: Date()) + return habit.targetCount == day || habit.targetDays?.contains(day) == true + case .interval: + // Show interval habits every N days from start + guard let startStr = habit.startDate ?? habit.createdAt, + let startDate = parseDate(startStr) else { return true } + let daysSince = Calendar.current.dateComponents([.day], from: startDate, to: Date()).day ?? 0 + let interval = max(habit.targetCount ?? 1, 1) + return daysSince % interval == 0 + case .custom: + // Custom habits: check target_days if set, otherwise show daily + guard let days = habit.targetDays, !days.isEmpty else { return true } + return days.contains(weekday) + } + } + } + + func parseDate(_ str: String) -> Date? { + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + return df.date(from: String(str.prefix(10))) + } + // MARK: - Actions func toggleHabit(_ habit: Habit) async { - if habit.completedToday == true { - // Already done — undo will handle it - return - } + if habit.completedToday == true { return } UIImpactFeedbackGenerator(style: .medium).impactOccurred() + let today = todayDateString() do { - let today = todayDateString() try await APIService.shared.logHabit(token: authManager.token, id: habit.id, date: today) recentlyLoggedHabitId = habit.id recentlyLoggedHabitLogDate = today @@ -225,7 +264,6 @@ struct DashboardView: View { func undoHabitLog(_ habit: Habit) async { UIImpactFeedbackGenerator(style: .light).impactOccurred() - // Get logs and find today's log to delete do { let logs = try await APIService.shared.getHabitLogs(token: authManager.token, habitId: habit.id, days: 1) let today = todayDateString() @@ -255,9 +293,7 @@ struct DashboardView: View { func undoTask(_ task: PulseTask) async { UIImpactFeedbackGenerator(style: .light).impactOccurred() - do { - try await APIService.shared.uncompleteTask(token: authManager.token, id: task.id) - } catch {} + do { try await APIService.shared.uncompleteTask(token: authManager.token, id: task.id) } catch {} recentlyCompletedTaskId = nil await loadData(refresh: true) } @@ -277,31 +313,6 @@ struct DashboardView: View { } } -// MARK: - DashStatCard - -struct DashStatCard: View { - let icon: String - let value: String - let label: String - let color: String - - var body: some View { - VStack(spacing: 8) { - Image(systemName: icon) - .foregroundColor(Color(hex: color)) - .font(.title2) - Text(value) - .font(.title3.bold()).foregroundColor(.white) - Text(label) - .font(.caption).foregroundColor(Color(hex: "8888aa")) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(16) - .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.05))) - } -} - // MARK: - DashHabitRow struct DashHabitRow: View { @@ -316,16 +327,23 @@ struct DashHabitRow: View { var body: some View { HStack(spacing: 14) { ZStack { - Circle().fill(accentColor.opacity(isDone ? 0.3 : 0.1)).frame(width: 44, height: 44) + if isDone { + Circle().fill(accentColor.opacity(0.2)).frame(width: 44, height: 44).blur(radius: 6) + } + Circle().fill(accentColor.opacity(isDone ? 0.2 : 0.08)).frame(width: 44, height: 44) Text(habit.displayIcon).font(.title3) } VStack(alignment: .leading, spacing: 3) { Text(habit.name) .font(.callout.weight(.medium)).foregroundColor(.white) HStack(spacing: 6) { - Text(habit.frequencyLabel).font(.caption).foregroundColor(Color(hex: "8888aa")) + Text(habit.frequencyLabel).font(.caption).foregroundColor(Theme.textSecondary) if let streak = habit.currentStreak, streak > 0 { - Text("🔥 \(streak)").font(.caption).foregroundColor(Color(hex: "ffa502")) + HStack(spacing: 2) { + Image(systemName: "flame.fill").font(.caption2) + Text("\(streak)") + } + .font(.caption).foregroundColor(Theme.orange) } } } @@ -333,22 +351,23 @@ struct DashHabitRow: View { if isUndoVisible { Button(action: { Task { await onUndo() } }) { Text("Отмена").font(.caption.bold()) - .foregroundColor(Color(hex: "ffa502")) + .foregroundColor(Theme.orange) .padding(.horizontal, 10).padding(.vertical, 6) - .background(RoundedRectangle(cornerRadius: 8).fill(Color(hex: "ffa502").opacity(0.15))) + .background(RoundedRectangle(cornerRadius: 8).fill(Theme.orange.opacity(0.15))) } } Button(action: { guard !isDone else { return }; Task { await onToggle() } }) { Image(systemName: isDone ? "checkmark.circle.fill" : "circle") .font(.title2) - .foregroundColor(isDone ? accentColor : Color(hex: "8888aa")) + .foregroundColor(isDone ? accentColor : Color(hex: "555566")) } + .buttonStyle(.plain) } .padding(14) - .background( + .glassCard(cornerRadius: 16) + .overlay( RoundedRectangle(cornerRadius: 16) - .fill(isDone ? accentColor.opacity(0.08) : Color.white.opacity(0.04)) - .overlay(RoundedRectangle(cornerRadius: 16).stroke(isDone ? accentColor.opacity(0.3) : Color.clear, lineWidth: 1)) + .stroke(isDone ? accentColor.opacity(0.3) : Color.clear, lineWidth: 1) ) .padding(.horizontal) .padding(.vertical, 2) @@ -368,25 +387,25 @@ struct DashTaskRow: View { Button(action: { Task { await onToggle() } }) { Image(systemName: task.completed ? "checkmark.circle.fill" : "circle") .font(.title3) - .foregroundColor(task.completed ? Color(hex: "0D9488") : Color(hex: "8888aa")) + .foregroundColor(task.completed ? Theme.teal : Color(hex: "555566")) } VStack(alignment: .leading, spacing: 3) { Text(task.title) - .foregroundColor(task.completed ? Color(hex: "8888aa") : .white) + .foregroundColor(task.completed ? Theme.textSecondary : .white) .strikethrough(task.completed) .font(.callout) HStack(spacing: 6) { if let due = task.dueDateFormatted { Text(due) .font(.caption2) - .foregroundColor(task.isOverdue ? Color(hex: "ff4757") : Color(hex: "ffa502")) + .foregroundColor(task.isOverdue ? Theme.red : Theme.orange) } if let p = task.priority, p > 1 { Circle().fill(Color(hex: task.priorityColor)).frame(width: 6, height: 6) } if task.isRecurring == true { Image(systemName: "arrow.clockwise") - .font(.caption2).foregroundColor(Color(hex: "8888aa")) + .font(.caption2).foregroundColor(Theme.textSecondary) } } } @@ -394,14 +413,14 @@ struct DashTaskRow: View { if isUndoVisible { Button(action: { Task { await onUndo() } }) { Text("Отмена").font(.caption.bold()) - .foregroundColor(Color(hex: "ffa502")) + .foregroundColor(Theme.orange) .padding(.horizontal, 10).padding(.vertical, 6) - .background(RoundedRectangle(cornerRadius: 8).fill(Color(hex: "ffa502").opacity(0.15))) + .background(RoundedRectangle(cornerRadius: 8).fill(Theme.orange.opacity(0.15))) } } } .padding(12) - .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05))) + .glassCard(cornerRadius: 14) .padding(.horizontal) .padding(.vertical, 2) } @@ -415,7 +434,7 @@ struct EmptyState: View { var body: some View { VStack(spacing: 8) { Image(systemName: icon).font(.system(size: 32)).foregroundColor(Color(hex: "334155")) - Text(text).font(.subheadline).foregroundColor(Color(hex: "8888aa")) + Text(text).font(.subheadline).foregroundColor(Theme.textSecondary) } .frame(maxWidth: .infinity) .padding(.vertical, 24) diff --git a/PulseHealth/Views/DesignSystem.swift b/PulseHealth/Views/DesignSystem.swift new file mode 100644 index 0000000..135f356 --- /dev/null +++ b/PulseHealth/Views/DesignSystem.swift @@ -0,0 +1,135 @@ +import SwiftUI + +// MARK: - Theme Colors + +enum Theme { + static let bg = Color(hex: "06060f") + static let cardBg = Color.white.opacity(0.06) + static let cardBorder = Color.white.opacity(0.08) + static let teal = Color(hex: "0D9488") + static let tealLight = Color(hex: "14b8a6") + static let textPrimary = Color.white + static let textSecondary = Color(hex: "6b7280") + static let red = Color(hex: "ff4757") + static let orange = Color(hex: "ffa502") + static let purple = Color(hex: "7c3aed") + static let blue = Color(hex: "3b82f6") + static let pink = Color(hex: "ec4899") + static let green = Color(hex: "10b981") + static let indigo = Color(hex: "6366f1") +} + +// MARK: - Glass Card Modifier + +struct GlassCard: ViewModifier { + var cornerRadius: CGFloat = 20 + func body(content: Content) -> some View { + content + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.ultraThinMaterial.opacity(0.3)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .fill( + LinearGradient( + colors: [Color.white.opacity(0.08), Color.white.opacity(0.02)], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .stroke( + LinearGradient( + colors: [Color.white.opacity(0.15), Color.white.opacity(0.03)], + startPoint: .topLeading, endPoint: .bottomTrailing + ), + lineWidth: 1 + ) + ) + ) + } +} + +extension View { + func glassCard(cornerRadius: CGFloat = 20) -> some View { + modifier(GlassCard(cornerRadius: cornerRadius)) + } +} + +// MARK: - Glow Icon View + +struct GlowIcon: View { + let systemName: String + let color: Color + var size: CGFloat = 44 + var iconSize: Font = .body + + var body: some View { + ZStack { + // Glow + Circle() + .fill(color.opacity(0.25)) + .frame(width: size * 1.2, height: size * 1.2) + .blur(radius: 12) + + // Icon circle + Circle() + .fill(color.opacity(0.15)) + .frame(width: size, height: size) + + Image(systemName: systemName) + .font(iconSize) + .foregroundColor(color) + } + } +} + +// MARK: - Glow Stat Card + +struct GlowStatCard: View { + let icon: String + let value: String + let label: String + let color: Color + + var body: some View { + VStack(spacing: 10) { + GlowIcon(systemName: icon, color: color, size: 40, iconSize: .title3) + Text(value) + .font(.title3.bold().monospacedDigit()) + .foregroundColor(.white) + Text(label) + .font(.caption) + .foregroundColor(Theme.textSecondary) + .multilineTextAlignment(.center) + .lineLimit(2) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + .padding(.horizontal, 8) + .glassCard(cornerRadius: 18) + } +} + +// MARK: - Section Header + +struct SectionHeader: View { + let title: String + var trailing: String? = nil + + var body: some View { + HStack { + Text(title) + .font(.headline) + .foregroundColor(.white) + Spacer() + if let t = trailing { + Text(t) + .font(.caption) + .foregroundColor(Theme.textSecondary) + } + } + .padding(.horizontal) + } +} diff --git a/PulseHealth/Views/Finance/AddTransactionView.swift b/PulseHealth/Views/Finance/AddTransactionView.swift index 524d087..9ff204f 100644 --- a/PulseHealth/Views/Finance/AddTransactionView.swift +++ b/PulseHealth/Views/Finance/AddTransactionView.swift @@ -5,19 +5,21 @@ struct AddTransactionView: View { @EnvironmentObject var authManager: AuthManager let categories: [FinanceCategory] let onAdded: () async -> Void - + @State private var amount = "" @State private var description = "" @State private var type = "expense" @State private var selectedCategoryId: Int? = nil + @State private var date = Date() @State private var isLoading = false + @State private var errorMessage: String? var filteredCategories: [FinanceCategory] { categories.filter { $0.type == type } } var isExpense: Bool { type == "expense" } var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + Color(hex: "06060f").ignoresSafeArea() VStack(spacing: 0) { // Handle @@ -91,7 +93,16 @@ struct AddTransactionView: View { .foregroundColor(.white).padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) } - + + // Date + VStack(alignment: .leading, spacing: 8) { + Label("Дата", systemImage: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa")) + DatePicker("", selection: $date, displayedComponents: .date) + .labelsHidden() + .colorInvert() + .colorMultiply(Color(hex: "0D9488")) + } + // Categories if !filteredCategories.isEmpty { VStack(alignment: .leading, spacing: 8) { @@ -100,7 +111,7 @@ struct AddTransactionView: View { ForEach(filteredCategories) { cat in Button(action: { selectedCategoryId = selectedCategoryId == cat.id ? nil : cat.id }) { HStack(spacing: 6) { - Text(cat.icon ?? "").font(.callout) + Text(cat.emoji ?? "").font(.callout) Text(cat.name).font(.caption).lineLimit(1) } .foregroundColor(selectedCategoryId == cat.id ? .black : .white) @@ -115,21 +126,38 @@ struct AddTransactionView: View { } } } + if let err = errorMessage { + Text(err) + .font(.caption).foregroundColor(Color(hex: "ff4757")) + .padding(10) + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(hex: "ff4757").opacity(0.1))) + } } .padding(20) } } } } - + func save() { guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")) else { return } isLoading = true + errorMessage = nil + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + let dateStr = df.string(from: date) Task { - let req = CreateTransactionRequest(amount: a, categoryId: selectedCategoryId, description: description.isEmpty ? nil : description, type: type) - try? await APIService.shared.createTransaction(token: authManager.token, request: req) - await onAdded() - await MainActor.run { isPresented = false } + do { + let req = CreateTransactionRequest(amount: a, categoryId: selectedCategoryId, description: description.isEmpty ? nil : description, type: type, date: dateStr) + try await APIService.shared.createTransaction(token: authManager.token, request: req) + await onAdded() + await MainActor.run { isPresented = false } + } catch { + await MainActor.run { + errorMessage = error.localizedDescription + isLoading = false + } + } } } } diff --git a/PulseHealth/Views/Finance/FinanceView.swift b/PulseHealth/Views/Finance/FinanceView.swift index 1418e11..d0f2981 100644 --- a/PulseHealth/Views/Finance/FinanceView.swift +++ b/PulseHealth/Views/Finance/FinanceView.swift @@ -11,7 +11,7 @@ struct FinanceView: View { var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + Color(hex: "06060f").ignoresSafeArea() VStack(spacing: 0) { // Header with month picker HStack { @@ -37,6 +37,7 @@ struct FinanceView: View { Text("Обзор").tag(0) Text("Транзакции").tag(1) Text("Аналитика").tag(2) + Text("Категории").tag(3) } .pickerStyle(.segmented) .padding(.horizontal) @@ -45,7 +46,8 @@ struct FinanceView: View { switch selectedTab { case 0: FinanceOverviewTab(month: selectedMonth, year: selectedYear) case 1: FinanceTransactionsTab(month: selectedMonth, year: selectedYear) - default: FinanceAnalyticsTab(month: selectedMonth, year: selectedYear) + case 2: FinanceAnalyticsTab(month: selectedMonth, year: selectedYear) + default: FinanceCategoriesTab() } } } @@ -105,7 +107,7 @@ struct FinanceOverviewTab: View { let pct = (cat.total ?? 0) / max(total, 1) VStack(spacing: 4) { HStack { - Text(cat.icon ?? "💸").font(.subheadline) + Text(cat.emoji ?? "💸").font(.subheadline) Text(cat.categoryName ?? "—").font(.callout).foregroundColor(.white) Spacer() Text(formatAmt(cat.total ?? 0)).font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) @@ -193,8 +195,8 @@ struct FinanceOverviewTab: View { .padding(.top, 8) } .task { await load() } - .onChange(of: month) { _ in Task { await load() } } - .onChange(of: year) { _ in Task { await load() } } + .onChange(of: month) { Task { await load() } } + .onChange(of: year) { Task { await load() } } .refreshable { await load(refresh: true) } } @@ -266,6 +268,7 @@ struct FinanceTransactionsTab: View { @State private var categories: [FinanceCategory] = [] @State private var isLoading = true @State private var showAdd = false + @State private var editingTransaction: FinanceTransaction? var groupedByDay: [(key: String, value: [FinanceTransaction])] { let grouped = Dictionary(grouping: transactions) { $0.dateOnly } @@ -291,6 +294,7 @@ struct FinanceTransactionsTab: View { FinanceTxRow(transaction: tx, categories: categories) .listRowBackground(Color.clear) .listRowSeparator(.hidden) + .onTapGesture { editingTransaction = tx } } .onDelete { idx in let toDelete = idx.map { section.value[$0] } @@ -321,13 +325,22 @@ struct FinanceTransactionsTab: View { .padding(.trailing, 20) } .task { await load() } - .onChange(of: month) { _ in Task { await load() } } - .onChange(of: year) { _ in Task { await load() } } + .onChange(of: month) { Task { await load() } } + .onChange(of: year) { Task { await load() } } .sheet(isPresented: $showAdd) { AddTransactionView(isPresented: $showAdd, categories: categories) { await load(refresh: true) } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) - .presentationBackground(Color(hex: "0a0a1a")) + .presentationBackground(Color(hex: "06060f")) + } + .sheet(item: $editingTransaction) { tx in + EditTransactionView(isPresented: .constant(true), transaction: tx, categories: categories) { + editingTransaction = nil + await load(refresh: true) + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(hex: "06060f")) } } @@ -360,7 +373,7 @@ struct FinanceTxRow: View { Circle() .fill((isIncome ? Color(hex: "0D9488") : Color(hex: "ff4757")).opacity(0.12)) .frame(width: 40, height: 40) - Text(cat?.icon ?? (isIncome ? "💰" : "💸")).font(.title3) + Text(cat?.emoji ?? (isIncome ? "💰" : "💸")).font(.title3) } VStack(alignment: .leading, spacing: 2) { Text(transaction.description ?? cat?.name ?? "Операция") @@ -434,8 +447,8 @@ struct FinanceAnalyticsTab: View { .padding(.top, 8) } .task { await load() } - .onChange(of: month) { _ in Task { await load() } } - .onChange(of: year) { _ in Task { await load() } } + .onChange(of: month) { Task { await load() } } + .onChange(of: year) { Task { await load() } } .refreshable { await load(refresh: true) } } @@ -481,3 +494,346 @@ struct MonthComparisonCard: View { } func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) } } + +// MARK: - EditTransactionView + +struct EditTransactionView: View { + @Binding var isPresented: Bool + @EnvironmentObject var authManager: AuthManager + let transaction: FinanceTransaction + let categories: [FinanceCategory] + let onSaved: () async -> Void + + @State private var amount: String + @State private var description: String + @State private var type: String + @State private var selectedCategoryId: Int? + @State private var date: Date + @State private var isLoading = false + + var filteredCategories: [FinanceCategory] { categories.filter { $0.type == type } } + var isExpense: Bool { type == "expense" } + + init(isPresented: Binding, transaction: FinanceTransaction, categories: [FinanceCategory], onSaved: @escaping () async -> Void) { + self._isPresented = isPresented + self.transaction = transaction + self.categories = categories + self.onSaved = onSaved + self._amount = State(initialValue: String(format: "%.0f", transaction.amount)) + self._description = State(initialValue: transaction.description ?? "") + self._type = State(initialValue: transaction.type) + self._selectedCategoryId = State(initialValue: transaction.categoryId) + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + let d = transaction.date.flatMap { df.date(from: String($0.prefix(10))) } ?? Date() + self._date = State(initialValue: d) + } + + 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 }.foregroundColor(Color(hex: "8888aa")) + Spacer() + Text("Редактировать").font(.headline).foregroundColor(.white) + Spacer() + Button(action: save) { + if isLoading { ProgressView().tint(Color(hex: "00d4aa")).scaleEffect(0.8) } + else { Text("Сохранить").foregroundColor(amount.isEmpty ? Color(hex: "8888aa") : Color(hex: "00d4aa")).fontWeight(.semibold) } + }.disabled(amount.isEmpty || isLoading) + } + .padding(.horizontal, 20).padding(.vertical, 16) + Divider().background(Color.white.opacity(0.1)) + ScrollView { + VStack(spacing: 20) { + HStack(spacing: 0) { + Button(action: { type = "expense" }) { + Text("Расход").font(.callout.bold()) + .foregroundColor(isExpense ? .black : Color(hex: "ff4757")) + .frame(maxWidth: .infinity).padding(.vertical, 12) + .background(isExpense ? Color(hex: "ff4757") : Color.clear) + } + Button(action: { type = "income" }) { + Text("Доход").font(.callout.bold()) + .foregroundColor(!isExpense ? .black : Color(hex: "00d4aa")) + .frame(maxWidth: .infinity).padding(.vertical, 12) + .background(!isExpense ? Color(hex: "00d4aa") : Color.clear) + } + } + .background(Color.white.opacity(0.07)).cornerRadius(12) + + HStack { + Text(isExpense ? "−" : "+").font(.title.bold()) + .foregroundColor(isExpense ? Color(hex: "ff4757") : Color(hex: "00d4aa")) + TextField("0", text: $amount).keyboardType(.decimalPad) + .font(.system(size: 36, weight: .bold)).foregroundColor(.white).multilineTextAlignment(.center) + Text("₽").font(.title.bold()).foregroundColor(Color(hex: "8888aa")) + } + .padding(20) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.07))) + + VStack(alignment: .leading, spacing: 8) { + Label("Описание", systemImage: "text.alignleft").font(.caption).foregroundColor(Color(hex: "8888aa")) + TextField("Комментарий...", text: $description) + .foregroundColor(.white).padding(14) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) + } + + VStack(alignment: .leading, spacing: 8) { + Label("Дата", systemImage: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa")) + DatePicker("", selection: $date, displayedComponents: .date) + .labelsHidden().colorInvert().colorMultiply(Color(hex: "0D9488")) + } + + if !filteredCategories.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Label("Категория", systemImage: "tag.fill").font(.caption).foregroundColor(Color(hex: "8888aa")) + LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 8) { + ForEach(filteredCategories) { cat in + Button(action: { selectedCategoryId = selectedCategoryId == cat.id ? nil : cat.id }) { + HStack(spacing: 6) { + Text(cat.emoji ?? "").font(.callout) + Text(cat.name).font(.caption).lineLimit(1) + } + .foregroundColor(selectedCategoryId == cat.id ? .black : .white) + .padding(.horizontal, 10).padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 10) + .fill(selectedCategoryId == cat.id ? Color(hex: "00d4aa") : Color.white.opacity(0.07))) + } + } + } + } + } + }.padding(20) + } + } + } + } + + func save() { + guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")) else { return } + isLoading = true + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + let dateStr = df.string(from: date) + Task { + let req = CreateTransactionRequest(amount: a, categoryId: selectedCategoryId, + description: description.isEmpty ? nil : description, + type: type, date: dateStr) + try? await APIService.shared.updateTransaction(token: authManager.token, id: transaction.id, request: req) + await onSaved() + await MainActor.run { isPresented = false } + } + } +} + +// MARK: - FinanceCategoriesTab + +struct FinanceCategoriesTab: View { + @EnvironmentObject var authManager: AuthManager + @State private var categories: [FinanceCategory] = [] + @State private var isLoading = true + @State private var editingCategory: FinanceCategory? + @State private var showAdd = false + @State private var selectedType = "expense" + + var filtered: [FinanceCategory] { categories.filter { $0.type == selectedType } } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + ScrollView { + VStack(spacing: 12) { + HStack(spacing: 0) { + Button(action: { selectedType = "expense" }) { + Text("Расходы").font(.callout.bold()) + .foregroundColor(selectedType == "expense" ? .black : Color(hex: "ff4757")) + .frame(maxWidth: .infinity).padding(.vertical, 10) + .background(selectedType == "expense" ? Color(hex: "ff4757") : Color.clear) + } + Button(action: { selectedType = "income" }) { + Text("Доходы").font(.callout.bold()) + .foregroundColor(selectedType == "income" ? .black : Color(hex: "0D9488")) + .frame(maxWidth: .infinity).padding(.vertical, 10) + .background(selectedType == "income" ? Color(hex: "0D9488") : Color.clear) + } + } + .background(Color.white.opacity(0.07)).cornerRadius(12) + .padding(.horizontal) + + if isLoading { + ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40) + } else if filtered.isEmpty { + EmptyState(icon: "tag", text: "Нет категорий") + } else { + ForEach(filtered) { cat in + HStack(spacing: 12) { + ZStack { + Circle().fill(Color(hex: selectedType == "expense" ? "ff4757" : "0D9488").opacity(0.15)) + .frame(width: 40, height: 40) + Text(cat.emoji ?? (selectedType == "expense" ? "💸" : "💰")).font(.title3) + } + Text(cat.name).font(.callout).foregroundColor(.white) + Spacer() + Button(action: { editingCategory = cat }) { + Image(systemName: "pencil").foregroundColor(Color(hex: "8888aa")) + } + Button(action: { Task { await deleteCategory(cat) } }) { + Image(systemName: "trash").foregroundColor(Color(hex: "ff4757").opacity(0.7)) + } + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.05))) + .padding(.horizontal) + } + } + Spacer(minLength: 80) + } + .padding(.top, 8) + } + .task { await load() } + .refreshable { await load(refresh: true) } + + Button(action: { showAdd = true }) { + ZStack { + Circle() + .fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: 56, height: 56) + .shadow(color: Color(hex: "0D9488").opacity(0.4), radius: 8, y: 4) + Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white) + } + } + .padding(.bottom, 90).padding(.trailing, 20) + } + .sheet(isPresented: $showAdd) { + FinanceCategoryFormView(isPresented: $showAdd, category: nil, defaultType: selectedType) { await load(refresh: true) } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(hex: "06060f")) + } + .sheet(item: $editingCategory) { cat in + FinanceCategoryFormView(isPresented: .constant(true), category: cat, defaultType: selectedType) { + editingCategory = nil + await load(refresh: true) + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(hex: "06060f")) + } + } + + func load(refresh: Bool = false) async { + if !refresh { isLoading = true } + categories = (try? await APIService.shared.getFinanceCategories(token: authManager.token)) ?? [] + isLoading = false + } + + func deleteCategory(_ cat: FinanceCategory) async { + try? await APIService.shared.deleteFinanceCategory(token: authManager.token, id: cat.id) + await load(refresh: true) + } +} + +// MARK: - FinanceCategoryFormView + +struct FinanceCategoryFormView: View { + @Binding var isPresented: Bool + @EnvironmentObject var authManager: AuthManager + let category: FinanceCategory? + let defaultType: String + let onSaved: () async -> Void + + @State private var name = "" + @State private var type: String + @State private var emoji = "" + @State private var isLoading = false + + let emojis = ["💸","💰","🏠","🍔","🚗","🎓","💊","✈️","👗","🎮","📱","🛒","⚡","🐾","🎵","💄","🍺","🎁","🏋️","📚"] + + init(isPresented: Binding, category: FinanceCategory?, defaultType: String, onSaved: @escaping () async -> Void) { + self._isPresented = isPresented + self.category = category + self.defaultType = defaultType + self.onSaved = onSaved + self._name = State(initialValue: category?.name ?? "") + self._type = State(initialValue: category?.type ?? defaultType) + self._emoji = State(initialValue: category?.emoji ?? "") + } + + 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 }.foregroundColor(Color(hex: "8888aa")) + Spacer() + Text(category == nil ? "Новая категория" : "Редактировать").font(.headline).foregroundColor(.white) + Spacer() + Button(action: save) { + if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) } + else { Text("Сохранить").foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) } + }.disabled(name.isEmpty || isLoading) + } + .padding(.horizontal, 20).padding(.vertical, 16) + Divider().background(Color.white.opacity(0.1)) + ScrollView { + VStack(spacing: 16) { + HStack(spacing: 0) { + Button(action: { type = "expense" }) { + Text("Расход").font(.callout.bold()) + .foregroundColor(type == "expense" ? .black : Color(hex: "ff4757")) + .frame(maxWidth: .infinity).padding(.vertical, 10) + .background(type == "expense" ? Color(hex: "ff4757") : Color.clear) + } + Button(action: { type = "income" }) { + Text("Доход").font(.callout.bold()) + .foregroundColor(type == "income" ? .black : Color(hex: "0D9488")) + .frame(maxWidth: .infinity).padding(.vertical, 10) + .background(type == "income" ? Color(hex: "0D9488") : Color.clear) + } + } + .background(Color.white.opacity(0.07)).cornerRadius(12) + + VStack(alignment: .leading, spacing: 8) { + Label("Название", systemImage: "pencil").font(.caption).foregroundColor(Color(hex: "8888aa")) + TextField("Название категории", text: $name) + .foregroundColor(.white).padding(14) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) + } + + VStack(alignment: .leading, spacing: 8) { + Label("Иконка", systemImage: "face.smiling").font(.caption).foregroundColor(Color(hex: "8888aa")) + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 8) { + ForEach(emojis, id: \.self) { e in + Button(action: { emoji = e }) { + Text(e).font(.title3) + .frame(width: 44, height: 44) + .background(Circle().fill(emoji == e ? Color(hex: "0D9488").opacity(0.25) : Color.white.opacity(0.05))) + .overlay(Circle().stroke(emoji == e ? Color(hex: "0D9488") : Color.clear, lineWidth: 2)) + } + } + } + } + }.padding(20) + } + } + } + } + + func save() { + isLoading = true + Task { + let req = CreateFinanceCategoryRequest(name: name, type: type, emoji: emoji.isEmpty ? nil : emoji, budget: nil) + if let cat = category { + try? await APIService.shared.updateFinanceCategory(token: authManager.token, id: cat.id, request: req) + } else { + try? await APIService.shared.createFinanceCategory(token: authManager.token, request: req) + } + await onSaved() + await MainActor.run { isPresented = false } + } + } +} diff --git a/PulseHealth/Views/Habits/AddHabitView.swift b/PulseHealth/Views/Habits/AddHabitView.swift index 0b82646..8880304 100644 --- a/PulseHealth/Views/Habits/AddHabitView.swift +++ b/PulseHealth/Views/Habits/AddHabitView.swift @@ -32,21 +32,22 @@ struct AddHabitView: View { var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + 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 }.foregroundColor(Color(hex: "8888aa")) + Button("Отмена") { isPresented = false } + .font(.callout).foregroundColor(Color(hex: "8888aa")) Spacer() Text("Новая привычка").font(.headline).foregroundColor(.white) Spacer() Button(action: save) { - if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) } - else { Text("Добавить").foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) } + 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, 20).padding(.vertical, 16) + .padding(.horizontal, 16).padding(.vertical, 14) Divider().background(Color.white.opacity(0.1)) ScrollView { VStack(spacing: 20) { @@ -144,7 +145,7 @@ struct AddHabitView: View { // Color picker VStack(alignment: .leading, spacing: 8) { Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa")) - HStack(spacing: 10) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 10) { ForEach(colors, id: \.self) { color in Button(action: { selectedColor = color }) { Circle() @@ -166,10 +167,11 @@ struct AddHabitView: View { func save() { isLoading = true Task { + let apiFrequency = (frequency == "interval" || frequency == "monthly") ? "custom" : frequency var body: [String: Any] = [ "name": name, "description": description, - "frequency": frequency, + "frequency": apiFrequency, "icon": selectedIcon, "color": selectedColor, "target_count": 1 diff --git a/PulseHealth/Views/Habits/EditHabitView.swift b/PulseHealth/Views/Habits/EditHabitView.swift index 238ab2e..f2cd2c5 100644 --- a/PulseHealth/Views/Habits/EditHabitView.swift +++ b/PulseHealth/Views/Habits/EditHabitView.swift @@ -14,6 +14,11 @@ struct EditHabitView: View { @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 = ["🔥", "💪", "🏃", "📚", "💧", "🧘", "🎯", "⭐️", "🌟", "✅", @@ -42,21 +47,22 @@ struct EditHabitView: View { var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + 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 }.foregroundColor(Color(hex: "8888aa")) + Button("Отмена") { isPresented = false } + .font(.callout).foregroundColor(Color(hex: "8888aa")) Spacer() - Text("Редактировать привычку").font(.headline).foregroundColor(.white) + Text("Редактировать").font(.headline).foregroundColor(.white) Spacer() Button(action: save) { - if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) } - else { Text("Сохранить").foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) } + 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, 20).padding(.vertical, 16) + .padding(.horizontal, 16).padding(.vertical, 14) Divider().background(Color.white.opacity(0.1)) ScrollView { VStack(spacing: 20) { @@ -152,7 +158,7 @@ struct EditHabitView: View { // Color picker VStack(alignment: .leading, spacing: 8) { Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa")) - HStack(spacing: 10) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 10) { ForEach(colors, id: \.self) { color in Button(action: { selectedColor = color }) { Circle() @@ -166,6 +172,73 @@ struct EditHabitView: View { } } + // 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 { @@ -181,6 +254,7 @@ struct EditHabitView: View { } } } + .task { freezes = (try? await APIService.shared.getHabitFreezes(token: authManager.token, habitId: habit.id)) ?? [] } .confirmationDialog( habit.isArchived == true ? "Восстановить привычку?" : "Архивировать привычку?", isPresented: $showArchiveConfirm, @@ -197,9 +271,10 @@ struct EditHabitView: View { func save() { isLoading = true Task { + let apiFrequency = (frequency == .interval || frequency == .monthly) ? "custom" : frequency.rawValue var body: [String: Any] = [ "name": name, - "frequency": frequency.rawValue, + "frequency": apiFrequency, "icon": selectedIcon, "color": selectedColor, "target_count": 1 @@ -218,6 +293,34 @@ struct EditHabitView: View { } } + 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) { diff --git a/PulseHealth/Views/Habits/HabitsView.swift b/PulseHealth/Views/Habits/HabitsView.swift index 5a092f4..9b028a3 100644 --- a/PulseHealth/Views/Habits/HabitsView.swift +++ b/PulseHealth/Views/Habits/HabitsView.swift @@ -12,7 +12,7 @@ struct HabitsView: View { var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + Color(hex: "06060f").ignoresSafeArea() VStack(spacing: 0) { HStack { VStack(alignment: .leading) { @@ -59,7 +59,7 @@ struct HabitsView: View { .sheet(isPresented: $showAddHabit) { AddHabitView(isPresented: $showAddHabit) { await loadHabits(refresh: true) } .presentationDetents([.large]).presentationDragIndicator(.visible) - .presentationBackground(Color(hex: "0a0a1a")) + .presentationBackground(Color(hex: "06060f")) } } diff --git a/PulseHealth/Views/Health/HealthView.swift b/PulseHealth/Views/Health/HealthView.swift index 2c6b4c6..6e24d28 100644 --- a/PulseHealth/Views/Health/HealthView.swift +++ b/PulseHealth/Views/Health/HealthView.swift @@ -8,212 +8,523 @@ struct HealthView: View { @State private var latest: LatestHealthResponse? @State private var heatmapData: [HeatmapEntry] = [] @State private var isLoading = true - - // Toast state @State private var showToast = false @State private var toastMessage = "" @State private var toastSuccess = true - - var greeting: String { - let hour = Calendar.current.component(.hour, from: Date()) - switch hour { - case 5..<12: return "Доброе утро" - case 12..<17: return "Добрый день" - case 17..<22: return "Добрый вечер" - default: return "Доброй ночи" - } - } + @State private var showSleepDetail = false var dateString: String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "ru_RU") - formatter.dateFormat = "d MMMM, EEEE" - return formatter.string(from: Date()) + let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU"); f.dateFormat = "d MMMM, EEEE" + return f.string(from: Date()) } var body: some View { ZStack { - Color(hex: "0a0a1a") - .ignoresSafeArea() - + Color(hex: "06060f").ignoresSafeArea() ScrollView(showsIndicators: false) { - VStack(spacing: 20) { + VStack(spacing: 16) { // MARK: - Header - headerView - .padding(.top, 8) + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Здоровье").font(.title2.bold()).foregroundColor(.white) + Text(dateString).font(.subheadline).foregroundColor(Theme.textSecondary) + } + Spacer() + Button { Task { await syncHealthKit() } } label: { + ZStack { + Circle().fill(Color(hex: "1a1a3e")).frame(width: 42, height: 42) + if healthKit.isSyncing { ProgressView().tint(Theme.teal).scaleEffect(0.8) } + else { Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 16, weight: .medium)).foregroundColor(Theme.teal) } + } + }.disabled(healthKit.isSyncing) + } + .padding(.horizontal).padding(.top, 8) if isLoading { - loadingView + ProgressView().tint(Theme.teal).padding(.top, 60) } else { // MARK: - Readiness - if let r = readiness { - ReadinessCardView(readiness: r) + if let r = readiness { ReadinessBanner(readiness: r) } + + // MARK: - Core Metrics 2x2 + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { + Button { showSleepDetail = true } label: { + HealthMetricTile(icon: "moon.fill", title: "Сон", + value: String(format: "%.1f", latest?.sleep?.totalSleep ?? 0), unit: "ч", + status: sleepStatus, color: Theme.purple, + hint: "Норма 7-9ч. Нажми для деталей") + }.buttonStyle(.plain) + + HealthMetricTile(icon: "heart.fill", title: "Пульс покоя", + value: "\(Int(latest?.restingHeartRate?.value ?? 0))", unit: "уд/м", + status: rhrStatus, color: Theme.red, + hint: "Чем ниже, тем лучше. Норма 50-70") + + HealthMetricTile(icon: "waveform.path.ecg", title: "HRV", + value: "\(Int(latest?.hrv?.avg ?? 0))", unit: "мс", + status: hrvStatus, color: Theme.teal, + hint: "Вариабельность пульса. Выше = лучше") + + HealthMetricTile(icon: "figure.walk", title: "Шаги", + value: fmtNum(latest?.steps?.total ?? 0), unit: "", + status: stepsStatus, color: Theme.orange, + hint: "Цель: 8 000 шагов в день") + } + .padding(.horizontal) + + // MARK: - Secondary Metrics + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { + if let spo2 = latest?.bloodOxygen, (spo2.avg ?? 0) > 0 { + HealthMetricTile(icon: "lungs.fill", title: "Кислород", + value: "\(Int(spo2.avg ?? 0))", unit: "%", + status: spo2Status, color: Theme.blue, + hint: "Насыщение крови O₂. Норма ≥ 96%") + } + if let rr = latest?.respiratoryRate, (rr.avg ?? 0) > 0 { + HealthMetricTile(icon: "wind", title: "Дыхание", + value: String(format: "%.0f", rr.avg ?? 0), unit: "вд/м", + status: rrStatus, color: Theme.indigo, + hint: "Частота дыхания. Норма 12-20") + } + if let energy = latest?.activeEnergy, (energy.total ?? 0) > 0 { + HealthMetricTile(icon: "flame.fill", title: "Энергия", + value: "\(energy.total ?? 0)", unit: energy.units == "kJ" ? "кДж" : "ккал", + status: energyStatus, color: Color(hex: "ff6348"), + hint: "Активные калории за день") + } + if let dist = latest?.distance, (dist.total ?? 0) > 0 { + HealthMetricTile(icon: "map.fill", title: "Дистанция", + value: String(format: "%.1f", (dist.total ?? 0) / 1000), unit: "км", + status: distStatus, color: Theme.green, + hint: "Пройдено пешком и бегом") + } + } + .padding(.horizontal) + + // MARK: - Heart Rate Card + if let hr = latest?.heartRate, (hr.avg ?? 0) > 0 { + HeartRateCard(hr: hr, rhr: latest?.restingHeartRate) } - // MARK: - Metrics Grid - metricsGrid + // MARK: - Weekly Trends + if heatmapData.count >= 2 { + WeeklyTrendsCard(heatmapData: heatmapData) + } // MARK: - Weekly Chart if !heatmapData.isEmpty { WeeklyChartCard(heatmapData: heatmapData) } - // MARK: - Insights - InsightsCard(readiness: readiness, latest: latest) + // MARK: - Recovery Score + RecoveryCard(sleep: latest?.sleep, hrv: latest?.hrv, rhr: latest?.restingHeartRate) + + // MARK: - Tips + TipsCard(readiness: readiness, latest: latest) Spacer(minLength: 30) } } } - .refreshable { - await loadData(refresh: true) - } + .refreshable { await loadData(refresh: true) } } .toast(isShowing: $showToast, message: toastMessage, isSuccess: toastSuccess) - .task { await loadData() } - } - - // MARK: - Header - - private var headerView: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Здоровье 🫀") - .font(.title2.bold()) - .foregroundColor(.white) - - Text(dateString) - .font(.subheadline) - .foregroundColor(Color(hex: "8888aa")) - } - - Spacer() - - // Sync button - Button { - Task { await syncHealthKit() } - } label: { - ZStack { - Circle() - .fill(Color(hex: "1a1a3e")) - .frame(width: 42, height: 42) - - if healthKit.isSyncing { - ProgressView() - .tint(Color(hex: "00d4aa")) - .scaleEffect(0.8) - } else { - Image(systemName: "arrow.triangle.2.circlepath") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(Color(hex: "00d4aa")) - } - } - } - .disabled(healthKit.isSyncing) - } - } - .padding(.horizontal) - } - - // MARK: - Loading - - private var loadingView: some View { - VStack(spacing: 16) { - ProgressView() - .tint(Color(hex: "00d4aa")) - .scaleEffect(1.2) - - Text("Загрузка данных...") - .font(.subheadline) - .foregroundColor(Color(hex: "8888aa")) - } - .padding(.top, 80) - } - - // MARK: - Metrics Grid - - private var metricsGrid: some View { - LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { + .sheet(isPresented: $showSleepDetail) { if let sleep = latest?.sleep { - SleepCard(sleep: sleep) - .frame(maxHeight: .infinity) - } - - if let rhr = latest?.restingHeartRate { - MetricCardView( - icon: "heart.fill", - title: "Пульс покоя", - value: "\(Int(rhr.value ?? 0)) уд/мин", - subtitle: latest?.heartRate != nil ? "Avg: \(latest?.heartRate?.avg ?? 0) уд/мин" : "", - color: Color(hex: "ff4757"), - gradientColors: [Color(hex: "ff4757"), Color(hex: "ff6b81")] - ) - .frame(maxHeight: .infinity) - } - - if let hrv = latest?.hrv { - MetricCardView( - icon: "waveform.path.ecg", - title: "HRV", - value: "\(Int(hrv.avg ?? 0)) мс", - subtitle: hrv.latest != nil ? "Последнее: \(Int(hrv.latest!)) мс" : "Вариабельность", - color: Color(hex: "00d4aa"), - gradientColors: [Color(hex: "00d4aa"), Color(hex: "00b894")] - ) - .frame(maxHeight: .infinity) - } - - if let steps = latest?.steps { - StepsCard(steps: steps.total ?? 0) - .frame(maxHeight: .infinity) + SleepDetailView(sleep: sleep).presentationDetents([.large]).presentationBackground(Color(hex: "06060f")) } } - .padding(.horizontal) + .task { + if healthKit.isAvailable { try? await healthKit.requestAuthorization() } + await loadData() + } } - // MARK: - Load Data + // MARK: - Statuses + + var sleepStatus: MetricStatus { + guard let s = latest?.sleep?.totalSleep, s > 0 else { return .noData } + if s >= 7.5 { return .good("Отличный сон") } + if s >= 6 { return .ok("Можно лучше") } + return .bad("Мало сна") + } + var rhrStatus: MetricStatus { + guard let v = latest?.restingHeartRate?.value, v > 0 else { return .noData } + if v <= 65 { return .good("Отлично") } + if v <= 80 { return .ok("Нормально") } + return .bad("Повышенный") + } + var hrvStatus: MetricStatus { + guard let v = latest?.hrv?.avg, v > 0 else { return .noData } + if v >= 50 { return .good("Хорошее восстановление") } + if v >= 30 { return .ok("Средний уровень") } + return .bad("Стресс / усталость") + } + var stepsStatus: MetricStatus { + guard let s = latest?.steps?.total, s > 0 else { return .noData } + if s >= 8000 { return .good("Цель достигнута") } + if s >= 5000 { return .ok("\(8000 - s) до цели") } + return .bad("Мало движения") + } + var spo2Status: MetricStatus { + guard let v = latest?.bloodOxygen?.avg, v > 0 else { return .noData } + if v >= 96 { return .good("Норма") } + if v >= 93 { return .ok("Пониженный") } + return .bad("Низкий!") + } + var rrStatus: MetricStatus { + guard let v = latest?.respiratoryRate?.avg, v > 0 else { return .noData } + if v >= 12 && v <= 20 { return .good("Норма") } + return .ok("Отклонение") + } + var energyStatus: MetricStatus { + guard let v = latest?.activeEnergy?.total, v > 0 else { return .noData } + if v >= 300 { return .good("Активный день") } + if v >= 150 { return .ok("Умеренно") } + return .bad("Мало активности") + } + var distStatus: MetricStatus { + guard let v = latest?.distance?.total, v > 0 else { return .noData } + let km = v / 1000 + if km >= 5 { return .good("Отлично") } + if km >= 2 { return .ok("Нормально") } + return .bad("Мало") + } + + // MARK: - Data func loadData(refresh: Bool = false) async { if !refresh { isLoading = true } - async let r = HealthAPIService.shared.getReadiness() async let l = HealthAPIService.shared.getLatest() async let h = HealthAPIService.shared.getHeatmap(days: 7) - - readiness = try? await r - latest = try? await l - heatmapData = (try? await h) ?? [] - + readiness = try? await r; latest = try? await l; heatmapData = (try? await h) ?? [] isLoading = false } - // MARK: - Sync HealthKit - func syncHealthKit() async { - guard healthKit.isAvailable else { - showToastMessage("HealthKit недоступен на этом устройстве", success: false) - return - } - + guard healthKit.isAvailable else { showToastMsg("HealthKit недоступен", success: false); return } UIImpactFeedbackGenerator(style: .medium).impactOccurred() - do { try await healthKit.syncToServer(apiKey: authManager.healthApiKey) UINotificationFeedbackGenerator().notificationOccurred(.success) - showToastMessage("Данные синхронизированы ✓", success: true) + showToastMsg("Синхронизировано", success: true) await loadData() } catch { UINotificationFeedbackGenerator().notificationOccurred(.error) - showToastMessage(error.localizedDescription, success: false) + showToastMsg(error.localizedDescription, success: false) } } - private func showToastMessage(_ message: String, success: Bool) { - toastMessage = message - toastSuccess = success - withAnimation { - showToast = true + private func showToastMsg(_ msg: String, success: Bool) { + toastMessage = msg; toastSuccess = success; withAnimation { showToast = true } + } + + private func fmtNum(_ n: Int) -> String { + let f = NumberFormatter(); f.numberStyle = .decimal; f.groupingSeparator = " " + return f.string(from: NSNumber(value: n)) ?? "\(n)" + } +} + +// MARK: - MetricStatus + +enum MetricStatus { + case good(String), ok(String), bad(String), noData + var text: String { + switch self { case .good(let s), .ok(let s), .bad(let s): return s; case .noData: return "Нет данных" } + } + var color: Color { + switch self { case .good: return Theme.teal; case .ok: return Theme.orange; case .bad: return Theme.red; case .noData: return Theme.textSecondary } + } + var icon: String { + switch self { case .good: return "arrow.up.right"; case .ok: return "minus"; case .bad: return "arrow.down.right"; case .noData: return "questionmark" } + } +} + +// MARK: - Readiness Banner + +struct ReadinessBanner: View { + let readiness: ReadinessResponse + var statusColor: Color { + if readiness.score >= 80 { return Theme.teal } + if readiness.score >= 60 { return Theme.orange } + return Theme.red + } + var statusText: String { + if readiness.score >= 80 { return "Отличный день для активности" } + if readiness.score >= 60 { return "Умеренная нагрузка будет в самый раз" } + return "Лучше отдохнуть и восстановиться" + } + var body: some View { + HStack(spacing: 16) { + ZStack { + Circle().stroke(Color.white.opacity(0.08), lineWidth: 8).frame(width: 72, height: 72) + Circle().trim(from: 0, to: CGFloat(readiness.score) / 100) + .stroke(statusColor, style: StrokeStyle(lineWidth: 8, lineCap: .round)) + .frame(width: 72, height: 72).rotationEffect(.degrees(-90)) + .shadow(color: statusColor.opacity(0.4), radius: 6) + Text("\(readiness.score)").font(.system(size: 22, weight: .bold, design: .rounded)).foregroundColor(statusColor) + } + VStack(alignment: .leading, spacing: 6) { + Text("Готовность").font(.subheadline).foregroundColor(Theme.textSecondary) + Text(statusText).font(.callout.weight(.medium)).foregroundColor(.white).lineLimit(2) + } + Spacer() + } + .padding(16).glassCard(cornerRadius: 18).padding(.horizontal) + } +} + +// MARK: - Health Metric Tile + +struct HealthMetricTile: View { + let icon: String; let title: String; let value: String; let unit: String + let status: MetricStatus; let color: Color + var hint: String? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + GlowIcon(systemName: icon, color: color, size: 32, iconSize: .caption) + Spacer() + HStack(spacing: 3) { + Image(systemName: status.icon).font(.system(size: 9, weight: .bold)) + Text(status.text).font(.system(size: 10, weight: .medium)) + }.foregroundColor(status.color) + } + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(value).font(.title2.bold().monospacedDigit()).foregroundColor(.white) + Text(unit).font(.caption.bold()).foregroundColor(Theme.textSecondary) + } + Text(title).font(.caption).foregroundColor(Theme.textSecondary) + if let h = hint { + Text(h).font(.system(size: 9)).foregroundColor(Theme.textSecondary.opacity(0.7)).lineLimit(2) + } + } + .padding(14).frame(maxWidth: .infinity, alignment: .leading).frame(minHeight: 120) + .glassCard(cornerRadius: 18) + } +} + +// MARK: - Heart Rate Card + +struct HeartRateCard: View { + let hr: HeartRateData + let rhr: RestingHRData? + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 8) { + GradientIcon(icon: "heart.fill", colors: [Theme.red, Theme.pink]) + Text("Пульс за день").font(.headline).foregroundColor(.white) + Spacer() + } + HStack(spacing: 0) { + HRStatBox(label: "Мин", value: "\(hr.min ?? 0)", color: Theme.teal) + Divider().frame(height: 40).background(Color.white.opacity(0.1)) + HRStatBox(label: "Средний", value: "\(hr.avg ?? 0)", color: .white) + Divider().frame(height: 40).background(Color.white.opacity(0.1)) + HRStatBox(label: "Макс", value: "\(hr.max ?? 0)", color: Theme.red) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.04))) + + if let rhr = rhr?.value, rhr > 0 { + HStack(spacing: 6) { + Circle().fill(Theme.purple).frame(width: 6, height: 6) + Text("Пульс покоя: \(Int(rhr)) уд/мин").font(.caption).foregroundColor(Theme.textSecondary) + Spacer() + Text(rhr <= 65 ? "Отлично" : rhr <= 80 ? "Норма" : "Высокий") + .font(.caption.bold()).foregroundColor(rhr <= 65 ? Theme.teal : rhr <= 80 ? Theme.orange : Theme.red) + } + } + } + .padding(16).glassCard(cornerRadius: 18).padding(.horizontal) + } +} + +struct HRStatBox: View { + let label: String; let value: String; let color: Color + var body: some View { + VStack(spacing: 4) { + Text(value).font(.title3.bold().monospacedDigit()).foregroundColor(color) + Text(label).font(.caption2).foregroundColor(Theme.textSecondary) + }.frame(maxWidth: .infinity) + } +} + +// MARK: - Weekly Trends Card + +struct WeeklyTrendsCard: View { + let heatmapData: [HeatmapEntry] + + var avgSleep: Double { + let vals = heatmapData.compactMap(\.sleep).filter { $0 > 0 } + return vals.isEmpty ? 0 : vals.reduce(0, +) / Double(vals.count) + } + var avgHRV: Double { + let vals = heatmapData.compactMap(\.hrv).filter { $0 > 0 } + return vals.isEmpty ? 0 : vals.reduce(0, +) / Double(vals.count) + } + var avgRHR: Double { + let vals = heatmapData.compactMap(\.rhr).filter { $0 > 0 } + return vals.isEmpty ? 0 : vals.reduce(0, +) / Double(vals.count) + } + var avgSteps: Int { + let vals = heatmapData.compactMap(\.steps).filter { $0 > 0 } + return vals.isEmpty ? 0 : vals.reduce(0, +) / vals.count + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 8) { + GradientIcon(icon: "chart.line.uptrend.xyaxis", colors: [Theme.indigo, Theme.blue]) + Text("Средние за неделю").font(.headline).foregroundColor(.white) + Spacer() + } + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { + TrendItem(icon: "moon.fill", label: "Сон", value: String(format: "%.1f ч", avgSleep), color: Theme.purple) + TrendItem(icon: "waveform.path.ecg", label: "HRV", value: "\(Int(avgHRV)) мс", color: Theme.teal) + TrendItem(icon: "heart.fill", label: "Пульс покоя", value: "\(Int(avgRHR)) уд/м", color: Theme.red) + TrendItem(icon: "figure.walk", label: "Шаги", value: "\(avgSteps)", color: Theme.orange) + } + } + .padding(16).glassCard(cornerRadius: 18).padding(.horizontal) + } +} + +struct TrendItem: View { + let icon: String; let label: String; let value: String; let color: Color + var body: some View { + HStack(spacing: 10) { + Image(systemName: icon).font(.caption).foregroundColor(color).frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + Text(value).font(.callout.bold().monospacedDigit()).foregroundColor(.white) + Text(label).font(.caption2).foregroundColor(Theme.textSecondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))) + } +} + +// MARK: - Recovery Card + +struct RecoveryCard: View { + let sleep: SleepData? + let hrv: HRVData? + let rhr: RestingHRData? + + var sleepScore: Double { + guard let s = sleep?.totalSleep, s > 0 else { return 0 } + return min(s / 8.0, 1.0) * 100 + } + var hrvScore: Double { + guard let v = hrv?.avg, v > 0 else { return 0 } + return min(v / 60.0, 1.0) * 100 + } + var rhrScore: Double { + guard let v = rhr?.value, v > 0 else { return 0 } + if v <= 55 { return 100 } + if v <= 65 { return 80 } + if v <= 75 { return 60 } + return max(40 - (v - 75), 10) + } + var recoveryScore: Int { + let scores = [sleepScore, hrvScore, rhrScore].filter { $0 > 0 } + guard !scores.isEmpty else { return 0 } + // Weighted: 40% sleep, 35% HRV, 25% RHR + let w = sleepScore * 0.4 + hrvScore * 0.35 + rhrScore * 0.25 + return Int(w) + } + var recoveryColor: Color { + if recoveryScore >= 75 { return Theme.teal } + if recoveryScore >= 50 { return Theme.orange } + return Theme.red + } + var recoveryText: String { + if recoveryScore >= 75 { return "Организм хорошо восстановился" } + if recoveryScore >= 50 { return "Среднее восстановление" } + if recoveryScore > 0 { return "Тело ещё не восстановилось" } + return "Недостаточно данных" + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 8) { + GradientIcon(icon: "battery.100.bolt", colors: [Theme.teal, Theme.green]) + Text("Восстановление").font(.headline).foregroundColor(.white) + Spacer() + Text("\(recoveryScore)%").font(.title3.bold()).foregroundColor(recoveryColor) + } + + Text(recoveryText).font(.subheadline).foregroundColor(.white.opacity(0.7)) + + // Factor bars + VStack(spacing: 8) { + RecoveryFactor(name: "Сон", score: sleepScore, color: Theme.purple) + RecoveryFactor(name: "HRV", score: hrvScore, color: Theme.teal) + RecoveryFactor(name: "Пульс покоя", score: rhrScore, color: Theme.red) + } + } + .padding(16).glassCard(cornerRadius: 18).padding(.horizontal) + } +} + +struct RecoveryFactor: View { + let name: String; let score: Double; let color: Color + var body: some View { + HStack(spacing: 10) { + Text(name).font(.caption).foregroundColor(Theme.textSecondary).frame(width: 80, alignment: .leading) + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.06)) + RoundedRectangle(cornerRadius: 3) + .fill(color) + .frame(width: geo.size.width * CGFloat(score / 100)) + .shadow(color: color.opacity(0.3), radius: 3) + } + }.frame(height: 6) + Text("\(Int(score))%").font(.caption.bold().monospacedDigit()).foregroundColor(.white.opacity(0.6)).frame(width: 32, alignment: .trailing) } } } + +// MARK: - Tips Card + +struct TipsCard: View { + let readiness: ReadinessResponse?; let latest: LatestHealthResponse? + var tips: [(icon: String, text: String, color: Color)] { + var r: [(String, String, Color)] = [] + if let s = readiness?.score { + if s >= 80 { r.append(("bolt.fill", "Высокая готовность — идеальный день для тренировки", Theme.teal)) } + else if s < 60 { r.append(("bed.double.fill", "Низкая готовность — сфокусируйся на восстановлении", Theme.red)) } + } + if let s = latest?.sleep?.totalSleep { + if s < 6 { r.append(("moon.zzz.fill", "Критически мало сна. Ложись раньше", Theme.purple)) } + else if s < 7 { r.append(("moon.fill", "Старайся спать 7-9 часов", Theme.purple)) } + } + if let v = latest?.hrv?.avg, v > 0, v < 30 { r.append(("exclamationmark.triangle.fill", "Низкий HRV — возможен стресс", Theme.orange)) } + if let s = latest?.steps?.total, s > 0, s < 5000 { r.append(("figure.walk", "15 минут прогулки улучшат самочувствие", Theme.orange)) } + if let spo2 = latest?.bloodOxygen?.avg, spo2 > 0, spo2 < 95 { r.append(("lungs.fill", "Кислород ниже нормы — дыши глубже", Theme.blue)) } + if r.isEmpty { r.append(("sparkles", "Все показатели в норме — так держать!", Theme.teal)) } + return r + } + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "lightbulb.fill").foregroundColor(Theme.orange) + Text("Рекомендации").font(.headline).foregroundColor(.white) + } + ForEach(Array(tips.enumerated()), id: \.offset) { _, tip in + HStack(alignment: .top, spacing: 10) { + Image(systemName: tip.icon).font(.caption).foregroundColor(tip.color).frame(width: 20).padding(.top, 2) + Text(tip.text).font(.subheadline).foregroundColor(.white.opacity(0.85)).lineLimit(3) + } + } + } + .padding(16).glassCard(cornerRadius: 18).padding(.horizontal) + } +} diff --git a/PulseHealth/Views/Health/MetricCardView.swift b/PulseHealth/Views/Health/MetricCardView.swift index 6a80cda..449627a 100644 --- a/PulseHealth/Views/Health/MetricCardView.swift +++ b/PulseHealth/Views/Health/MetricCardView.swift @@ -9,24 +9,20 @@ struct GradientIcon: View { var body: some View { ZStack { + Circle() + .fill(colors.first?.opacity(0.25) ?? Color.clear) + .frame(width: size * 1.2, height: size * 1.2) + .blur(radius: 10) Circle() .fill( - LinearGradient( - colors: colors.map { $0.opacity(0.2) }, - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + LinearGradient(colors: colors.map { $0.opacity(0.15) }, + startPoint: .topLeading, endPoint: .bottomTrailing) ) .frame(width: size, height: size) - Image(systemName: icon) .font(.system(size: size * 0.4)) .foregroundStyle( - LinearGradient( - colors: colors, - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing) ) } } @@ -41,79 +37,26 @@ struct MetricCardView: View { let subtitle: String let color: Color var gradientColors: [Color]? = nil - var progress: Double? = nil - var progressMax: Double = 1.0 @State private var appeared = false var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 10) { HStack { GradientIcon(icon: icon, colors: gradientColors ?? [color, color.opacity(0.6)]) Spacer() } - - Text(value) - .font(.title2.bold()) - .foregroundColor(.white) - - Text(title) - .font(.subheadline.weight(.medium)) - .foregroundColor(.white.opacity(0.7)) - - if subtitle.isEmpty == false { - Text(subtitle) - .font(.caption) - .foregroundColor(Color(hex: "8888aa")) - .lineLimit(2) - } - - if let progress = progress { - VStack(spacing: 4) { - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(Color.white.opacity(0.08)) - - RoundedRectangle(cornerRadius: 4) - .fill( - LinearGradient( - colors: gradientColors ?? [color, color.opacity(0.6)], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(width: geo.size.width * min(CGFloat(progress / progressMax), 1.0)) - } - } - .frame(height: 6) - - HStack { - Spacer() - Text("\(Int(progress / progressMax * 100))% от цели") - .font(.system(size: 10)) - .foregroundColor(Color(hex: "8888aa")) - } - } + Text(value).font(.title2.bold()).foregroundColor(.white) + Text(title).font(.subheadline.weight(.medium)).foregroundColor(.white.opacity(0.7)) + if !subtitle.isEmpty { + Text(subtitle).font(.caption).foregroundColor(Theme.textSecondary).lineLimit(2) } } .padding(16) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20) - .fill(Color(hex: "12122a").opacity(0.7)) - ) - ) - .shadow(color: .black.opacity(0.15), radius: 8, y: 4) + .glassCard(cornerRadius: 20) .opacity(appeared ? 1 : 0) .offset(y: appeared ? 0 : 15) - .onAppear { - withAnimation(.easeOut(duration: 0.5).delay(0.1)) { - appeared = true - } - } + .onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.1)) { appeared = true } } } } @@ -124,90 +67,32 @@ struct SleepCard: View { @State private var appeared = false var totalHours: Double { sleep.totalSleep ?? 0 } - var deepMin: Int { Int((sleep.deep ?? 0) * 60) } - var remHours: String { formatHours(sleep.rem ?? 0) } - var coreHours: String { formatHours(sleep.core ?? 0) } var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 10) { HStack { - GradientIcon(icon: "moon.fill", colors: [Color(hex: "7c3aed"), Color(hex: "a78bfa")]) + GradientIcon(icon: "moon.fill", colors: [Theme.purple, Color(hex: "a78bfa")]) Spacer() } + Text(String(format: "%.1f ч", totalHours)).font(.title2.bold()).foregroundColor(.white) + Text("Сон").font(.subheadline.weight(.medium)).foregroundColor(.white.opacity(0.7)) - Text(String(format: "%.1f ч", totalHours)) - .font(.title2.bold()) - .foregroundColor(.white) - - Text("Сон") - .font(.subheadline.weight(.medium)) - .foregroundColor(.white.opacity(0.7)) - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 12) { - SleepPhase(label: "Deep", value: "\(deepMin)мин", color: Color(hex: "7c3aed")) - SleepPhase(label: "REM", value: remHours, color: Color(hex: "a78bfa")) - SleepPhase(label: "Core", value: coreHours, color: Color(hex: "c4b5fd")) - } - .font(.system(size: 10)) - } - - // Progress to 9h goal GeometryReader { geo in ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.08)) RoundedRectangle(cornerRadius: 4) - .fill(Color.white.opacity(0.08)) - RoundedRectangle(cornerRadius: 4) - .fill( - LinearGradient( - colors: [Color(hex: "7c3aed"), Color(hex: "a78bfa")], - startPoint: .leading, - endPoint: .trailing - ) - ) + .fill(LinearGradient(colors: [Theme.purple, Color(hex: "a78bfa")], startPoint: .leading, endPoint: .trailing)) .frame(width: geo.size.width * min(CGFloat(totalHours / 9.0), 1.0)) + .shadow(color: Theme.purple.opacity(0.5), radius: 4, y: 0) } } .frame(height: 6) } .padding(16) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20) - .fill(Color(hex: "12122a").opacity(0.7)) - ) - ) - .shadow(color: .black.opacity(0.15), radius: 8, y: 4) + .glassCard(cornerRadius: 20) .opacity(appeared ? 1 : 0) .offset(y: appeared ? 0 : 15) - .onAppear { - withAnimation(.easeOut(duration: 0.5).delay(0.15)) { - appeared = true - } - } - } - - private func formatHours(_ h: Double) -> String { - if h < 1 { return "\(Int(h * 60))мин" } - return String(format: "%.0fч", h) - } -} - -struct SleepPhase: View { - let label: String - let value: String - let color: Color - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .foregroundColor(Color(hex: "8888aa")) - Text(value) - .foregroundColor(color) - .fontWeight(.medium) - } + .onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.15)) { appeared = true } } } } @@ -219,68 +104,40 @@ struct StepsCard: View { @State private var appeared = false var progress: Double { Double(steps) / Double(goal) } - var percent: Int { Int(progress * 100) } var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 10) { HStack { - GradientIcon(icon: "figure.walk", colors: [Color(hex: "ffa502"), Color(hex: "ff6348")]) + GradientIcon(icon: "figure.walk", colors: [Theme.orange, Color(hex: "ff6348")]) Spacer() } - - Text(formatSteps(steps)) - .font(.title2.bold()) - .foregroundColor(.white) - - Text("Шаги") - .font(.subheadline.weight(.medium)) - .foregroundColor(.white.opacity(0.7)) + Text(formatSteps(steps)).font(.title2.bold()).foregroundColor(.white) + Text("Шаги").font(.subheadline.weight(.medium)).foregroundColor(.white.opacity(0.7)) GeometryReader { geo in ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.08)) RoundedRectangle(cornerRadius: 4) - .fill(Color.white.opacity(0.08)) - RoundedRectangle(cornerRadius: 4) - .fill( - LinearGradient( - colors: [Color(hex: "ffa502"), Color(hex: "ff6348")], - startPoint: .leading, - endPoint: .trailing - ) - ) + .fill(LinearGradient(colors: [Theme.orange, Color(hex: "ff6348")], startPoint: .leading, endPoint: .trailing)) .frame(width: geo.size.width * min(CGFloat(progress), 1.0)) + .shadow(color: Theme.orange.opacity(0.5), radius: 4, y: 0) } } .frame(height: 6) - Text("\(percent)% от цели") - .font(.system(size: 10)) - .foregroundColor(Color(hex: "8888aa")) + Text("\(Int(progress * 100))% от цели") + .font(.system(size: 10)).foregroundColor(Theme.textSecondary) } .padding(16) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20) - .fill(Color(hex: "12122a").opacity(0.7)) - ) - ) - .shadow(color: .black.opacity(0.15), radius: 8, y: 4) + .glassCard(cornerRadius: 20) .opacity(appeared ? 1 : 0) .offset(y: appeared ? 0 : 15) - .onAppear { - withAnimation(.easeOut(duration: 0.5).delay(0.25)) { - appeared = true - } - } + .onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.25)) { appeared = true } } } private func formatSteps(_ n: Int) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.groupingSeparator = " " - return formatter.string(from: NSNumber(value: n)) ?? "\(n)" + let f = NumberFormatter(); f.numberStyle = .decimal; f.groupingSeparator = " " + return f.string(from: NSNumber(value: n)) ?? "\(n)" } } @@ -289,81 +146,121 @@ struct StepsCard: View { struct InsightsCard: View { let readiness: ReadinessResponse? let latest: LatestHealthResponse? - @State private var appeared = false var insights: [(icon: String, text: String, color: Color)] { var result: [(String, String, Color)] = [] - if let r = readiness { - if r.score >= 80 { - result.append(("bolt.fill", "Отличный день для тренировки!", Color(hex: "00d4aa"))) - } else if r.score < 60 { - result.append(("bed.double.fill", "Сегодня лучше отдохнуть", Color(hex: "ff4757"))) - } + if r.score >= 80 { result.append(("bolt.fill", "Отличный день для тренировки!", Theme.teal)) } + else if r.score < 60 { result.append(("bed.double.fill", "Сегодня лучше отдохнуть", Theme.red)) } } - if let sleep = latest?.sleep?.totalSleep, sleep < 7 { - result.append(("moon.zzz.fill", "Мало сна — постарайся лечь раньше", Color(hex: "7c3aed"))) + result.append(("moon.zzz.fill", "Мало сна — постарайся лечь раньше", Theme.purple)) } - if let hrv = latest?.hrv?.avg, hrv > 50 { - result.append(("heart.fill", "HRV в норме — хороший знак", Color(hex: "00d4aa"))) + result.append(("heart.fill", "HRV в норме — хороший знак", Theme.teal)) } else if let hrv = latest?.hrv?.avg, hrv > 0 { - result.append(("exclamationmark.triangle.fill", "HRV ниже нормы — следи за стрессом", Color(hex: "ffa502"))) + result.append(("exclamationmark.triangle.fill", "HRV ниже нормы — следи за стрессом", Theme.orange)) } - if let steps = latest?.steps?.total, steps > 0 && steps < 5000 { - result.append(("figure.walk", "Мало шагов — прогуляйся!", Color(hex: "ffa502"))) + result.append(("figure.walk", "Мало шагов — прогуляйся!", Theme.orange)) } - if result.isEmpty { - result.append(("sparkles", "Данные обновятся после синхронизации", Color(hex: "8888aa"))) + result.append(("sparkles", "Данные обновятся после синхронизации", Theme.textSecondary)) } - return result } var body: some View { VStack(alignment: .leading, spacing: 14) { HStack { - GradientIcon(icon: "lightbulb.fill", colors: [Color(hex: "ffa502"), Color(hex: "ff6348")]) - Text("Инсайты") - .font(.headline.weight(.semibold)) - .foregroundColor(.white) + GradientIcon(icon: "lightbulb.fill", colors: [Theme.orange, Color(hex: "ff6348")]) + Text("Инсайты").font(.headline.weight(.semibold)).foregroundColor(.white) Spacer() } - ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in HStack(spacing: 12) { - Image(systemName: insight.icon) - .font(.subheadline) - .foregroundColor(insight.color) - .frame(width: 24) - - Text(insight.text) - .font(.subheadline) - .foregroundColor(.white.opacity(0.85)) - .lineLimit(2) + Image(systemName: insight.icon).font(.subheadline).foregroundColor(insight.color).frame(width: 24) + Text(insight.text).font(.subheadline).foregroundColor(.white.opacity(0.85)).lineLimit(2) } } } .padding(20) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20) - .fill(Color(hex: "12122a").opacity(0.7)) - ) - ) - .shadow(color: .black.opacity(0.2), radius: 10, y: 5) + .glassCard(cornerRadius: 20) .padding(.horizontal) - .opacity(appeared ? 1 : 0) - .offset(y: appeared ? 0 : 20) - .onAppear { - withAnimation(.easeOut(duration: 0.5).delay(0.3)) { - appeared = true - } - } + } +} + +// MARK: - Sleep Phases Card + +struct SleepPhasesCard: View { + let sleep: SleepData + @State private var appeared = false + + var total: Double { sleep.totalSleep ?? 0 } + var deep: Double { sleep.deep ?? 0 } + var rem: Double { sleep.rem ?? 0 } + var core: Double { sleep.core ?? 0 } + + var phases: [(name: String, value: Double, color: Color)] { + [ + ("Глубокий", deep, Theme.purple), + ("Быстрый (REM)", rem, Color(hex: "a78bfa")), + ("Базовый", core, Color(hex: "c4b5fd")), + ] + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + GradientIcon(icon: "bed.double.fill", colors: [Theme.purple, Color(hex: "a78bfa")]) + Text("Фазы сна").font(.headline.weight(.semibold)).foregroundColor(.white) + Spacer() + Text(String(format: "%.1f ч", total)) + .font(.callout.bold()).foregroundColor(Theme.purple) + } + + // Stacked bar + GeometryReader { geo in + HStack(spacing: 2) { + ForEach(phases, id: \.name) { phase in + let fraction = total > 0 ? phase.value / total : 0 + RoundedRectangle(cornerRadius: 4) + .fill(phase.color) + .frame(width: max(geo.size.width * CGFloat(fraction), fraction > 0 ? 4 : 0)) + .shadow(color: phase.color.opacity(0.4), radius: 4, y: 0) + } + } + } + .frame(height: 12) + + // Phase details + ForEach(phases, id: \.name) { phase in + HStack(spacing: 12) { + Circle().fill(phase.color).frame(width: 8, height: 8) + Text(phase.name).font(.callout).foregroundColor(.white) + Spacer() + Text(fmtDuration(phase.value)) + .font(.callout.bold().monospacedDigit()).foregroundColor(phase.color) + if total > 0 { + Text("\(Int(phase.value / total * 100))%") + .font(.caption).foregroundColor(Theme.textSecondary) + .frame(width: 32, alignment: .trailing) + } + } + } + } + .padding(20) + .glassCard(cornerRadius: 20) + .padding(.horizontal) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 15) + .onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.2)) { appeared = true } } + } + + private func fmtDuration(_ h: Double) -> String { + let hrs = Int(h) + let mins = Int((h - Double(hrs)) * 60) + if hrs > 0 { return "\(hrs)ч \(mins)м" } + return "\(mins)м" } } diff --git a/PulseHealth/Views/Health/ReadinessCardView.swift b/PulseHealth/Views/Health/ReadinessCardView.swift index 94b0eb0..727af77 100644 --- a/PulseHealth/Views/Health/ReadinessCardView.swift +++ b/PulseHealth/Views/Health/ReadinessCardView.swift @@ -1,152 +1,3 @@ +// ReadinessCardView — replaced by ReadinessBanner in HealthView.swift +// This file is intentionally empty. import SwiftUI - -struct ReadinessCardView: View { - let readiness: ReadinessResponse - @State private var animatedScore: CGFloat = 0 - @State private var appeared = false - - var statusColor: Color { - if readiness.score >= 80 { return Color(hex: "00d4aa") } - if readiness.score >= 60 { return Color(hex: "ffa502") } - return Color(hex: "ff4757") - } - - var statusText: String { - if readiness.score >= 80 { return "Отличная готовность 💪" } - if readiness.score >= 60 { return "Умеренная активность 🚶" } - return "День отдыха 😴" - } - - var body: some View { - VStack(spacing: 20) { - // Score Ring - ZStack { - // Background ring - Circle() - .stroke(Color.white.opacity(0.08), lineWidth: 14) - .frame(width: 150, height: 150) - - // Animated ring - Circle() - .trim(from: 0, to: animatedScore / 100) - .stroke( - AngularGradient( - colors: [statusColor.opacity(0.5), statusColor, statusColor.opacity(0.8)], - center: .center, - startAngle: .degrees(0), - endAngle: .degrees(360) - ), - style: StrokeStyle(lineWidth: 14, lineCap: .round) - ) - .frame(width: 150, height: 150) - .rotationEffect(.degrees(-90)) - - // Score text - VStack(spacing: 2) { - Text("\(readiness.score)") - .font(.system(size: 48, weight: .bold, design: .rounded)) - .foregroundColor(statusColor) - Text("из 100") - .font(.caption2) - .foregroundColor(Color(hex: "8888aa")) - } - } - - // Status - VStack(spacing: 6) { - Text(statusText) - .font(.title3.weight(.semibold)) - .foregroundColor(.white) - - Text(readiness.recommendation) - .font(.subheadline) - .foregroundColor(Color(hex: "8888aa")) - .multilineTextAlignment(.center) - .lineLimit(3) - .padding(.horizontal, 8) - } - - // Factor bars - if let f = readiness.factors { - VStack(spacing: 10) { - Divider().background(Color.white.opacity(0.1)) - - FactorRow(name: "Сон", icon: "moon.fill", score: f.sleep.score, value: f.sleep.value, color: Color(hex: "7c3aed")) - FactorRow(name: "HRV", icon: "waveform.path.ecg", score: f.hrv.score, value: f.hrv.value, color: Color(hex: "00d4aa")) - FactorRow(name: "Пульс", icon: "heart.fill", score: f.rhr.score, value: f.rhr.value, color: Color(hex: "ff4757")) - FactorRow(name: "Активность", icon: "flame.fill", score: f.activity.score, value: f.activity.value, color: Color(hex: "ffa502")) - } - } - } - .padding(24) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20) - .fill(Color(hex: "12122a").opacity(0.7)) - ) - ) - .shadow(color: .black.opacity(0.2), radius: 10, y: 5) - .padding(.horizontal) - .onAppear { - withAnimation(.easeOut(duration: 1.2)) { - animatedScore = CGFloat(readiness.score) - } - } - .opacity(appeared ? 1 : 0) - .offset(y: appeared ? 0 : 20) - .onAppear { - withAnimation(.easeOut(duration: 0.5).delay(0.1)) { - appeared = true - } - } - } -} - -// MARK: - Factor Row - -struct FactorRow: View { - let name: String - let icon: String - let score: Int - let value: String - let color: Color - - var body: some View { - HStack(spacing: 10) { - Image(systemName: icon) - .font(.caption) - .foregroundColor(color) - .frame(width: 20) - - Text(name) - .font(.caption.weight(.medium)) - .foregroundColor(Color(hex: "8888aa")) - .frame(width: 75, alignment: .leading) - - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3) - .fill(Color.white.opacity(0.08)) - - RoundedRectangle(cornerRadius: 3) - .fill( - LinearGradient( - colors: [color.opacity(0.7), color], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(width: geo.size.width * CGFloat(score) / 100) - } - } - .frame(height: 6) - - Text(value) - .font(.caption) - .foregroundColor(.white.opacity(0.7)) - .frame(width: 55, alignment: .trailing) - } - } -} diff --git a/PulseHealth/Views/Health/SleepDetailView.swift b/PulseHealth/Views/Health/SleepDetailView.swift new file mode 100644 index 0000000..5040e46 --- /dev/null +++ b/PulseHealth/Views/Health/SleepDetailView.swift @@ -0,0 +1,249 @@ +import SwiftUI + +struct SleepDetailView: View { + let sleep: SleepData + @StateObject private var healthKit = HealthKitService() + @State private var segments: [SleepSegment] = [] + @State private var isLoading = true + @Environment(\.dismiss) var dismiss + + var total: Double { sleep.totalSleep ?? 0 } + var deep: Double { sleep.deep ?? 0 } + var rem: Double { sleep.rem ?? 0 } + var core: Double { sleep.core ?? 0 } + + var phases: [(name: String, value: Double, color: Color, icon: String)] { + [ + ("Глубокий", deep, SleepPhaseType.deep.color, "moon.zzz.fill"), + ("REM", rem, SleepPhaseType.rem.color, "brain.head.profile"), + ("Базовый", core, SleepPhaseType.core.color, "moon.fill"), + ] + } + + var sleepStart: Date? { segments.first?.start } + var sleepEnd: Date? { segments.last?.end } + + var body: some View { + ZStack { + Color(hex: "06060f").ignoresSafeArea() + + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + // Header + HStack { + Button(action: { dismiss() }) { + Image(systemName: "xmark.circle.fill") + .font(.title2).foregroundColor(Theme.textSecondary) + } + Spacer() + Text("Анализ сна").font(.headline).foregroundColor(.white) + Spacer() + Color.clear.frame(width: 28) + } + .padding(.horizontal).padding(.top, 16) + + // Total + VStack(spacing: 8) { + Text(String(format: "%.1f ч", total)) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .foregroundColor(Theme.purple) + if let start = sleepStart, let end = sleepEnd { + Text("\(fmt(start)) — \(fmt(end))") + .font(.callout).foregroundColor(Theme.textSecondary) + } + } + .padding(.vertical, 4) + + // Phase cards + HStack(spacing: 12) { + ForEach(phases, id: \.name) { phase in + VStack(spacing: 6) { + Image(systemName: phase.icon).font(.caption).foregroundColor(phase.color) + Text(fmtDuration(phase.value)).font(.callout.bold().monospacedDigit()).foregroundColor(.white) + Text(phase.name).font(.caption2).foregroundColor(Theme.textSecondary) + if total > 0 { + Text("\(Int(phase.value / total * 100))%").font(.caption2.bold()).foregroundColor(phase.color) + } + } + .frame(maxWidth: .infinity).padding(.vertical, 12) + .glassCard(cornerRadius: 14) + } + } + .padding(.horizontal) + + // Hypnogram + if isLoading { + ProgressView().tint(Theme.purple).padding(.top, 20) + } else if !segments.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Гипнограмма").font(.subheadline.bold()).foregroundColor(.white) + HypnogramView(segments: segments) + .frame(height: 180) + } + .padding(16) + .glassCard(cornerRadius: 16) + .padding(.horizontal) + } else { + VStack(spacing: 8) { + Image(systemName: "moon.zzz").font(.title).foregroundColor(Theme.textSecondary) + Text("График недоступен").font(.subheadline).foregroundColor(Theme.textSecondary) + }.padding(.top, 20) + } + + // Stacked bar + VStack(alignment: .leading, spacing: 8) { + Text("Распределение").font(.subheadline.bold()).foregroundColor(.white) + GeometryReader { geo in + HStack(spacing: 2) { + ForEach(phases, id: \.name) { phase in + let frac = total > 0 ? phase.value / total : 0 + RoundedRectangle(cornerRadius: 4) + .fill(phase.color) + .frame(width: max(geo.size.width * CGFloat(frac), frac > 0 ? 4 : 0)) + .shadow(color: phase.color.opacity(0.4), radius: 4) + } + } + } + .frame(height: 14) + } + .padding(16) + .glassCard(cornerRadius: 16) + .padding(.horizontal) + + // Legend + HStack(spacing: 16) { + ForEach([SleepPhaseType.awake, .rem, .core, .deep], id: \.rawValue) { phase in + HStack(spacing: 4) { + Circle().fill(phase.color).frame(width: 8, height: 8) + Text(phase.rawValue).font(.caption2).foregroundColor(Theme.textSecondary) + } + } + } + + Spacer(minLength: 40) + } + } + } + .task { + if healthKit.isAvailable { + try? await healthKit.requestAuthorization() + segments = await healthKit.fetchSleepSegments() + } + isLoading = false + } + } + + private func fmt(_ date: Date) -> String { + let f = DateFormatter(); f.dateFormat = "HH:mm"; return f.string(from: date) + } + private func fmtDuration(_ h: Double) -> String { + let hrs = Int(h); let mins = Int((h - Double(hrs)) * 60) + if hrs > 0 { return "\(hrs)ч \(mins)м" } + return "\(mins)м" + } +} + +// MARK: - Hypnogram (sleep stages chart) + +struct HypnogramView: View { + let segments: [SleepSegment] + + // Phase levels: awake=top, rem, core, deep=bottom + private func yLevel(_ phase: SleepPhaseType) -> CGFloat { + switch phase { + case .awake: return 0.0 + case .rem: return 0.33 + case .core: return 0.66 + case .deep: return 1.0 + } + } + + private var timeStart: Date { segments.first?.start ?? Date() } + private var timeEnd: Date { segments.last?.end ?? Date() } + private var totalSpan: TimeInterval { max(timeEnd.timeIntervalSince(timeStart), 1) } + + var body: some View { + GeometryReader { geo in + let w = geo.size.width + let chartH = geo.size.height - 36 // space for labels + + ZStack(alignment: .topLeading) { + // Grid lines + labels + ForEach(0..<4, id: \.self) { i in + let y = chartH * CGFloat(i) / 3.0 + Path { p in p.move(to: CGPoint(x: 0, y: y)); p.addLine(to: CGPoint(x: w, y: y)) } + .stroke(Color.white.opacity(0.05), lineWidth: 1) + + let labels = ["Пробуждение", "REM", "Базовый", "Глубокий"] + Text(labels[i]) + .font(.system(size: 8)) + .foregroundColor(Color.white.opacity(0.25)) + .position(x: 35, y: y) + } + + // Filled step areas + ForEach(segments) { seg in + let x1 = w * CGFloat(seg.start.timeIntervalSince(timeStart) / totalSpan) + let x2 = w * CGFloat(seg.end.timeIntervalSince(timeStart) / totalSpan) + let segW = max(x2 - x1, 1) + let y = yLevel(seg.phase) * chartH + + // Fill from phase level to bottom + Rectangle() + .fill(seg.phase.color.opacity(0.15)) + .frame(width: segW, height: chartH - y) + .position(x: x1 + segW / 2, y: y + (chartH - y) / 2) + + // Top edge highlight + Rectangle() + .fill(seg.phase.color) + .frame(width: segW, height: 3) + .shadow(color: seg.phase.color.opacity(0.6), radius: 4, y: 0) + .position(x: x1 + segW / 2, y: y) + } + + // Step line connecting phases + Path { path in + for (i, seg) in segments.enumerated() { + let x = w * CGFloat(seg.start.timeIntervalSince(timeStart) / totalSpan) + let y = yLevel(seg.phase) * chartH + let xEnd = w * CGFloat(seg.end.timeIntervalSince(timeStart) / totalSpan) + + if i == 0 { path.move(to: CGPoint(x: x, y: y)) } + else { path.addLine(to: CGPoint(x: x, y: y)) } + path.addLine(to: CGPoint(x: xEnd, y: y)) + } + } + .stroke(Color.white.opacity(0.5), style: StrokeStyle(lineWidth: 1.5)) + + // Time labels at bottom + let hours = timeLabels() + ForEach(hours, id: \.1) { (date, label) in + let x = w * CGFloat(date.timeIntervalSince(timeStart) / totalSpan) + Text(label) + .font(.system(size: 9)) + .foregroundColor(Theme.textSecondary) + .position(x: x, y: chartH + 18) + } + } + } + } + + private func timeLabels() -> [(Date, String)] { + let cal = Calendar.current + let f = DateFormatter(); f.dateFormat = "HH:mm" + var labels: [(Date, String)] = [] + var date = cal.date(bySetting: .minute, value: 0, of: timeStart) ?? timeStart + if date < timeStart { date = cal.date(byAdding: .hour, value: 1, to: date) ?? date } + while date < timeEnd { + labels.append((date, f.string(from: date))) + date = cal.date(byAdding: .hour, value: 1, to: date) ?? timeEnd + } + // Keep max 6 labels to avoid overlap + if labels.count > 6 { + let step = labels.count / 5 + labels = stride(from: 0, to: labels.count, by: step).map { labels[$0] } + } + return labels + } +} diff --git a/PulseHealth/Views/Health/WeeklyChartView.swift b/PulseHealth/Views/Health/WeeklyChartView.swift index e4e1c80..1cff59c 100644 --- a/PulseHealth/Views/Health/WeeklyChartView.swift +++ b/PulseHealth/Views/Health/WeeklyChartView.swift @@ -16,14 +16,9 @@ struct WeeklyChartCard: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - // Header HStack { - GradientIcon(icon: "chart.bar.fill", colors: [Color(hex: "7c3aed"), Color(hex: "00d4aa")]) - - Text("За неделю") - .font(.headline.weight(.semibold)) - .foregroundColor(.white) - + GradientIcon(icon: "chart.xyaxis.line", colors: [Theme.purple, Theme.teal]) + Text("За неделю").font(.headline.weight(.semibold)).foregroundColor(.white) Spacer() } @@ -31,19 +26,16 @@ struct WeeklyChartCard: View { HStack(spacing: 4) { ForEach(ChartType.allCases, id: \.self) { type in Button { - withAnimation(.easeInOut(duration: 0.2)) { - selectedChart = type - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } + withAnimation(.easeInOut(duration: 0.2)) { selectedChart = type } + UIImpactFeedbackGenerator(style: .light).impactOccurred() } label: { Text(type.rawValue) .font(.caption.weight(.medium)) - .foregroundColor(selectedChart == type ? .white : Color(hex: "8888aa")) - .padding(.horizontal, 16) - .padding(.vertical, 8) + .foregroundColor(selectedChart == type ? .white : Theme.textSecondary) + .padding(.horizontal, 16).padding(.vertical, 8) .background( selectedChart == type - ? Color(hex: "7c3aed").opacity(0.5) + ? chartColor.opacity(0.3) : Color.clear ) .cornerRadius(10) @@ -51,34 +43,23 @@ struct WeeklyChartCard: View { } } .padding(4) - .background(Color(hex: "1a1a3e")) + .background(Color.white.opacity(0.06)) .cornerRadius(12) - // Chart - BarChartView( + // Line Chart + LineChartView( values: chartValues, color: chartColor, - maxValue: chartMaxValue, unit: chartUnit, appeared: appeared ) .frame(height: 160) } .padding(20) - .background( - RoundedRectangle(cornerRadius: 20) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20) - .fill(Color(hex: "12122a").opacity(0.7)) - ) - ) - .shadow(color: .black.opacity(0.2), radius: 10, y: 5) + .glassCard(cornerRadius: 20) .padding(.horizontal) .onAppear { - withAnimation(.easeOut(duration: 0.8).delay(0.3)) { - appeared = true - } + withAnimation(.easeOut(duration: 0.8).delay(0.3)) { appeared = true } } } @@ -96,17 +77,9 @@ struct WeeklyChartCard: View { private var chartColor: Color { switch selectedChart { - case .sleep: return Color(hex: "7c3aed") - case .hrv: return Color(hex: "00d4aa") - case .steps: return Color(hex: "ffa502") - } - } - - private var chartMaxValue: Double { - switch selectedChart { - case .sleep: return 10 - case .hrv: return 120 - case .steps: return 12000 + case .sleep: return Theme.purple + case .hrv: return Theme.teal + case .steps: return Theme.orange } } @@ -119,68 +92,124 @@ struct WeeklyChartCard: View { } } -// MARK: - Bar Chart +// MARK: - Line Chart -struct BarChartView: View { +struct LineChartView: View { let values: [(date: String, value: Double)] let color: Color - let maxValue: Double let unit: String let appeared: Bool + private var maxVal: Double { + let m = values.map(\.value).max() ?? 1 + return m > 0 ? m * 1.15 : 1 + } + + private var minVal: Double { + let m = values.map(\.value).min() ?? 0 + return max(m * 0.85, 0) + } + var body: some View { GeometryReader { geo in - let barWidth = max((geo.size.width - CGFloat(values.count - 1) * 8) / CGFloat(max(values.count, 1)), 10) - let chartHeight = geo.size.height - 30 + let w = geo.size.width + let h = geo.size.height - 24 // space for labels + let count = max(values.count - 1, 1) - HStack(alignment: .bottom, spacing: 8) { - ForEach(Array(values.enumerated()), id: \.offset) { index, item in - VStack(spacing: 4) { - // Value label - if item.value > 0 { - Text(formatValue(item.value)) - .font(.system(size: 9, weight: .medium)) - .foregroundColor(Color(hex: "8888aa")) - } - - // Bar - RoundedRectangle(cornerRadius: 6) - .fill( - LinearGradient( - colors: [color, color.opacity(0.5)], - startPoint: .top, - endPoint: .bottom - ) - ) - .frame( - width: barWidth, - height: appeared - ? max(CGFloat(item.value / maxValue) * chartHeight, 4) - : 4 - ) - .animation( - .spring(response: 0.6, dampingFraction: 0.7).delay(Double(index) * 0.05), - value: appeared - ) - - // Date label - Text(item.date) - .font(.system(size: 10)) - .foregroundColor(Color(hex: "8888aa")) + ZStack(alignment: .topLeading) { + // Grid lines + ForEach(0..<4, id: \.self) { i in + let y = h * CGFloat(i) / 3.0 + Path { path in + path.move(to: CGPoint(x: 0, y: y)) + path.addLine(to: CGPoint(x: w, y: y)) } - .frame(maxWidth: .infinity) + .stroke(Color.white.opacity(0.04), lineWidth: 1) } + + if values.count >= 2 { + // Gradient fill under line + Path { path in + for (i, val) in values.enumerated() { + let x = w * CGFloat(i) / CGFloat(count) + let y = h * (1 - CGFloat((val.value - minVal) / max(maxVal - minVal, 1))) + if i == 0 { path.move(to: CGPoint(x: x, y: y)) } + else { path.addLine(to: CGPoint(x: x, y: y)) } + } + path.addLine(to: CGPoint(x: w, y: h)) + path.addLine(to: CGPoint(x: 0, y: h)) + path.closeSubpath() + } + .fill( + LinearGradient( + colors: [color.opacity(appeared ? 0.25 : 0), color.opacity(0)], + startPoint: .top, endPoint: .bottom + ) + ) + .animation(.easeOut(duration: 1), value: appeared) + + // Line + Path { path in + for (i, val) in values.enumerated() { + let x = w * CGFloat(i) / CGFloat(count) + let y = h * (1 - CGFloat((val.value - minVal) / max(maxVal - minVal, 1))) + if i == 0 { path.move(to: CGPoint(x: x, y: y)) } + else { path.addLine(to: CGPoint(x: x, y: y)) } + } + } + .trim(from: 0, to: appeared ? 1 : 0) + .stroke( + LinearGradient(colors: [color, color.opacity(0.6)], startPoint: .leading, endPoint: .trailing), + style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round) + ) + .shadow(color: color.opacity(0.5), radius: 6, y: 2) + .animation(.easeOut(duration: 1), value: appeared) + + // Dots + ForEach(Array(values.enumerated()), id: \.offset) { i, val in + let x = w * CGFloat(i) / CGFloat(count) + let y = h * (1 - CGFloat((val.value - minVal) / max(maxVal - minVal, 1))) + + Circle() + .fill(color) + .frame(width: 6, height: 6) + .shadow(color: color.opacity(0.6), radius: 4) + .position(x: x, y: y) + .opacity(appeared ? 1 : 0) + .animation(.easeOut(duration: 0.4).delay(Double(i) * 0.08), value: appeared) + } + } + + // Date labels at bottom + HStack(spacing: 0) { + ForEach(Array(values.enumerated()), id: \.offset) { _, val in + Text(val.date) + .font(.system(size: 9)) + .foregroundColor(Theme.textSecondary) + .frame(maxWidth: .infinity) + } + } + .offset(y: h + 6) + + // Value labels on right + VStack { + Text(formatValue(maxVal)) + Spacer() + Text(formatValue((maxVal + minVal) / 2)) + Spacer() + Text(formatValue(minVal)) + } + .font(.system(size: 8)) + .foregroundColor(Color.white.opacity(0.2)) + .frame(height: h) + .offset(x: w - 28) } } } private func formatValue(_ value: Double) -> String { - if value >= 1000 { - return String(format: "%.1fк", value / 1000) - } else if value == floor(value) { - return "\(Int(value))\(unit)" - } else { - return String(format: "%.1f\(unit)", value) - } + if value >= 1000 { return String(format: "%.0fк", value / 1000) } + if value == floor(value) { return "\(Int(value))\(unit)" } + return String(format: "%.1f", value) } } diff --git a/PulseHealth/Views/LoginView.swift b/PulseHealth/Views/LoginView.swift index 38c849b..9bb6c40 100644 --- a/PulseHealth/Views/LoginView.swift +++ b/PulseHealth/Views/LoginView.swift @@ -13,7 +13,7 @@ struct LoginView: View { var body: some View { ZStack { - Color(hex: "0a0a1a") + Color(hex: "06060f") .ignoresSafeArea() VStack(spacing: 32) { @@ -126,7 +126,7 @@ struct LoginView: View { password: password ) await MainActor.run { - authManager.login(token: response.authToken, user: response.user) + authManager.login(token: response.authToken, refreshToken: response.refreshToken, user: response.user) } } catch let error as APIError { await MainActor.run { errorMessage = error.errorDescription ?? "Ошибка"; isLoading = false } @@ -146,7 +146,7 @@ struct LoginView: View { name: name ) await MainActor.run { - authManager.login(token: response.authToken, user: response.user) + authManager.login(token: response.authToken, refreshToken: response.refreshToken, user: response.user) } } catch let error as APIError { await MainActor.run { errorMessage = error.errorDescription ?? "Ошибка"; isLoading = false } @@ -167,7 +167,7 @@ struct ForgotPasswordView: View { var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + Color(hex: "06060f").ignoresSafeArea() VStack(spacing: 24) { Text("Сброс пароля").font(.title2.bold()).foregroundColor(.white) if isSent { diff --git a/PulseHealth/Views/MainTabView.swift b/PulseHealth/Views/MainTabView.swift index 0e80117..ef32fbf 100644 --- a/PulseHealth/Views/MainTabView.swift +++ b/PulseHealth/Views/MainTabView.swift @@ -3,29 +3,104 @@ import SwiftUI struct MainTabView: View { @EnvironmentObject var authManager: AuthManager @AppStorage("colorScheme") private var colorSchemeRaw: String = "dark" + @State private var selectedTab = 0 var preferredColorScheme: ColorScheme? { colorSchemeRaw == "light" ? .light : .dark } + let tabs: [(icon: String, label: String, color: Color)] = [ + ("house.fill", "Главная", Theme.teal), + ("chart.bar.fill", "Трекер", Theme.indigo), + ("heart.fill", "Здоровье", Theme.pink), + ("building.columns.fill", "Накопления", Theme.purple), + ("gearshape.fill", "Ещё", Theme.blue), + ] + var body: some View { - TabView { - DashboardView() - .tabItem { Label("Главная", systemImage: "house.fill") } + VStack(spacing: 0) { + // Content + Group { + switch selectedTab { + case 0: DashboardView() + case 1: TrackerView() + case 2: HealthView() + case 3: SavingsView() + case 4: SettingsView() + default: DashboardView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) - TrackerView() - .tabItem { Label("Трекер", systemImage: "chart.bar.fill") } - - HealthView() - .tabItem { Label("Здоровье", systemImage: "heart.fill") } - - SavingsView() - .tabItem { Label("Накопления", systemImage: "building.columns.fill") } - - SettingsView() - .tabItem { Label("Настройки", systemImage: "gearshape.fill") } + // Custom Tab Bar + HStack(spacing: 0) { + ForEach(0.. Void + + var body: some View { + Button(action: action) { + VStack(spacing: 4) { + ZStack { + if isSelected { + // Glow effect + Circle() + .fill(color.opacity(0.3)) + .frame(width: 40, height: 40) + .blur(radius: 10) + + Circle() + .fill(color.opacity(0.15)) + .frame(width: 36, height: 36) + } + + Image(systemName: icon) + .font(.system(size: 18, weight: isSelected ? .semibold : .regular)) + .foregroundColor(isSelected ? color : Color(hex: "555566")) + } + .frame(height: 32) + + Text(label) + .font(.system(size: 10, weight: isSelected ? .medium : .regular)) + .foregroundColor(isSelected ? color : Color(hex: "555566")) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } +} diff --git a/PulseHealth/Views/Profile/ProfileView.swift b/PulseHealth/Views/Profile/ProfileView.swift index bf90385..67faaec 100644 --- a/PulseHealth/Views/Profile/ProfileView.swift +++ b/PulseHealth/Views/Profile/ProfileView.swift @@ -23,7 +23,7 @@ struct ChangePasswordView: View { var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + Color(hex: "06060f").ignoresSafeArea() VStack(spacing: 16) { RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12) Text("Смена пароля").font(.title3.bold()).foregroundColor(.white).padding(.top, 4) diff --git a/PulseHealth/Views/Savings/EditSavingsCategoryView.swift b/PulseHealth/Views/Savings/EditSavingsCategoryView.swift index ee17f3f..b47df6d 100644 --- a/PulseHealth/Views/Savings/EditSavingsCategoryView.swift +++ b/PulseHealth/Views/Savings/EditSavingsCategoryView.swift @@ -31,7 +31,7 @@ struct EditSavingsCategoryView: View { var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + 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 { diff --git a/PulseHealth/Views/Savings/SavingsView.swift b/PulseHealth/Views/Savings/SavingsView.swift index 7a6ec2f..affcfb5 100644 --- a/PulseHealth/Views/Savings/SavingsView.swift +++ b/PulseHealth/Views/Savings/SavingsView.swift @@ -7,7 +7,7 @@ struct SavingsView: View { var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + Color(hex: "06060f").ignoresSafeArea() VStack(spacing: 0) { HStack { Text("Накопления").font(.title.bold()).foregroundColor(.white) @@ -44,8 +44,9 @@ struct SavingsOverviewTab2: View { @State private var stats: SavingsStats? @State private var isLoading = true - var recurringCategories: [SavingsCategory] { categories.filter { $0.isRecurring == true } } - var hasOverdue: Bool { (stats?.overdueCount ?? 0) > 0 } + var monthlyDetails: [MonthlyPaymentDetail] { stats?.monthlyPaymentDetails ?? [] } + var overdues: [OverduePayment] { stats?.overdues ?? [] } + var hasOverdue: Bool { !overdues.isEmpty } var body: some View { ScrollView { @@ -88,23 +89,81 @@ struct SavingsOverviewTab2: View { .padding(.horizontal) } - // Overdue block - if hasOverdue, let s = stats { - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(Color(hex: "ff4757")) - .font(.title3) - VStack(alignment: .leading, spacing: 2) { - Text("Просроченные платежи").font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) - Text("\(s.overdueCount ?? 0) платежей на сумму \(formatAmt(s.overdueAmount ?? 0))") - .font(.caption).foregroundColor(.white.opacity(0.7)) + // Monthly payments from API + if !monthlyDetails.isEmpty { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Ежемесячные платежи") + .font(.subheadline.bold()).foregroundColor(.white) + Spacer() + Text(formatAmt(stats?.monthlyPayments ?? 0)) + .font(.callout.bold()).foregroundColor(Color(hex: "ffa502")) + } + .padding(.horizontal) + + ForEach(monthlyDetails) { detail in + HStack(spacing: 12) { + ZStack { + Circle().fill(Color(hex: "ffa502").opacity(0.15)).frame(width: 40, height: 40) + Image(systemName: "calendar.badge.clock").foregroundColor(Color(hex: "ffa502")).font(.body) + } + VStack(alignment: .leading, spacing: 2) { + Text(detail.categoryName).font(.callout).foregroundColor(.white) + Text("\(detail.day) числа каждого месяца").font(.caption2).foregroundColor(Color(hex: "8888aa")) + } + Spacer() + Text(formatAmt(detail.amount)) + .font(.callout.bold()).foregroundColor(Color(hex: "ffa502")) + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.04))) + .padding(.horizontal) + } + } + } + + // Overdues — detailed list + if hasOverdue { + VStack(alignment: .leading, spacing: 10) { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(Color(hex: "ff4757")) + Text("Просрочки") + .font(.subheadline.bold()).foregroundColor(Color(hex: "ff4757")) + Spacer() + Text(formatAmt(stats?.overdueAmount ?? 0)) + .font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) + } + .padding(.horizontal) + + ForEach(overdues) { overdue in + HStack(spacing: 12) { + ZStack { + Circle().fill(Color(hex: "ff4757").opacity(0.15)).frame(width: 40, height: 40) + Image(systemName: "exclamationmark.circle.fill").foregroundColor(Color(hex: "ff4757")).font(.body) + } + VStack(alignment: .leading, spacing: 2) { + Text(overdue.categoryName).font(.callout).foregroundColor(.white) + HStack(spacing: 6) { + Text(overdue.month) + .font(.caption2.bold()) + .foregroundColor(Color(hex: "ff4757")) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 4).fill(Color(hex: "ff4757").opacity(0.15))) + Text("\(overdue.daysOverdue) дн. просрочки") + .font(.caption2).foregroundColor(Color(hex: "8888aa")) + } + } + Spacer() + Text(formatAmt(overdue.amount)) + .font(.callout.bold()).foregroundColor(Color(hex: "ff4757")) + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "ff4757").opacity(0.06))) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "ff4757").opacity(0.2), lineWidth: 1)) + .padding(.horizontal) } - Spacer() } - .padding(16) - .background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "ff4757").opacity(0.12))) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "ff4757").opacity(0.3), lineWidth: 1)) - .padding(.horizontal) } // Categories progress @@ -117,34 +176,6 @@ struct SavingsOverviewTab2: View { } } } - - // Monthly payments - if !recurringCategories.isEmpty { - VStack(alignment: .leading, spacing: 10) { - Text("Ежемесячные платежи") - .font(.subheadline.bold()).foregroundColor(.white).padding(.horizontal) - ForEach(recurringCategories) { cat in - HStack(spacing: 12) { - ZStack { - Circle().fill(Color(hex: cat.colorHex).opacity(0.15)).frame(width: 40, height: 40) - Image(systemName: cat.icon).foregroundColor(Color(hex: cat.colorHex)).font(.body) - } - VStack(alignment: .leading, spacing: 2) { - Text(cat.name).font(.callout).foregroundColor(.white) - if let day = cat.recurringDay { - Text("\(day) числа каждого месяца").font(.caption2).foregroundColor(Color(hex: "8888aa")) - } - } - Spacer() - Text(formatAmt(cat.recurringAmount ?? 0)) - .font(.callout.bold()).foregroundColor(Color(hex: cat.colorHex)) - } - .padding(14) - .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.04))) - .padding(.horizontal) - } - } - } } Spacer(minLength: 80) } @@ -176,6 +207,7 @@ struct SavingsCategoriesTab: View { @State private var isLoading = true @State private var showAdd = false @State private var editingCategory: SavingsCategory? + @State private var recurringPlansCategory: SavingsCategory? var active: [SavingsCategory] { categories.filter { $0.isClosed != true } } var closed: [SavingsCategory] { categories.filter { $0.isClosed == true } } @@ -192,10 +224,12 @@ struct SavingsCategoriesTab: View { List { Section(header: Text("Активные").foregroundColor(Color(hex: "8888aa"))) { ForEach(active) { cat in - SavingsCategoryRow(category: cat) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .onTapGesture { editingCategory = cat } + SavingsCategoryRow(category: cat, showRecurringButton: cat.isRecurring == true) { + recurringPlansCategory = cat + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .onTapGesture { editingCategory = cat } } .onDelete { idx in let toDelete = idx.map { active[$0] } @@ -238,13 +272,19 @@ struct SavingsCategoriesTab: View { AddSavingsCategoryView(isPresented: $showAdd) { await load(refresh: true) } .presentationDetents([.large]) .presentationDragIndicator(.visible) - .presentationBackground(Color(hex: "0a0a1a")) + .presentationBackground(Color(hex: "06060f")) } - .sheet(item: ) { cat in + .sheet(item: $editingCategory) { cat in EditSavingsCategoryView(isPresented: .constant(true), category: cat) { await load(refresh: true) } .presentationDetents([.large]) .presentationDragIndicator(.visible) - .presentationBackground(Color(hex: "0a0a1a")) + .presentationBackground(Color(hex: "06060f")) + } + .sheet(item: $recurringPlansCategory) { cat in + RecurringPlansView(category: cat) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(hex: "06060f")) } } @@ -259,6 +299,8 @@ struct SavingsCategoriesTab: View { struct SavingsCategoryRow: View { let category: SavingsCategory + var showRecurringButton: Bool = false + var onRecurringTap: (() -> Void)? = nil var body: some View { HStack(spacing: 12) { ZStack { @@ -273,6 +315,14 @@ struct SavingsCategoryRow: View { Text(category.typeLabel).font(.caption).foregroundColor(Color(hex: "8888aa")) } Spacer() + if showRecurringButton { + Button(action: { onRecurringTap?() }) { + Image(systemName: "calendar.badge.clock") + .foregroundColor(Color(hex: "0D9488")) + .font(.callout) + } + .buttonStyle(.plain) + } Text(formatAmt(category.currentAmount ?? 0)) .font(.callout.bold()).foregroundColor(Color(hex: category.colorHex)) } @@ -302,7 +352,7 @@ struct AddSavingsCategoryView: View { var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + 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 { @@ -400,6 +450,7 @@ struct SavingsOperationsTab: View { @State private var selectedCategoryId: Int? = nil @State private var isLoading = true @State private var showAdd = false + @State private var editingTransaction: SavingsTransaction? var filtered: [SavingsTransaction] { guard let cid = selectedCategoryId else { return transactions } @@ -434,6 +485,7 @@ struct SavingsOperationsTab: View { SavingsTransactionRow2(transaction: tx) .listRowBackground(Color.clear) .listRowSeparator(.hidden) + .onTapGesture { editingTransaction = tx } } .onDelete { idx in let toDelete = idx.map { filtered[$0] } @@ -466,7 +518,16 @@ struct SavingsOperationsTab: View { AddSavingsTransactionView(isPresented: $showAdd, categories: categories) { await load(refresh: true) } .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) - .presentationBackground(Color(hex: "0a0a1a")) + .presentationBackground(Color(hex: "06060f")) + } + .sheet(item: $editingTransaction) { tx in + EditSavingsTransactionView(isPresented: .constant(true), transaction: tx, categories: categories) { + editingTransaction = nil + await load(refresh: true) + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationBackground(Color(hex: "06060f")) } } @@ -540,13 +601,15 @@ struct AddSavingsTransactionView: View { @State private var description = "" @State private var type = "deposit" @State private var selectedCategoryId: Int? = nil + @State private var date = Date() @State private var isLoading = false + @State private var errorMessage: String? var isDeposit: Bool { type == "deposit" } var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + 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 { @@ -607,12 +670,28 @@ struct AddSavingsTransactionView: View { } } + VStack(alignment: .leading, spacing: 8) { + Label("Дата", systemImage: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa")) + DatePicker("", selection: $date, displayedComponents: .date) + .labelsHidden() + .colorInvert() + .colorMultiply(Color(hex: "0D9488")) + } + VStack(alignment: .leading, spacing: 8) { Label("Описание", systemImage: "text.alignleft").font(.caption).foregroundColor(Color(hex: "8888aa")) TextField("Комментарий...", text: $description) .foregroundColor(.white).padding(14) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) } + + if let err = errorMessage { + Text(err) + .font(.caption).foregroundColor(Color(hex: "ff4757")) + .padding(10) + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(hex: "ff4757").opacity(0.1))) + } }.padding(20) } } @@ -623,11 +702,21 @@ struct AddSavingsTransactionView: View { guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")), let cid = selectedCategoryId else { return } isLoading = true + errorMessage = nil + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + let dateStr = df.string(from: date) Task { - let req = CreateSavingsTransactionRequest(categoryId: cid, amount: a, type: type, description: description.isEmpty ? nil : description) - try? await APIService.shared.createSavingsTransaction(token: authManager.token, request: req) - await onAdded() - await MainActor.run { isPresented = false } + do { + let req = CreateSavingsTransactionRequest(categoryId: cid, amount: a, type: type, description: description.isEmpty ? nil : description, date: dateStr) + try await APIService.shared.createSavingsTransaction(token: authManager.token, request: req) + await onAdded() + await MainActor.run { isPresented = false } + } catch { + await MainActor.run { + errorMessage = error.localizedDescription + isLoading = false + } + } } } } @@ -679,3 +768,324 @@ struct SavingsCategoryCard: View { v >= 1_000_000 ? String(format: "%.2f млн ₽", v / 1_000_000) : String(format: "%.0f ₽", v) } } + +// MARK: - EditSavingsTransactionView + +struct EditSavingsTransactionView: View { + @Binding var isPresented: Bool + @EnvironmentObject var authManager: AuthManager + let transaction: SavingsTransaction + let categories: [SavingsCategory] + let onSaved: () async -> Void + + @State private var amount: String + @State private var description: String + @State private var type: String + @State private var selectedCategoryId: Int? + @State private var date: Date + @State private var isLoading = false + @State private var errorMessage: String? + + var isDeposit: Bool { type == "deposit" } + + init(isPresented: Binding, transaction: SavingsTransaction, categories: [SavingsCategory], onSaved: @escaping () async -> Void) { + self._isPresented = isPresented + self.transaction = transaction + self.categories = categories + self.onSaved = onSaved + self._amount = State(initialValue: String(format: "%.0f", transaction.amount)) + self._description = State(initialValue: transaction.description ?? "") + self._type = State(initialValue: transaction.type) + self._selectedCategoryId = State(initialValue: transaction.categoryId) + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + let d = transaction.date.flatMap { df.date(from: String($0.prefix(10))) } ?? Date() + self._date = State(initialValue: d) + } + + 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 }.foregroundColor(Color(hex: "8888aa")) + Spacer() + Text("Редактировать").font(.headline).foregroundColor(.white) + Spacer() + Button(action: save) { + if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) } + else { Text("Сохранить").foregroundColor(amount.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) } + }.disabled(amount.isEmpty || isLoading) + } + .padding(.horizontal, 20).padding(.vertical, 16) + Divider().background(Color.white.opacity(0.1)) + ScrollView { + VStack(spacing: 20) { + HStack(spacing: 0) { + Button(action: { type = "deposit" }) { + Text("Пополнение ↓").font(.callout.bold()) + .foregroundColor(isDeposit ? .black : Color(hex: "0D9488")) + .frame(maxWidth: .infinity).padding(.vertical, 12) + .background(isDeposit ? Color(hex: "0D9488") : Color.clear) + } + Button(action: { type = "withdrawal" }) { + Text("Снятие ↑").font(.callout.bold()) + .foregroundColor(!isDeposit ? .black : Color(hex: "ff4757")) + .frame(maxWidth: .infinity).padding(.vertical, 12) + .background(!isDeposit ? Color(hex: "ff4757") : Color.clear) + } + } + .background(Color.white.opacity(0.07)).cornerRadius(12) + + HStack { + Text(isDeposit ? "+" : "−").font(.title.bold()) + .foregroundColor(isDeposit ? Color(hex: "0D9488") : Color(hex: "ff4757")) + TextField("0", text: $amount).keyboardType(.decimalPad) + .font(.system(size: 32, weight: .bold)).foregroundColor(.white).multilineTextAlignment(.center) + Text("₽").font(.title.bold()).foregroundColor(Color(hex: "8888aa")) + } + .padding(20) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.07))) + + VStack(alignment: .leading, spacing: 8) { + Label("Дата", systemImage: "calendar").font(.caption).foregroundColor(Color(hex: "8888aa")) + DatePicker("", selection: $date, displayedComponents: .date) + .labelsHidden().colorInvert().colorMultiply(Color(hex: "0D9488")) + } + + VStack(alignment: .leading, spacing: 8) { + Label("Категория", systemImage: "tag.fill").font(.caption).foregroundColor(Color(hex: "8888aa")) + ForEach(categories.filter { $0.isClosed != true }) { cat in + Button(action: { selectedCategoryId = selectedCategoryId == cat.id ? nil : cat.id }) { + HStack(spacing: 10) { + Image(systemName: cat.icon).foregroundColor(Color(hex: cat.colorHex)).font(.body) + Text(cat.name).font(.callout).foregroundColor(.white) + Spacer() + if selectedCategoryId == cat.id { Image(systemName: "checkmark").foregroundColor(Color(hex: "0D9488")) } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 12).fill(selectedCategoryId == cat.id ? Color(hex: "0D9488").opacity(0.15) : Color.white.opacity(0.05))) + } + } + } + + VStack(alignment: .leading, spacing: 8) { + Label("Описание", systemImage: "text.alignleft").font(.caption).foregroundColor(Color(hex: "8888aa")) + TextField("Комментарий...", text: $description) + .foregroundColor(.white).padding(14) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) + } + + if let err = errorMessage { + Text(err) + .font(.caption).foregroundColor(Color(hex: "ff4757")) + .padding(10) + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(hex: "ff4757").opacity(0.1))) + } + }.padding(20) + } + } + } + } + + func save() { + guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")), + let cid = selectedCategoryId else { return } + isLoading = true + errorMessage = nil + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + let dateStr = df.string(from: date) + Task { + do { + let req = CreateSavingsTransactionRequest(categoryId: cid, amount: a, type: type, + description: description.isEmpty ? nil : description, date: dateStr) + try await APIService.shared.updateSavingsTransaction(token: authManager.token, id: transaction.id, request: req) + await onSaved() + await MainActor.run { isPresented = false } + } catch { + await MainActor.run { + errorMessage = error.localizedDescription + isLoading = false + } + } + } + } +} + +// MARK: - RecurringPlansView + +struct RecurringPlansView: View { + @EnvironmentObject var authManager: AuthManager + let category: SavingsCategory + @State private var plans: [SavingsRecurringPlan] = [] + @State private var isLoading = true + @State private var showAdd = false + @State private var newAmount = "" + @State private var newDay = "1" + @State private var newEffective = Date() + @State private var editingPlan: SavingsRecurringPlan? + @State private var editAmount = "" + @State private var editDay = "" + + 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 { + Spacer() + Text("Регулярные платежи").font(.headline).foregroundColor(.white) + Spacer() + } + .padding(.horizontal, 20).padding(.vertical, 16) + Divider().background(Color.white.opacity(0.1)) + ScrollView { + VStack(spacing: 12) { + if isLoading { + ProgressView().tint(Color(hex: "0D9488")).padding(.top, 20) + } else if plans.isEmpty && !showAdd { + Text("Нет регулярных платежей").font(.callout).foregroundColor(Color(hex: "8888aa")) + .padding(.top, 20) + } else { + ForEach(plans) { plan in + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(formatAmt(plan.amount)).font(.callout.bold()).foregroundColor(.white) + if let day = plan.day { + Text("Каждый \(day) день месяца").font(.caption).foregroundColor(Color(hex: "8888aa")) + } + if let eff = plan.effective { + Text("С \(formatDate(eff))").font(.caption2).foregroundColor(Color(hex: "8888aa")) + } + } + Spacer() + Button(action: { + editingPlan = plan + editAmount = String(format: "%.0f", plan.amount) + editDay = String(plan.day ?? 1) + }) { + Image(systemName: "pencil").foregroundColor(Color(hex: "8888aa")) + } + Button(action: { Task { await deletePlan(plan) } }) { + Image(systemName: "trash").foregroundColor(Color(hex: "ff4757").opacity(0.7)) + } + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.05))) + .padding(.horizontal) + } + } + + if let plan = editingPlan { + VStack(spacing: 10) { + Text("Редактировать платёж").font(.caption.bold()).foregroundColor(Color(hex: "8888aa")) + HStack { + TextField("Сумма", text: $editAmount).keyboardType(.decimalPad) + .foregroundColor(.white).padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07))) + Text("₽").foregroundColor(Color(hex: "8888aa")) + TextField("День", text: $editDay).keyboardType(.numberPad) + .foregroundColor(.white).frame(width: 60).padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07))) + } + HStack { + Button("Отмена") { editingPlan = nil }.foregroundColor(Color(hex: "8888aa")) + Spacer() + Button("Сохранить") { Task { await updatePlan(plan) } } + .foregroundColor(Color(hex: "0D9488")).fontWeight(.semibold) + } + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.07))) + .padding(.horizontal) + } + + if showAdd { + VStack(spacing: 10) { + Text("Новый платёж").font(.caption.bold()).foregroundColor(Color(hex: "8888aa")) + HStack { + TextField("Сумма", text: $newAmount).keyboardType(.decimalPad) + .foregroundColor(.white).padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07))) + Text("₽").foregroundColor(Color(hex: "8888aa")) + TextField("День", text: $newDay).keyboardType(.numberPad) + .foregroundColor(.white).frame(width: 60).padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07))) + } + HStack { + Text("Начало").font(.caption).foregroundColor(Color(hex: "8888aa")) + DatePicker("", selection: $newEffective, displayedComponents: .date) + .labelsHidden().colorInvert().colorMultiply(Color(hex: "0D9488")) + } + HStack { + Button("Отмена") { showAdd = false }.foregroundColor(Color(hex: "8888aa")) + Spacer() + Button("Добавить") { Task { await addPlan() } } + .foregroundColor(Color(hex: "0D9488")).fontWeight(.semibold) + } + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.07))) + .padding(.horizontal) + } + + if !showAdd && editingPlan == nil { + Button(action: { showAdd = true }) { + Label("Добавить платёж", systemImage: "plus.circle") + .foregroundColor(Color(hex: "0D9488")) + .padding(14) + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "0D9488").opacity(0.08))) + } + .padding(.horizontal) + } + Spacer(minLength: 40) + } + .padding(.top, 12) + } + } + } + .task { await load() } + } + + func load() async { + isLoading = true + plans = (try? await APIService.shared.getRecurringPlans(token: authManager.token, categoryId: category.id)) ?? [] + isLoading = false + } + + func addPlan() async { + guard let a = Double(newAmount.replacingOccurrences(of: ",", with: ".")) else { return } + let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" + let effStr = df.string(from: newEffective) + let req = CreateRecurringPlanRequest(effective: effStr, amount: a, day: Int(newDay)) + if let plan = try? await APIService.shared.createRecurringPlan(token: authManager.token, categoryId: category.id, request: req) { + await MainActor.run { plans.append(plan); showAdd = false; newAmount = ""; newDay = "1" } + } + } + + func updatePlan(_ plan: SavingsRecurringPlan) async { + guard let a = Double(editAmount.replacingOccurrences(of: ",", with: ".")) else { return } + let req = UpdateRecurringPlanRequest(effective: plan.effective, amount: a, day: Int(editDay)) + if let updated = try? await APIService.shared.updateRecurringPlan(token: authManager.token, planId: plan.id, request: req) { + await MainActor.run { + if let idx = plans.firstIndex(where: { $0.id == plan.id }) { plans[idx] = updated } + editingPlan = nil + } + } + } + + func deletePlan(_ plan: SavingsRecurringPlan) async { + try? await APIService.shared.deleteRecurringPlan(token: authManager.token, planId: plan.id) + await MainActor.run { plans.removeAll { $0.id == plan.id } } + } + + func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) } + + func formatDate(_ 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])" + } +} diff --git a/PulseHealth/Views/Settings/SettingsView.swift b/PulseHealth/Views/Settings/SettingsView.swift index 07d90e4..ef75840 100644 --- a/PulseHealth/Views/Settings/SettingsView.swift +++ b/PulseHealth/Views/Settings/SettingsView.swift @@ -12,13 +12,21 @@ struct SettingsView: View { // Profile fields @State private var telegramChatId = "" - @State private var morningNotification = true - @State private var eveningNotification = true - @State private var morningTime = "09:00" - @State private var eveningTime = "21:00" @State private var timezone = "Europe/Moscow" @State private var username = "" + // Local notifications + @AppStorage("notif_morning") private var morningNotif = false + @AppStorage("notif_evening") private var eveningNotif = false + @AppStorage("notif_morning_hour") private var morningHour = 8 + @AppStorage("notif_morning_min") private var morningMin = 0 + @AppStorage("notif_evening_hour") private var eveningHour = 21 + @AppStorage("notif_evening_min") private var eveningMin = 0 + @AppStorage("notif_payments") private var paymentNotif = true + @State private var notifAuthorized = false + @State private var morningDate = Calendar.current.date(from: DateComponents(hour: 8, minute: 0))! + @State private var eveningDate = Calendar.current.date(from: DateComponents(hour: 21, minute: 0))! + var isDark: Bool { colorSchemeRaw != "light" } let timezones = [ @@ -30,7 +38,7 @@ struct SettingsView: View { var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + Color(hex: "06060f").ignoresSafeArea() ScrollView { VStack(spacing: 0) { // Header / Avatar @@ -74,77 +82,78 @@ struct SettingsView: View { } } - // MARK: Telegram - SettingsSection(title: "Telegram Бот") { - VStack(alignment: .leading, spacing: 8) { - Label("Chat ID", systemImage: "paperplane.fill") - .font(.caption).foregroundColor(Color(hex: "8888aa")) - TextField("Например: 123456789", text: $telegramChatId) - .keyboardType(.numbersAndPunctuation) - .foregroundColor(.white).padding(14) - .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07))) - HStack(spacing: 4) { - Image(systemName: "info.circle").font(.caption2).foregroundColor(Color(hex: "0D9488")) - Text("Напишите /start боту @pulse_tracking_bot, чтобы получить Chat ID") - .font(.caption2).foregroundColor(Color(hex: "8888aa")) - } - } - .padding(.horizontal, 4) - } - // MARK: Notifications SettingsSection(title: "Уведомления") { - VStack(spacing: 12) { - SettingsToggle(icon: "sunrise.fill", title: "Утренние уведомления", color: "ffa502", isOn: morningNotification) { - morningNotification.toggle() - } - if morningNotification { - HStack { - Text("Время").font(.callout).foregroundColor(Color(hex: "8888aa")) + if !notifAuthorized { + Button(action: { + Task { notifAuthorized = await NotificationService.shared.requestPermission() } + }) { + HStack(spacing: 14) { + GlowIcon(systemName: "bell.badge.fill", color: Theme.orange, size: 36, iconSize: .subheadline) + VStack(alignment: .leading, spacing: 2) { + Text("Включить уведомления").font(.callout).foregroundColor(.white) + Text("Нажми, чтобы разрешить").font(.caption).foregroundColor(Theme.textSecondary) + } Spacer() - TextField("09:00", text: $morningTime) - .keyboardType(.numbersAndPunctuation) - .foregroundColor(.white) - .multilineTextAlignment(.trailing) - .frame(width: 60) + Image(systemName: "arrow.right.circle.fill").foregroundColor(Theme.teal) } - .padding(.horizontal, 4) } - - Divider().background(Color.white.opacity(0.08)) - - SettingsToggle(icon: "moon.stars.fill", title: "Вечерние уведомления", color: "6366f1", isOn: eveningNotification) { - eveningNotification.toggle() - } - if eveningNotification { - HStack { - Text("Время").font(.callout).foregroundColor(Color(hex: "8888aa")) + } else { + VStack(spacing: 14) { + // Morning + NotifRow(icon: "sunrise.fill", title: "Утреннее", color: Theme.orange, + isOn: $morningNotif, date: $morningDate) + Divider().background(Color.white.opacity(0.06)) + // Evening + NotifRow(icon: "moon.stars.fill", title: "Вечернее", color: Theme.indigo, + isOn: $eveningNotif, date: $eveningDate) + Divider().background(Color.white.opacity(0.06)) + // Payments + HStack(spacing: 14) { + GlowIcon(systemName: "creditcard.fill", color: Theme.purple, size: 36, iconSize: .subheadline) + VStack(alignment: .leading, spacing: 2) { + Text("Платежи").font(.callout).foregroundColor(.white) + Text("За 5 дн, 1 день и в день оплаты").font(.caption2).foregroundColor(Theme.textSecondary) + } Spacer() - TextField("21:00", text: $eveningTime) - .keyboardType(.numbersAndPunctuation) - .foregroundColor(.white) - .multilineTextAlignment(.trailing) - .frame(width: 60) + Toggle("", isOn: $paymentNotif).tint(Theme.teal).labelsHidden() } - .padding(.horizontal, 4) } + .onChange(of: morningNotif) { applyNotifSchedule() } + .onChange(of: eveningNotif) { applyNotifSchedule() } + .onChange(of: morningDate) { saveTimes(); applyNotifSchedule() } + .onChange(of: eveningDate) { saveTimes(); applyNotifSchedule() } + .onChange(of: paymentNotif) { Task { await schedulePaymentNotifs() } } } } // MARK: Timezone SettingsSection(title: "Часовой пояс") { - VStack(alignment: .leading, spacing: 8) { - Label("Выберите часовой пояс", systemImage: "clock.fill") - .font(.caption).foregroundColor(Color(hex: "8888aa")) - Picker("Часовой пояс", selection: $timezone) { - ForEach(timezones, id: \.self) { tz in Text(tz).tag(tz) } + HStack(spacing: 14) { + GlowIcon(systemName: "clock.fill", color: Theme.blue, size: 36, iconSize: .subheadline) + VStack(alignment: .leading, spacing: 2) { + Text("Часовой пояс").font(.callout).foregroundColor(.white) + Text(timezoneDisplay(timezone)).font(.caption).foregroundColor(Theme.textSecondary) + } + Spacer() + Menu { + ForEach(timezones, id: \.self) { tz in + Button(action: { timezone = tz }) { + HStack { + Text(timezoneDisplay(tz)) + if timezone == tz { Image(systemName: "checkmark") } + } + } + } + } label: { + HStack(spacing: 4) { + Text(timezoneShort(timezone)).font(.callout.bold()).foregroundColor(Theme.teal) + Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundColor(Theme.teal) + } + .padding(.horizontal, 12).padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 10).fill(Theme.teal.opacity(0.15))) } - .pickerStyle(.wheel) - .frame(height: 120) - .clipped() - .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.04))) } - .padding(.horizontal, 4) } // MARK: Save Button @@ -186,20 +195,19 @@ struct SettingsView: View { ChangePasswordView(isPresented: $showPasswordChange) .presentationDetents([.medium]) .presentationDragIndicator(.visible) - .presentationBackground(Color(hex: "0a0a1a")) + .presentationBackground(Color(hex: "06060f")) } } func loadProfile() async { isLoading = true username = authManager.userName + notifAuthorized = await NotificationService.shared.isAuthorized() + morningDate = Calendar.current.date(from: DateComponents(hour: morningHour, minute: morningMin)) ?? morningDate + eveningDate = Calendar.current.date(from: DateComponents(hour: eveningHour, minute: eveningMin)) ?? eveningDate if let p = try? await APIService.shared.getProfile(token: authManager.token) { profile = p telegramChatId = p.telegramChatId ?? "" - morningNotification = p.morningNotification ?? true - eveningNotification = p.eveningNotification ?? true - morningTime = p.morningTime ?? "09:00" - eveningTime = p.eveningTime ?? "21:00" timezone = p.timezone ?? "Europe/Moscow" } isLoading = false @@ -209,10 +217,6 @@ struct SettingsView: View { isSaving = true let req = UpdateProfileRequest( telegramChatId: telegramChatId.isEmpty ? nil : telegramChatId, - morningNotification: morningNotification, - eveningNotification: eveningNotification, - morningTime: morningTime, - eveningTime: eveningTime, timezone: timezone ) _ = try? await APIService.shared.updateProfile(token: authManager.token, request: req) @@ -228,6 +232,51 @@ struct SettingsView: View { } isSaving = false } + + func applyNotifSchedule() { + let cal = Calendar.current + let mh = cal.component(.hour, from: morningDate) + let mm = cal.component(.minute, from: morningDate) + let eh = cal.component(.hour, from: eveningDate) + let em = cal.component(.minute, from: eveningDate) + NotificationService.shared.updateSchedule( + morning: morningNotif, morningTime: "\(mh):\(String(format: "%02d", mm))", + evening: eveningNotif, eveningTime: "\(eh):\(String(format: "%02d", em))" + ) + } + + func saveTimes() { + let cal = Calendar.current + morningHour = cal.component(.hour, from: morningDate) + morningMin = cal.component(.minute, from: morningDate) + eveningHour = cal.component(.hour, from: eveningDate) + eveningMin = cal.component(.minute, from: eveningDate) + } + + func schedulePaymentNotifs() async { + guard paymentNotif else { + NotificationService.shared.cancelPaymentReminders() + return + } + let stats = try? await APIService.shared.getSavingsStats(token: authManager.token) + guard let details = stats?.monthlyPaymentDetails else { return } + NotificationService.shared.schedulePaymentReminders(payments: details) + } + + func timezoneDisplay(_ tz: String) -> String { + guard let zone = TimeZone(identifier: tz) else { return tz } + let offset = zone.secondsFromGMT() / 3600 + let sign = offset >= 0 ? "+" : "" + let city = tz.split(separator: "/").last?.replacingOccurrences(of: "_", with: " ") ?? tz + return "\(city) (UTC\(sign)\(offset))" + } + + func timezoneShort(_ tz: String) -> String { + guard let zone = TimeZone(identifier: tz) else { return tz } + let offset = zone.secondsFromGMT() / 3600 + let sign = offset >= 0 ? "+" : "" + return "UTC\(sign)\(offset)" + } } // MARK: - SettingsSection @@ -242,7 +291,7 @@ struct SettingsSection: View { content() } .padding(16) - .background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04))) + .glassCard(cornerRadius: 16) .padding(.horizontal) } } @@ -258,14 +307,11 @@ struct SettingsToggle: View { let onToggle: () -> Void var body: some View { 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) - } + GlowIcon(systemName: icon, color: Color(hex: color), size: 36, iconSize: .subheadline) Text(title).font(.callout).foregroundColor(.white) Spacer() Toggle("", isOn: Binding(get: { isOn }, set: { _ in onToggle() })) - .tint(Color(hex: "0D9488")) + .tint(Theme.teal) } } } @@ -280,13 +326,39 @@ struct SettingsButton: View { 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) - } + GlowIcon(systemName: icon, color: Color(hex: color), size: 36, iconSize: .subheadline) Text(title).font(.callout).foregroundColor(.white) Spacer() - Image(systemName: "chevron.right").foregroundColor(Color(hex: "8888aa")).font(.caption) + Image(systemName: "chevron.right").foregroundColor(Theme.textSecondary).font(.caption) + } + } + } +} + +// MARK: - NotifRow + +struct NotifRow: View { + let icon: String + let title: String + let color: Color + @Binding var isOn: Bool + @Binding var date: Date + + var body: some View { + VStack(spacing: 8) { + HStack(spacing: 14) { + GlowIcon(systemName: icon, color: color, size: 36, iconSize: .subheadline) + Text(title).font(.callout).foregroundColor(.white) + Spacer() + Toggle("", isOn: $isOn).tint(Theme.teal).labelsHidden() + } + if isOn { + DatePicker("", selection: $date, displayedComponents: .hourAndMinute) + .datePickerStyle(.wheel) + .labelsHidden() + .frame(height: 100) + .clipped() + .environment(\.colorScheme, .dark) } } } diff --git a/PulseHealth/Views/Tasks/AddTaskView.swift b/PulseHealth/Views/Tasks/AddTaskView.swift index 1e992f8..95f2d89 100644 --- a/PulseHealth/Views/Tasks/AddTaskView.swift +++ b/PulseHealth/Views/Tasks/AddTaskView.swift @@ -12,13 +12,25 @@ struct AddTaskView: View { @State private var selectedColor = "#0D9488" @State private var hasDueDate = false @State private var dueDate = Date() + @State private var isRecurring = false + @State private var recurrenceType = "daily" + @State private var recurrenceInterval = "1" + @State private var hasRecurrenceEnd = false + @State private var recurrenceEndDate = Date().addingTimeInterval(86400 * 30) @State private var isLoading = false + @State private var errorMessage: String? + + let recurrenceTypes: [(String, String)] = [ + ("daily", "Ежедневно"), + ("weekly", "Еженедельно"), + ("monthly", "Ежемесячно"), + ("custom", "Каждые N дней") + ] let priorities: [(Int, String, String)] = [ (1, "Низкий", "8888aa"), (2, "Средний", "ffa502"), - (3, "Высокий", "ff4757"), - (4, "Срочный", "ff0000") + (3, "Высокий", "ff4757") ] let icons = ["✅","📌","🎯","💼","🏠","🛒","📞","🎓","💊","🚗", @@ -29,21 +41,22 @@ struct AddTaskView: View { var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + 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 }.foregroundColor(Color(hex: "8888aa")) + Button("Отмена") { isPresented = false } + .font(.callout).foregroundColor(Color(hex: "8888aa")) Spacer() Text("Новая задача").font(.headline).foregroundColor(.white) Spacer() Button(action: save) { - if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) } - else { Text("Добавить").foregroundColor(title.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) } + if isLoading { ProgressView().tint(Theme.teal).scaleEffect(0.8) } + else { Text("Готово").font(.callout.bold()).foregroundColor(title.isEmpty ? Color(hex: "8888aa") : Theme.teal) } }.disabled(title.isEmpty || isLoading) } - .padding(.horizontal, 20).padding(.vertical, 16) + .padding(.horizontal, 16).padding(.vertical, 14) Divider().background(Color.white.opacity(0.1)) ScrollView { VStack(spacing: 16) { @@ -106,7 +119,7 @@ struct AddTaskView: View { // Color VStack(alignment: .leading, spacing: 8) { Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa")) - HStack(spacing: 10) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 10) { ForEach(colors, id: \.self) { c in Button(action: { selectedColor = c }) { Circle().fill(Color(hex: String(c.dropFirst()))).frame(width: 32, height: 32) @@ -116,6 +129,57 @@ struct AddTaskView: View { } } } + // Recurrence + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("Повторение", systemImage: "repeat").font(.caption).foregroundColor(Color(hex: "8888aa")) + Spacer() + Toggle("", isOn: $isRecurring).tint(Color(hex: "0D9488")).labelsHidden() + } + if isRecurring { + VStack(spacing: 8) { + ForEach(recurrenceTypes, id: \.0) { rt in + Button(action: { recurrenceType = rt.0 }) { + HStack { + Text(rt.1).foregroundColor(recurrenceType == rt.0 ? .white : Color(hex: "8888aa")) + Spacer() + if recurrenceType == rt.0 { Image(systemName: "checkmark").foregroundColor(Color(hex: "0D9488")) } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(recurrenceType == rt.0 ? Color(hex: "0D9488").opacity(0.15) : Color.white.opacity(0.05))) + } + } + if recurrenceType == "custom" { + HStack { + Text("Каждые").foregroundColor(Color(hex: "8888aa")).font(.callout) + TextField("1", text: $recurrenceInterval).keyboardType(.numberPad) + .foregroundColor(.white).frame(width: 50).padding(8) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07))) + Text("дней").foregroundColor(Color(hex: "8888aa")).font(.callout) + } + } + HStack { + Label("Дата окончания", systemImage: "calendar.badge.minus").font(.caption).foregroundColor(Color(hex: "8888aa")) + Spacer() + Toggle("", isOn: $hasRecurrenceEnd).tint(Color(hex: "0D9488")).labelsHidden() + } + if hasRecurrenceEnd { + DatePicker("", selection: $recurrenceEndDate, in: Date()..., displayedComponents: .date) + .labelsHidden() + .colorInvert() + .colorMultiply(Color(hex: "0D9488")) + } + } + } + } + + if let err = errorMessage { + Text(err) + .font(.caption).foregroundColor(Color(hex: "ff4757")) + .padding(10) + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(hex: "ff4757").opacity(0.1))) + } }.padding(20) } } @@ -124,20 +188,34 @@ struct AddTaskView: View { func save() { isLoading = true + errorMessage = nil let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" let dueDateStr = hasDueDate ? df.string(from: dueDate) : nil + let recEndStr = (isRecurring && hasRecurrenceEnd) ? df.string(from: recurrenceEndDate) : nil + let interval = recurrenceType == "custom" ? (Int(recurrenceInterval) ?? 1) : nil Task { - let req = CreateTaskRequest( - title: title, - description: description.isEmpty ? nil : description, - priority: priority, - dueDate: dueDateStr, - icon: selectedIcon, - color: selectedColor - ) - try? await APIService.shared.createTask(token: authManager.token, request: req) - await onAdded() - await MainActor.run { isPresented = false } + do { + let req = CreateTaskRequest( + title: title, + description: description.isEmpty ? nil : description, + priority: priority, + dueDate: dueDateStr, + icon: selectedIcon, + color: selectedColor, + isRecurring: isRecurring ? true : nil, + recurrenceType: isRecurring ? recurrenceType : nil, + recurrenceInterval: interval, + recurrenceEndDate: recEndStr + ) + try await APIService.shared.createTask(token: authManager.token, request: req) + await onAdded() + await MainActor.run { isPresented = false } + } catch { + await MainActor.run { + errorMessage = error.localizedDescription + isLoading = false + } + } } } } diff --git a/PulseHealth/Views/Tasks/EditTaskView.swift b/PulseHealth/Views/Tasks/EditTaskView.swift index e72c5cf..3d423e7 100644 --- a/PulseHealth/Views/Tasks/EditTaskView.swift +++ b/PulseHealth/Views/Tasks/EditTaskView.swift @@ -13,13 +13,24 @@ struct EditTaskView: View { @State private var selectedColor: String @State private var hasDueDate: Bool @State private var dueDate: Date + @State private var isRecurring: Bool + @State private var recurrenceType: String + @State private var recurrenceInterval: String + @State private var hasRecurrenceEnd: Bool + @State private var recurrenceEndDate: Date @State private var isLoading = false + let recurrenceTypes: [(String, String)] = [ + ("daily", "Ежедневно"), + ("weekly", "Еженедельно"), + ("monthly", "Ежемесячно"), + ("custom", "Каждые N дней") + ] + let priorities: [(Int, String, String)] = [ (1, "Низкий", "8888aa"), (2, "Средний", "ffa502"), - (3, "Высокий", "ff4757"), - (4, "Срочный", "ff0000") + (3, "Высокий", "ff4757") ] let icons = ["✅","📌","🎯","💼","🏠","🛒","📞","🎓","💊","🚗", "📅","⚡","🔧","📬","💡","🏋️","🌿","🎵","✍️","🌏"] @@ -42,6 +53,16 @@ struct EditTaskView: View { self._hasDueDate = State(initialValue: false) self._dueDate = State(initialValue: Date()) } + self._isRecurring = State(initialValue: task.isRecurring ?? false) + self._recurrenceType = State(initialValue: task.recurrenceType ?? "daily") + self._recurrenceInterval = State(initialValue: String(task.recurrenceInterval ?? 1)) + if let endStr = task.recurrenceEndDate, let parsed = Self.parseDate(endStr) { + self._hasRecurrenceEnd = State(initialValue: true) + self._recurrenceEndDate = State(initialValue: parsed) + } else { + self._hasRecurrenceEnd = State(initialValue: false) + self._recurrenceEndDate = State(initialValue: Date().addingTimeInterval(86400 * 30)) + } } static func parseDate(_ str: String) -> Date? { @@ -52,21 +73,22 @@ struct EditTaskView: View { var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + 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 }.foregroundColor(Color(hex: "8888aa")) + Button("Отмена") { isPresented = false } + .font(.callout).foregroundColor(Color(hex: "8888aa")) Spacer() - Text("Редактировать задачу").font(.headline).foregroundColor(.white) + Text("Редактировать").font(.headline).foregroundColor(.white) Spacer() Button(action: save) { - if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) } - else { Text("Сохранить").foregroundColor(title.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) } + if isLoading { ProgressView().tint(Theme.teal).scaleEffect(0.8) } + else { Text("Готово").font(.callout.bold()).foregroundColor(title.isEmpty ? Color(hex: "8888aa") : Theme.teal) } }.disabled(title.isEmpty || isLoading) } - .padding(.horizontal, 20).padding(.vertical, 16) + .padding(.horizontal, 16).padding(.vertical, 14) Divider().background(Color.white.opacity(0.1)) ScrollView { VStack(spacing: 16) { @@ -123,7 +145,7 @@ struct EditTaskView: View { } VStack(alignment: .leading, spacing: 8) { Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa")) - HStack(spacing: 10) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 10) { ForEach(colors, id: \.self) { c in Button(action: { selectedColor = c }) { Circle().fill(Color(hex: String(c.dropFirst()))).frame(width: 32, height: 32) @@ -133,6 +155,49 @@ struct EditTaskView: View { } } } + // Recurrence + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("Повторение", systemImage: "repeat").font(.caption).foregroundColor(Color(hex: "8888aa")) + Spacer() + Toggle("", isOn: $isRecurring).tint(Color(hex: "0D9488")).labelsHidden() + } + if isRecurring { + VStack(spacing: 8) { + ForEach(recurrenceTypes, id: \.0) { rt in + Button(action: { recurrenceType = rt.0 }) { + HStack { + Text(rt.1).foregroundColor(recurrenceType == rt.0 ? .white : Color(hex: "8888aa")) + Spacer() + if recurrenceType == rt.0 { Image(systemName: "checkmark").foregroundColor(Color(hex: "0D9488")) } + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(recurrenceType == rt.0 ? Color(hex: "0D9488").opacity(0.15) : Color.white.opacity(0.05))) + } + } + if recurrenceType == "custom" { + HStack { + Text("Каждые").foregroundColor(Color(hex: "8888aa")).font(.callout) + TextField("1", text: $recurrenceInterval).keyboardType(.numberPad) + .foregroundColor(.white).frame(width: 50).padding(8) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.07))) + Text("дней").foregroundColor(Color(hex: "8888aa")).font(.callout) + } + } + HStack { + Label("Дата окончания", systemImage: "calendar.badge.minus").font(.caption).foregroundColor(Color(hex: "8888aa")) + Spacer() + Toggle("", isOn: $hasRecurrenceEnd).tint(Color(hex: "0D9488")).labelsHidden() + } + if hasRecurrenceEnd { + DatePicker("", selection: $recurrenceEndDate, in: Date()..., displayedComponents: .date) + .labelsHidden() + .colorInvert() + .colorMultiply(Color(hex: "0D9488")) + } + } + } + } }.padding(20) } } @@ -143,13 +208,21 @@ struct EditTaskView: View { isLoading = true let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd" let dueDateStr = hasDueDate ? df.string(from: dueDate) : nil + let recEndStr = (isRecurring && hasRecurrenceEnd) ? df.string(from: recurrenceEndDate) : nil + let interval = recurrenceType == "custom" ? (Int(recurrenceInterval) ?? 1) : nil Task { let req = UpdateTaskRequest( title: title, description: description.isEmpty ? nil : description, priority: priority, dueDate: dueDateStr, - completed: nil + completed: nil, + icon: selectedIcon, + color: selectedColor, + isRecurring: isRecurring ? true : nil, + recurrenceType: isRecurring ? recurrenceType : nil, + recurrenceInterval: interval, + recurrenceEndDate: recEndStr ) try? await APIService.shared.updateTask(token: authManager.token, id: task.id, request: req) await onSaved() diff --git a/PulseHealth/Views/Tasks/TasksView.swift b/PulseHealth/Views/Tasks/TasksView.swift index 47f3919..91c60f9 100644 --- a/PulseHealth/Views/Tasks/TasksView.swift +++ b/PulseHealth/Views/Tasks/TasksView.swift @@ -23,7 +23,7 @@ struct TasksView: View { var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + Color(hex: "06060f").ignoresSafeArea() VStack(spacing: 0) { // Header HStack { @@ -76,9 +76,9 @@ struct TasksView: View { } .sheet(isPresented: $showAddTask) { AddTaskView(isPresented: $showAddTask) { await loadTasks() } - .presentationDetents([.medium, .large]) + .presentationDetents([.large]) .presentationDragIndicator(.visible) - .presentationBackground(Color(hex: "0a0a1a")) + .presentationBackground(Color(hex: "06060f")) } .task { await loadTasks() } .refreshable { await loadTasks(refresh: true) } diff --git a/PulseHealth/Views/Tracker/TrackerView.swift b/PulseHealth/Views/Tracker/TrackerView.swift index 196b205..d903c84 100644 --- a/PulseHealth/Views/Tracker/TrackerView.swift +++ b/PulseHealth/Views/Tracker/TrackerView.swift @@ -8,7 +8,7 @@ struct TrackerView: View { var body: some View { ZStack { - Color(hex: "0a0a1a").ignoresSafeArea() + Color(hex: "06060f").ignoresSafeArea() VStack(spacing: 0) { // Header HStack { @@ -64,11 +64,10 @@ struct HabitListView: View { } else { List { ForEach(activeHabits) { habit in - HabitTrackerRow(habit: habit) { await toggleHabit(habit) } + HabitTrackerRow(habit: habit, onToggle: { await toggleHabit(habit) }, onEdit: { editingHabit = habit }) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 3, leading: 16, bottom: 3, trailing: 16)) - .onTapGesture { editingHabit = habit } } .onDelete { idx in let toDelete = idx.map { activeHabits[$0] } @@ -83,7 +82,7 @@ struct HabitListView: View { if !archivedHabits.isEmpty { Section(header: Text("Архив").foregroundColor(Color(hex: "8888aa"))) { ForEach(archivedHabits) { habit in - HabitTrackerRow(habit: habit, isArchived: true) {} + HabitTrackerRow(habit: habit, isArchived: true, onToggle: {}) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } @@ -113,13 +112,13 @@ struct HabitListView: View { AddHabitView(isPresented: $showAddHabit) { await loadHabits(refresh: true) } .presentationDetents([.large]) .presentationDragIndicator(.visible) - .presentationBackground(Color(hex: "0a0a1a")) + .presentationBackground(Color(hex: "06060f")) } .sheet(item: $editingHabit) { habit in EditHabitView(isPresented: .constant(true), habit: habit) { await loadHabits(refresh: true) } .presentationDetents([.large]) .presentationDragIndicator(.visible) - .presentationBackground(Color(hex: "0a0a1a")) + .presentationBackground(Color(hex: "06060f")) } .alert("Ошибка", isPresented: $showError) { Button("OK", role: .cancel) {} } message: { Text(errorMsg ?? "") } @@ -127,7 +126,14 @@ struct HabitListView: View { func loadHabits(refresh: Bool = false) async { if !refresh { isLoading = true } - habits = (try? await APIService.shared.getHabits(token: authManager.token, includeArchived: true)) ?? [] + var loaded = (try? await APIService.shared.getHabits(token: authManager.token, includeArchived: true)) ?? [] + // Enrich with completedToday + let today = todayStr() + for i in loaded.indices where loaded[i].isArchived != true { + let logs = (try? await APIService.shared.getHabitLogs(token: authManager.token, habitId: loaded[i].id, days: 1)) ?? [] + loaded[i].completedToday = logs.contains { $0.dateOnly == today } + } + habits = loaded isLoading = false } @@ -141,7 +147,7 @@ struct HabitListView: View { try await APIService.shared.unlogHabit(token: authManager.token, habitId: habit.id, logId: log.id) } } else { - try await APIService.shared.logHabit(token: authManager.token, id: habit.id) + try await APIService.shared.logHabit(token: authManager.token, id: habit.id, date: todayStr()) } await loadHabits(refresh: true) } catch APIError.serverError(let code, _) where code == 409 { @@ -172,36 +178,44 @@ struct HabitTrackerRow: View { let habit: Habit var isArchived: Bool = false let onToggle: () async -> Void + var onEdit: (() -> Void)? = nil var accentColor: Color { Color(hex: habit.accentColorHex.replacingOccurrences(of: "#", with: "")) } var isDone: Bool { habit.completedToday == true } var body: some View { HStack(spacing: 14) { - ZStack { - Circle().fill(accentColor.opacity(isArchived ? 0.05 : isDone ? 0.3 : 0.15)).frame(width: 44, height: 44) - Text(habit.displayIcon).font(.title3).opacity(isArchived ? 0.4 : 1) - } - VStack(alignment: .leading, spacing: 3) { - Text(habit.name) - .font(.callout.weight(.medium)) - .foregroundColor(isArchived ? Color(hex: "8888aa") : .white) - HStack(spacing: 8) { - Text(habit.frequencyLabel).font(.caption).foregroundColor(Color(hex: "8888aa")) - if let streak = habit.currentStreak, streak > 0 { - HStack(spacing: 2) { - Text("🔥").font(.caption2) - Text("\(streak) дн.").font(.caption).foregroundColor(Color(hex: "ffa502")) + // Tappable area for edit + HStack(spacing: 14) { + ZStack { + Circle().fill(accentColor.opacity(isArchived ? 0.05 : isDone ? 0.3 : 0.15)).frame(width: 44, height: 44) + Text(habit.displayIcon).font(.title3).opacity(isArchived ? 0.4 : 1) + } + VStack(alignment: .leading, spacing: 3) { + Text(habit.name) + .font(.callout.weight(.medium)) + .foregroundColor(isArchived ? Color(hex: "8888aa") : .white) + HStack(spacing: 8) { + Text(habit.frequencyLabel).font(.caption).foregroundColor(Color(hex: "8888aa")) + if let streak = habit.currentStreak, streak > 0 { + HStack(spacing: 2) { + Text("🔥").font(.caption2) + Text("\(streak) дн.").font(.caption).foregroundColor(Color(hex: "ffa502")) + } } } } + Spacer() } - Spacer() + .contentShape(Rectangle()) + .onTapGesture { onEdit?() } + if !isArchived { Button(action: { Task { await onToggle() } }) { Image(systemName: isDone ? "checkmark.circle.fill" : "circle") .font(.title2).foregroundColor(isDone ? accentColor : Color(hex: "8888aa")) } + .buttonStyle(.plain) } else { Text("Архив").font(.caption).foregroundColor(Color(hex: "8888aa")) .padding(.horizontal, 8).padding(.vertical, 4) @@ -259,11 +273,10 @@ struct TaskListView: View { } else { List { ForEach(filtered) { task in - TrackerTaskRow(task: task, onToggle: { await toggleTask(task) }) + TrackerTaskRow(task: task, onToggle: { await toggleTask(task) }, onEdit: { editingTask = task }) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16)) - .onTapGesture { editingTask = task } } .onDelete { idx in let toDelete = idx.map { filtered[$0] } @@ -294,15 +307,15 @@ struct TaskListView: View { .task { await loadTasks() } .sheet(isPresented: $showAddTask) { AddTaskView(isPresented: $showAddTask) { await loadTasks(refresh: true) } - .presentationDetents([.medium, .large]) + .presentationDetents([.large]) .presentationDragIndicator(.visible) - .presentationBackground(Color(hex: "0a0a1a")) + .presentationBackground(Color(hex: "06060f")) } .sheet(item: $editingTask) { task in EditTaskView(isPresented: .constant(true), task: task) { await loadTasks(refresh: true) } - .presentationDetents([.medium, .large]) + .presentationDetents([.large]) .presentationDragIndicator(.visible) - .presentationBackground(Color(hex: "0a0a1a")) + .presentationBackground(Color(hex: "06060f")) } .alert("Ошибка", isPresented: $showError) { Button("OK", role: .cancel) {} } message: { Text(errorMsg ?? "") } @@ -332,36 +345,44 @@ struct TaskListView: View { struct TrackerTaskRow: View { let task: PulseTask let onToggle: () async -> Void + var onEdit: (() -> Void)? = nil var body: some View { HStack(spacing: 12) { Button(action: { Task { await onToggle() } }) { Image(systemName: task.completed ? "checkmark.circle.fill" : "circle") .font(.title3) - .foregroundColor(task.completed ? Color(hex: "0D9488") : Color(hex: "8888aa")) + .foregroundColor(task.completed ? Theme.teal : Color(hex: "8888aa")) } - VStack(alignment: .leading, spacing: 3) { - Text(task.title) - .strikethrough(task.completed) - .foregroundColor(task.completed ? Color(hex: "8888aa") : .white) - .font(.callout) - HStack(spacing: 6) { - if let p = task.priority, p > 0 { - Text(task.priorityDisplayName) - .font(.caption2) - .foregroundColor(Color(hex: task.priorityColor)) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(RoundedRectangle(cornerRadius: 4).fill(Color(hex: task.priorityColor).opacity(0.15))) - } - if let due = task.dueDateFormatted { - Text(due).font(.caption2).foregroundColor(task.isOverdue ? Color(hex: "ff4757") : Color(hex: "8888aa")) - } - if task.isRecurring == true { - Image(systemName: "arrow.clockwise").font(.caption2).foregroundColor(Color(hex: "8888aa")) + .buttonStyle(.plain) + + // Tappable area for edit + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 3) { + Text(task.title) + .strikethrough(task.completed) + .foregroundColor(task.completed ? Color(hex: "8888aa") : .white) + .font(.callout) + HStack(spacing: 6) { + if let p = task.priority, p > 0 { + Text(task.priorityDisplayName) + .font(.caption2) + .foregroundColor(Color(hex: task.priorityColor)) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 4).fill(Color(hex: task.priorityColor).opacity(0.15))) + } + if let due = task.dueDateFormatted { + Text(due).font(.caption2).foregroundColor(task.isOverdue ? Theme.red : Color(hex: "8888aa")) + } + if task.isRecurring == true { + Image(systemName: "arrow.clockwise").font(.caption2).foregroundColor(Color(hex: "8888aa")) + } } } + Spacer() } - Spacer() + .contentShape(Rectangle()) + .onTapGesture { onEdit?() } } .padding(12) .background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05))) diff --git a/project.yml b/project.yml index fcca12a..f048917 100644 --- a/project.yml +++ b/project.yml @@ -10,8 +10,6 @@ targets: sources: PulseHealth entitlements: path: PulseHealth/PulseHealth.entitlements - capabilities: - - healthkit settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.daniil.pulsehealth @@ -19,4 +17,10 @@ targets: INFOPLIST_FILE: PulseHealth/Info.plist CODE_SIGN_STYLE: Automatic CODE_SIGN_ENTITLEMENTS: PulseHealth/PulseHealth.entitlements - DEVELOPMENT_TEAM: TEAM_ID_PLACEHOLDER + DEVELOPMENT_TEAM: V9AG8JTFLC + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + capabilities: + HealthKit: true + BackgroundModes: + modes: + - processing