feat: major app overhaul — API fixes, glassmorphism UI, health dashboard, notifications
API Integration: - Fix logHabit: send "date" instead of "completed_at" - Fix FinanceCategory: "icon" → "emoji" to match API - Fix task priorities: remove level 4, keep 1-3 matching API - Fix habit frequencies: map monthly/interval → "custom" for API - Add token refresh (401 → auto retry with new token) - Add proper error handling (remove try? in save functions, show errors in UI) - Add date field to savings transactions - Add MonthlyPaymentDetail and OverduePayment models - Fix habit completedToday: compute on client from logs (API doesn't return it) - Filter habits by day of week on client (daily/weekly/monthly/interval) Design System (glassmorphism): - New DesignSystem.swift: Theme colors, GlassCard modifier, GlowIcon, GlowStatCard - Custom tab bar with per-tab glow colors (VStack layout, not ZStack overlay) - Deep dark background #06060f across all views - Glass cards with gradient fill + stroke throughout app - App icon: glassmorphism style with teal glow Health Dashboard: - Compact ReadinessBanner with recommendation text - 8 metric tiles: sleep, HR, HRV, steps, SpO2, respiratory rate, energy, distance - Each tile with status indicator (good/ok/bad) and hint text - Heart rate card (min/avg/max) - Weekly trends card (averages) - Recovery score (weighted: 40% sleep, 35% HRV, 25% RHR) - Tips card with actionable recommendations - Sleep detail view with hypnogram (step chart of phases) - Sleep segments timeline from HealthKit (deep/rem/core/awake with exact times) - Line chart replacing bar chart for weekly data - Collect respiratory_rate and sleep phases with timestamps from HealthKit - Background sync every ~30min via BGProcessingTask Notifications: - NotificationService for local push notifications - Morning/evening reminders with native DatePicker (wheel) - Payment reminders: 5 days, 1 day, and day-of for recurring savings - Notification settings in Settings tab UI Fixes: - Fix color picker overflow: HStack → LazyVGrid 5 columns - Fix sheet headers: shorter text, proper padding - Fix task/habit toggle: separate tap zones (checkbox vs edit) - Fix deprecated onChange syntax for iOS 17+ - Savings overview: real monthly payments and detailed overdues from API - Settings: timezone as Menu picker, removed Telegram/server notifications sections - All sheets use .presentationDetents([.large]) Config: - project.yml: real DEVELOPMENT_TEAM, HealthKit + BackgroundModes capabilities - Info.plist: BGTaskScheduler + UIBackgroundModes - Assets.xcassets with AppIcon - CLAUDE.md project documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
146
CLAUDE.md
Normal file
146
CLAUDE.md
Normal file
@@ -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)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import BackgroundTasks
|
||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
init(hex: String) {
|
init(hex: String) {
|
||||||
@@ -20,14 +21,54 @@ extension Color {
|
|||||||
struct PulseApp: App {
|
struct PulseApp: App {
|
||||||
@StateObject private var authManager = AuthManager()
|
@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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
|
Group {
|
||||||
if authManager.isLoggedIn {
|
if authManager.isLoggedIn {
|
||||||
MainTabView()
|
MainTabView()
|
||||||
.environmentObject(authManager)
|
|
||||||
} else {
|
} else {
|
||||||
LoginView()
|
LoginView()
|
||||||
|
}
|
||||||
|
}
|
||||||
.environmentObject(authManager)
|
.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 {
|
class AuthManager: ObservableObject {
|
||||||
@Published var isLoggedIn: Bool = false
|
@Published var isLoggedIn: Bool = false
|
||||||
@Published var token: String = ""
|
@Published var token: String = ""
|
||||||
|
@Published var refreshToken: String = ""
|
||||||
@Published var userName: String = ""
|
@Published var userName: String = ""
|
||||||
@Published var userId: Int = 0
|
@Published var userId: Int = 0
|
||||||
@Published var healthApiKey: String = "health-cosmo-2026"
|
@Published var healthApiKey: String = "health-cosmo-2026"
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
token = UserDefaults.standard.string(forKey: "pulseToken") ?? ""
|
token = UserDefaults.standard.string(forKey: "pulseToken") ?? ""
|
||||||
|
refreshToken = UserDefaults.standard.string(forKey: "pulseRefreshToken") ?? ""
|
||||||
userName = UserDefaults.standard.string(forKey: "userName") ?? ""
|
userName = UserDefaults.standard.string(forKey: "userName") ?? ""
|
||||||
userId = UserDefaults.standard.integer(forKey: "userId")
|
userId = UserDefaults.standard.integer(forKey: "userId")
|
||||||
healthApiKey = UserDefaults.standard.string(forKey: "healthApiKey") ?? "health-cosmo-2026"
|
healthApiKey = UserDefaults.standard.string(forKey: "healthApiKey") ?? "health-cosmo-2026"
|
||||||
isLoggedIn = !token.isEmpty
|
isLoggedIn = !token.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
func login(token: String, user: UserInfo) {
|
func login(token: String, refreshToken: String? = nil, user: UserInfo) {
|
||||||
self.token = token
|
self.token = token
|
||||||
|
self.refreshToken = refreshToken ?? ""
|
||||||
self.userName = user.displayName
|
self.userName = user.displayName
|
||||||
self.userId = user.id
|
self.userId = user.id
|
||||||
UserDefaults.standard.set(token, forKey: "pulseToken")
|
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.displayName, forKey: "userName")
|
||||||
UserDefaults.standard.set(user.id, forKey: "userId")
|
UserDefaults.standard.set(user.id, forKey: "userId")
|
||||||
isLoggedIn = true
|
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() {
|
func logout() {
|
||||||
token = ""; userName = ""; userId = 0
|
token = ""; refreshToken = ""; userName = ""; userId = 0
|
||||||
UserDefaults.standard.removeObject(forKey: "pulseToken")
|
UserDefaults.standard.removeObject(forKey: "pulseToken")
|
||||||
|
UserDefaults.standard.removeObject(forKey: "pulseRefreshToken")
|
||||||
UserDefaults.standard.removeObject(forKey: "userName")
|
UserDefaults.standard.removeObject(forKey: "userName")
|
||||||
UserDefaults.standard.removeObject(forKey: "userId")
|
UserDefaults.standard.removeObject(forKey: "userId")
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
|
|||||||
BIN
PulseHealth/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
PulseHealth/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 768 KiB |
14
PulseHealth/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
14
PulseHealth/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
PulseHealth/Assets.xcassets/Contents.json
Normal file
6
PulseHealth/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,40 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key><string>ru</string>
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
|
<array>
|
||||||
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>com.daniil.pulsehealth.healthsync</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
|
</array>
|
||||||
<key>CFBundleName</key><string>$(PRODUCT_NAME)</string>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<key>CFBundlePackageType</key><string>$(PRODUCT_BUNDLE_TYPE)</string>
|
<string>ru</string>
|
||||||
<key>CFBundleShortVersionString</key><string>1.0</string>
|
<key>CFBundleExecutable</key>
|
||||||
<key>CFBundleVersion</key><string>1</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>NSHealthShareUsageDescription</key><string>Для отправки данных здоровья на ваш персональный дашборд</string>
|
<key>CFBundleIdentifier</key>
|
||||||
<key>NSHealthUpdateUsageDescription</key><string>Для записи данных тренировок</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
<string>Для отправки данных здоровья на ваш персональный дашборд</string>
|
||||||
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
|
<string>Для записи данных тренировок</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key><false/>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>UILaunchScreen</key><dict/>
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>processing</string>
|
||||||
|
</array>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct RegisterRequest: Codable {
|
|||||||
struct AuthResponse: Codable {
|
struct AuthResponse: Codable {
|
||||||
let token: String?
|
let token: String?
|
||||||
let accessToken: String?
|
let accessToken: String?
|
||||||
|
let refreshToken: String?
|
||||||
let user: UserInfo
|
let user: UserInfo
|
||||||
|
|
||||||
var authToken: String { token ?? accessToken ?? "" }
|
var authToken: String { token ?? accessToken ?? "" }
|
||||||
@@ -21,10 +22,33 @@ struct AuthResponse: Codable {
|
|||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case token
|
case token
|
||||||
case accessToken = "access_token"
|
case accessToken = "access_token"
|
||||||
|
case refreshToken = "refresh_token"
|
||||||
case user
|
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 {
|
struct UserInfo: Codable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let email: String
|
let email: String
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ struct FinanceTransaction: Codable, Identifiable {
|
|||||||
var type: String // "income" or "expense"
|
var type: String // "income" or "expense"
|
||||||
var date: String?
|
var date: String?
|
||||||
var createdAt: String?
|
var createdAt: String?
|
||||||
|
var categoryName: String?
|
||||||
|
var categoryEmoji: String?
|
||||||
|
|
||||||
var isIncome: Bool { type == "income" }
|
var isIncome: Bool { type == "income" }
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ struct FinanceTransaction: Codable, Identifiable {
|
|||||||
case id, amount, description, type, date
|
case id, amount, description, type, date
|
||||||
case categoryId = "category_id"
|
case categoryId = "category_id"
|
||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
|
case categoryName = "category_name"
|
||||||
|
case categoryEmoji = "category_emoji"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,9 +38,16 @@ struct FinanceTransaction: Codable, Identifiable {
|
|||||||
struct FinanceCategory: Codable, Identifiable {
|
struct FinanceCategory: Codable, Identifiable {
|
||||||
let id: Int
|
let id: Int
|
||||||
var name: String
|
var name: String
|
||||||
var icon: String?
|
var emoji: String?
|
||||||
var color: String?
|
var color: String?
|
||||||
var type: 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
|
// MARK: - FinanceSummary
|
||||||
@@ -65,11 +76,11 @@ struct CategorySpend: Codable, Identifiable {
|
|||||||
var categoryId: Int?
|
var categoryId: Int?
|
||||||
var categoryName: String?
|
var categoryName: String?
|
||||||
var total: Double?
|
var total: Double?
|
||||||
var icon: String?
|
var emoji: String?
|
||||||
var color: String?
|
var color: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case total, icon, color
|
case total, emoji, color
|
||||||
case categoryId = "category_id"
|
case categoryId = "category_id"
|
||||||
case categoryName = "category_name"
|
case categoryName = "category_name"
|
||||||
}
|
}
|
||||||
@@ -111,3 +122,12 @@ struct CreateTransactionRequest: Codable {
|
|||||||
case categoryId = "category_id"
|
case categoryId = "category_id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - CreateFinanceCategoryRequest
|
||||||
|
|
||||||
|
struct CreateFinanceCategoryRequest: Codable {
|
||||||
|
var name: String
|
||||||
|
var type: String // "expense" or "income"
|
||||||
|
var emoji: String?
|
||||||
|
var budget: Double?
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,33 @@ struct LatestHealthResponse: Codable {
|
|||||||
let hrv: HRVData?
|
let hrv: HRVData?
|
||||||
let steps: StepsData?
|
let steps: StepsData?
|
||||||
let activeEnergy: EnergyData?
|
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 {
|
struct SleepData: Codable {
|
||||||
@@ -37,6 +64,42 @@ struct SleepData: Codable {
|
|||||||
let core: Double?
|
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 {
|
struct HeartRateData: Codable {
|
||||||
let avg: Int?
|
let avg: Int?
|
||||||
let min: Int?
|
let min: Int?
|
||||||
|
|||||||
@@ -107,8 +107,10 @@ struct SavingsStats: Codable {
|
|||||||
var totalWithdrawals: Double?
|
var totalWithdrawals: Double?
|
||||||
var categoriesCount: Int?
|
var categoriesCount: Int?
|
||||||
var monthlyPayments: Double?
|
var monthlyPayments: Double?
|
||||||
|
var monthlyPaymentDetails: [MonthlyPaymentDetail]?
|
||||||
var overdueAmount: Double?
|
var overdueAmount: Double?
|
||||||
var overdueCount: Int?
|
var overdueCount: Int?
|
||||||
|
var overdues: [OverduePayment]?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case totalBalance = "total_balance"
|
case totalBalance = "total_balance"
|
||||||
@@ -116,8 +118,47 @@ struct SavingsStats: Codable {
|
|||||||
case totalWithdrawals = "total_withdrawals"
|
case totalWithdrawals = "total_withdrawals"
|
||||||
case categoriesCount = "categories_count"
|
case categoriesCount = "categories_count"
|
||||||
case monthlyPayments = "monthly_payments"
|
case monthlyPayments = "monthly_payments"
|
||||||
|
case monthlyPaymentDetails = "monthly_payment_details"
|
||||||
case overdueAmount = "overdue_amount"
|
case overdueAmount = "overdue_amount"
|
||||||
case overdueCount = "overdue_count"
|
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"
|
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?
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ struct PulseTask: Codable, Identifiable {
|
|||||||
var isRecurring: Bool?
|
var isRecurring: Bool?
|
||||||
var recurrenceType: String?
|
var recurrenceType: String?
|
||||||
var recurrenceInterval: Int?
|
var recurrenceInterval: Int?
|
||||||
|
var recurrenceEndDate: String?
|
||||||
|
|
||||||
var priorityColor: String {
|
var priorityColor: String {
|
||||||
switch priority {
|
switch priority {
|
||||||
case 4: return "ff0000"
|
|
||||||
case 3: return "ff4757"
|
case 3: return "ff4757"
|
||||||
case 2: return "ffa502"
|
case 2: return "ffa502"
|
||||||
default: return "8888aa"
|
default: return "8888aa"
|
||||||
@@ -31,7 +31,6 @@ struct PulseTask: Codable, Identifiable {
|
|||||||
case 1: return "Низкий"
|
case 1: return "Низкий"
|
||||||
case 2: return "Средний"
|
case 2: return "Средний"
|
||||||
case 3: return "Высокий"
|
case 3: return "Высокий"
|
||||||
case 4: return "Срочный"
|
|
||||||
default: return "Без приоритета"
|
default: return "Без приоритета"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,6 +63,7 @@ struct PulseTask: Codable, Identifiable {
|
|||||||
case isRecurring = "is_recurring"
|
case isRecurring = "is_recurring"
|
||||||
case recurrenceType = "recurrence_type"
|
case recurrenceType = "recurrence_type"
|
||||||
case recurrenceInterval = "recurrence_interval"
|
case recurrenceInterval = "recurrence_interval"
|
||||||
|
case recurrenceEndDate = "recurrence_end_date"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +92,18 @@ struct CreateTaskRequest: Codable {
|
|||||||
var dueDate: String?
|
var dueDate: String?
|
||||||
var icon: String?
|
var icon: String?
|
||||||
var color: String?
|
var color: String?
|
||||||
|
var isRecurring: Bool?
|
||||||
|
var recurrenceType: String?
|
||||||
|
var recurrenceInterval: Int?
|
||||||
|
var recurrenceEndDate: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case title, description, priority, icon, color
|
case title, description, priority, icon, color
|
||||||
case dueDate = "due_date"
|
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 priority: Int?
|
||||||
var dueDate: String?
|
var dueDate: String?
|
||||||
var completed: Bool?
|
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 {
|
enum CodingKeys: String, CodingKey {
|
||||||
case title, description, priority, completed
|
case title, description, priority, completed, icon, color
|
||||||
case dueDate = "due_date"
|
case dueDate = "due_date"
|
||||||
|
case isRecurring = "is_recurring"
|
||||||
|
case recurrenceType = "recurrence_type"
|
||||||
|
case recurrenceInterval = "recurrence_interval"
|
||||||
|
case recurrenceEndDate = "recurrence_end_date"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.developer.healthkit</key>
|
<key>com.apple.developer.healthkit</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.developer.healthkit.access</key>
|
<key>com.apple.developer.healthkit.background-delivery</key>
|
||||||
<array>
|
<true/>
|
||||||
<string>health-records</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ enum APIError: Error, LocalizedError {
|
|||||||
class APIService {
|
class APIService {
|
||||||
static let shared = APIService()
|
static let shared = APIService()
|
||||||
let baseURL = "https://api.digital-home.site"
|
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 {
|
private func makeRequest(_ path: String, method: String = "GET", token: String? = nil, body: Data? = nil) -> URLRequest {
|
||||||
var req = URLRequest(url: URL(string: "\(baseURL)\(path)")!)
|
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 req = makeRequest(path, method: method, token: token, body: body)
|
||||||
let (data, response) = try await URLSession.shared.data(for: req)
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
guard let http = response as? HTTPURLResponse else { throw APIError.networkError("Нет ответа") }
|
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 == 401 { throw APIError.unauthorized }
|
||||||
if http.statusCode >= 400 {
|
if http.statusCode >= 400 {
|
||||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown"
|
let msg = String(data: data, encoding: .utf8) ?? "Unknown"
|
||||||
@@ -46,7 +68,6 @@ class APIService {
|
|||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
do { return try decoder.decode(T.self, from: data) }
|
do { return try decoder.decode(T.self, from: data) }
|
||||||
catch {
|
catch {
|
||||||
// Debug: print first 200 chars of response
|
|
||||||
let snippet = String(data: data, encoding: .utf8)?.prefix(200) ?? ""
|
let snippet = String(data: data, encoding: .utf8)?.prefix(200) ?? ""
|
||||||
throw APIError.decodingError("\(error.localizedDescription) | Response: \(snippet)")
|
throw APIError.decodingError("\(error.localizedDescription) | Response: \(snippet)")
|
||||||
}
|
}
|
||||||
@@ -68,6 +89,11 @@ class APIService {
|
|||||||
return try await fetch("/auth/me", token: token)
|
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
|
// MARK: - Profile
|
||||||
|
|
||||||
func getProfile(token: String) async throws -> UserProfile {
|
func getProfile(token: String) async throws -> UserProfile {
|
||||||
@@ -136,7 +162,7 @@ class APIService {
|
|||||||
|
|
||||||
func logHabit(token: String, id: Int, date: String? = nil) async throws {
|
func logHabit(token: String, id: Int, date: String? = nil) async throws {
|
||||||
var params: [String: Any] = [:]
|
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 body = try JSONSerialization.data(withJSONObject: params)
|
||||||
let _: EmptyResponse = try await fetch("/habits/\(id)/log", method: "POST", token: token, body: body)
|
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 {
|
func deleteSavingsTransaction(token: String, id: Int) async throws {
|
||||||
let _: EmptyResponse = try await fetch("/savings/transactions/\(id)", method: "DELETE", token: token)
|
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 {}
|
struct EmptyResponse: Codable {}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class HealthKitService: ObservableObject {
|
|||||||
HKQuantityType(.activeEnergyBurned),
|
HKQuantityType(.activeEnergyBurned),
|
||||||
HKQuantityType(.oxygenSaturation),
|
HKQuantityType(.oxygenSaturation),
|
||||||
HKQuantityType(.distanceWalkingRunning),
|
HKQuantityType(.distanceWalkingRunning),
|
||||||
|
HKQuantityType(.respiratoryRate),
|
||||||
HKCategoryType(.sleepAnalysis),
|
HKCategoryType(.sleepAnalysis),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -116,15 +117,136 @@ class HealthKitService: ObservableObject {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Respiratory Rate (дыхание)
|
||||||
|
let rrValues = await fetchAllSamples(.respiratoryRate, unit: HKUnit.count().unitDivided(by: .minute()), predicate: predicate)
|
||||||
|
if !rrValues.isEmpty {
|
||||||
|
let rrData = rrValues.map { sample -> [String: Any] in
|
||||||
|
["date": dateFormatter.string(from: sample.startDate), "qty": sample.quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute()))]
|
||||||
|
}
|
||||||
|
metrics.append([
|
||||||
|
"name": "respiratory_rate",
|
||||||
|
"units": "breaths/min",
|
||||||
|
"data": rrData
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sleep Analysis — расширенный диапазон (последние 24 ча<EFBFBD><EFBFBD>а, чтобы захватить ночной сон)
|
||||||
|
let sleepData = await fetchSleepData(dateFormatter: dateFormatter)
|
||||||
|
if !sleepData.isEmpty {
|
||||||
|
metrics.append([
|
||||||
|
"name": "sleep_analysis",
|
||||||
|
"units": "hr",
|
||||||
|
"data": sleepData
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
return metrics
|
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
|
// MARK: - Send to Server
|
||||||
|
|
||||||
func syncToServer(apiKey: String) async throws {
|
func syncToServer(apiKey: String) async throws {
|
||||||
await MainActor.run { isSyncing = true }
|
await MainActor.run { isSyncing = true }
|
||||||
defer { Task { @MainActor in isSyncing = false } }
|
defer { Task { @MainActor in isSyncing = false } }
|
||||||
|
|
||||||
|
guard isAvailable else {
|
||||||
|
throw HealthKitError.notAvailable
|
||||||
|
}
|
||||||
|
|
||||||
try await requestAuthorization()
|
try await requestAuthorization()
|
||||||
|
|
||||||
let metrics = await collectAllMetrics()
|
let metrics = await collectAllMetrics()
|
||||||
@@ -151,11 +273,12 @@ class HealthKitService: ObservableObject {
|
|||||||
request.httpBody = jsonData
|
request.httpBody = jsonData
|
||||||
request.timeoutInterval = 30
|
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,
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
(200...299).contains(httpResponse.statusCode) else {
|
(200...299).contains(httpResponse.statusCode) else {
|
||||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
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)
|
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
|
// MARK: - Errors
|
||||||
|
|
||||||
enum HealthKitError: Error, LocalizedError {
|
enum HealthKitError: Error, LocalizedError {
|
||||||
|
case notAvailable
|
||||||
case noData
|
case noData
|
||||||
case invalidURL
|
case invalidURL
|
||||||
case serverError(Int)
|
case serverError(Int, String)
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .noData: return "Нет данных HealthKit за сегодня"
|
case .notAvailable: return "HealthKit недоступен на этом устройстве"
|
||||||
|
case .noData: return "Нет данных HealthKit за сегодня. Убедитесь, что Apple Watch синхронизированы"
|
||||||
case .invalidURL: return "Неверный URL сервера"
|
case .invalidURL: return "Неверный URL сервера"
|
||||||
case .serverError(let code): return "Ошибка сервера: \(code)"
|
case .serverError(let code, let body): return "Ошибка сервера (\(code)): \(body.prefix(100))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
169
PulseHealth/Services/NotificationService.swift
Normal file
169
PulseHealth/Services/NotificationService.swift
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import UserNotifications
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
static let shared = NotificationService()
|
||||||
|
|
||||||
|
// MARK: - Permission
|
||||||
|
|
||||||
|
func requestPermission() async -> Bool {
|
||||||
|
do {
|
||||||
|
let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])
|
||||||
|
return granted
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAuthorized() async -> Bool {
|
||||||
|
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||||
|
return settings.authorizationStatus == .authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Morning Reminder
|
||||||
|
|
||||||
|
func scheduleMorningReminder(hour: Int, minute: Int) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Доброе утро!"
|
||||||
|
content.body = "Посмотри свои привычки и задачи на сегодня"
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
var components = DateComponents()
|
||||||
|
components.hour = hour
|
||||||
|
components.minute = minute
|
||||||
|
|
||||||
|
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
|
||||||
|
let request = UNNotificationRequest(identifier: "morning_reminder", content: content, trigger: trigger)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Evening Reminder
|
||||||
|
|
||||||
|
func scheduleEveningReminder(hour: Int, minute: Int) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Итоги дня"
|
||||||
|
content.body = "Проверь, все ли привычки выполнены сегодня"
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
var components = DateComponents()
|
||||||
|
components.hour = hour
|
||||||
|
components.minute = minute
|
||||||
|
|
||||||
|
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
|
||||||
|
let request = UNNotificationRequest(identifier: "evening_reminder", content: content, trigger: trigger)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Task Deadline Reminder
|
||||||
|
|
||||||
|
func scheduleTaskReminder(taskId: Int, title: String, dueDate: Date) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Задача скоро"
|
||||||
|
content.body = title
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
// За 1 час до дедлайна
|
||||||
|
let reminderDate = dueDate.addingTimeInterval(-3600)
|
||||||
|
guard reminderDate > Date() else { return }
|
||||||
|
|
||||||
|
let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: reminderDate)
|
||||||
|
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
|
||||||
|
let request = UNNotificationRequest(identifier: "task_\(taskId)", content: content, trigger: trigger)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Habit Reminder
|
||||||
|
|
||||||
|
func scheduleHabitReminder(habitId: Int, name: String, hour: Int, minute: Int) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Привычка"
|
||||||
|
content.body = name
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
var components = DateComponents()
|
||||||
|
components.hour = hour
|
||||||
|
components.minute = minute
|
||||||
|
|
||||||
|
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
|
||||||
|
let request = UNNotificationRequest(identifier: "habit_\(habitId)", content: content, trigger: trigger)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cancel
|
||||||
|
|
||||||
|
func cancelReminder(_ identifier: String) {
|
||||||
|
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelAllReminders() {
|
||||||
|
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Payment Reminders
|
||||||
|
|
||||||
|
func schedulePaymentReminders(payments: [MonthlyPaymentDetail]) {
|
||||||
|
cancelPaymentReminders()
|
||||||
|
|
||||||
|
let cal = Calendar.current
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
for payment in payments {
|
||||||
|
let day = payment.day
|
||||||
|
guard day >= 1, day <= 28 else { continue }
|
||||||
|
|
||||||
|
// Build due date for this month
|
||||||
|
var components = cal.dateComponents([.year, .month], from: now)
|
||||||
|
components.day = day
|
||||||
|
components.hour = 11
|
||||||
|
components.minute = 0
|
||||||
|
guard let dueDate = cal.date(from: components) else { continue }
|
||||||
|
|
||||||
|
let offsets = [(-5, "через 5 дней"), (-1, "завтра"), (0, "сегодня")]
|
||||||
|
|
||||||
|
for (offset, label) in offsets {
|
||||||
|
guard let notifDate = cal.date(byAdding: .day, value: offset, to: dueDate),
|
||||||
|
notifDate > now else { continue }
|
||||||
|
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Платёж \(label)"
|
||||||
|
content.body = "\(payment.categoryName): \(Int(payment.amount)) ₽ — \(day) числа"
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
let trigger = UNCalendarNotificationTrigger(
|
||||||
|
dateMatching: cal.dateComponents([.year, .month, .day, .hour, .minute], from: notifDate),
|
||||||
|
repeats: false
|
||||||
|
)
|
||||||
|
let id = "payment_\(payment.categoryId)_\(offset)"
|
||||||
|
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: id, content: content, trigger: trigger))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelPaymentReminders() {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
center.getPendingNotificationRequests { requests in
|
||||||
|
let ids = requests.filter { $0.identifier.hasPrefix("payment_") }.map(\.identifier)
|
||||||
|
center.removePendingNotificationRequests(withIdentifiers: ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Update from settings
|
||||||
|
|
||||||
|
func updateSchedule(morning: Bool, morningTime: String, evening: Bool, eveningTime: String) {
|
||||||
|
cancelReminder("morning_reminder")
|
||||||
|
cancelReminder("evening_reminder")
|
||||||
|
|
||||||
|
if morning {
|
||||||
|
let parts = morningTime.split(separator: ":").compactMap { Int($0) }
|
||||||
|
if parts.count == 2 { scheduleMorningReminder(hour: parts[0], minute: parts[1]) }
|
||||||
|
}
|
||||||
|
if evening {
|
||||||
|
let parts = eveningTime.split(separator: ":").compactMap { Int($0) }
|
||||||
|
if parts.count == 2 { scheduleEveningReminder(hour: parts[0], minute: parts[1]) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,8 +41,8 @@ struct DashboardView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Theme.bg.ignoresSafeArea()
|
||||||
ScrollView {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// MARK: Header
|
// MARK: Header
|
||||||
HStack {
|
HStack {
|
||||||
@@ -50,7 +50,7 @@ struct DashboardView: View {
|
|||||||
Text("\(greeting), \(authManager.userName)!")
|
Text("\(greeting), \(authManager.userName)!")
|
||||||
.font(.title2.bold()).foregroundColor(.white)
|
.font(.title2.bold()).foregroundColor(.white)
|
||||||
Text(Date(), style: .date)
|
Text(Date(), style: .date)
|
||||||
.font(.subheadline).foregroundColor(Color(hex: "8888aa"))
|
.font(.subheadline).foregroundColor(Theme.textSecondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -58,51 +58,47 @@ struct DashboardView: View {
|
|||||||
.padding(.top)
|
.padding(.top)
|
||||||
|
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView().tint(Color(hex: "0D9488")).padding(.top, 40)
|
ProgressView().tint(Theme.teal).padding(.top, 40)
|
||||||
} else {
|
} else {
|
||||||
// MARK: Day Progress
|
// MARK: Day Progress
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Прогресс дня")
|
Text("Прогресс дня")
|
||||||
.font(.subheadline).foregroundColor(Color(hex: "8888aa"))
|
.font(.subheadline.weight(.medium)).foregroundColor(Theme.textSecondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(completedHabitsToday)/\(totalHabitsToday) привычек")
|
Text("\(completedHabitsToday)/\(totalHabitsToday)")
|
||||||
.font(.caption).foregroundColor(Color(hex: "0D9488"))
|
.font(.caption.bold()).foregroundColor(Theme.teal)
|
||||||
}
|
}
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
.fill(Color.white.opacity(0.1))
|
.fill(Color.white.opacity(0.08))
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
.fill(LinearGradient(colors: [Color(hex: "0D9488"), Color(hex: "14b8a6")], startPoint: .leading, endPoint: .trailing))
|
.fill(LinearGradient(colors: [Theme.teal, Theme.tealLight], startPoint: .leading, endPoint: .trailing))
|
||||||
.frame(width: geo.size.width * dayProgress)
|
.frame(width: geo.size.width * dayProgress)
|
||||||
|
.shadow(color: Theme.teal.opacity(0.5), radius: 8, y: 0)
|
||||||
.animation(.easeInOut(duration: 0.5), value: dayProgress)
|
.animation(.easeInOut(duration: 0.5), value: dayProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 8)
|
.frame(height: 8)
|
||||||
}
|
}
|
||||||
|
.padding(16)
|
||||||
|
.glassCard(cornerRadius: 16)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
// MARK: Stat Cards
|
// MARK: Stat Cards
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) {
|
||||||
DashStatCard(icon: "checkmark.circle.fill", value: "\(completedHabitsToday)", label: "Выполнено сегодня", color: "0D9488")
|
GlowStatCard(icon: "checkmark.circle.fill", value: "\(completedHabitsToday)", label: "Выполнено", color: Theme.teal)
|
||||||
DashStatCard(icon: "flame.fill", value: "\(habitsStats?.activeHabits ?? totalHabitsToday)", label: "Активных привычек", color: "ffa502")
|
GlowStatCard(icon: "flame.fill", value: "\(habitsStats?.activeHabits ?? totalHabitsToday)", label: "Активных", color: Theme.orange)
|
||||||
DashStatCard(icon: "calendar", value: "\(todayTasks.count)", label: "Задач на сегодня", color: "6366f1")
|
GlowStatCard(icon: "calendar", value: "\(todayTasks.count)", label: "Задач", color: Theme.indigo)
|
||||||
DashStatCard(icon: "checkmark.seal.fill", value: "\(completedTodayTasksCount)", label: "Задач выполнено", color: "10b981")
|
GlowStatCard(icon: "checkmark.seal.fill", value: "\(completedTodayTasksCount)", label: "Готово", color: Theme.green)
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
// MARK: Today's Habits
|
// MARK: Today's Habits
|
||||||
if !todayHabits.isEmpty {
|
if !todayHabits.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
SectionHeader(title: "Привычки", trailing: "\(completedHabitsToday)/\(totalHabitsToday)")
|
||||||
Text("Привычки сегодня")
|
|
||||||
.font(.headline).foregroundColor(.white)
|
|
||||||
Spacer()
|
|
||||||
Text("\(completedHabitsToday)/\(totalHabitsToday)")
|
|
||||||
.font(.caption).foregroundColor(Color(hex: "8888aa"))
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
ForEach(todayHabits) { habit in
|
ForEach(todayHabits) { habit in
|
||||||
DashHabitRow(
|
DashHabitRow(
|
||||||
@@ -120,12 +116,12 @@ struct DashboardView: View {
|
|||||||
// MARK: Today's Tasks
|
// MARK: Today's Tasks
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Задачи на сегодня")
|
Text("Задачи")
|
||||||
.font(.headline).foregroundColor(.white)
|
.font(.headline).foregroundColor(.white)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: { addMode = .task; showAddSheet = true }) {
|
Button(action: { addMode = .task; showAddSheet = true }) {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
.foregroundColor(Color(hex: "0D9488"))
|
.foregroundColor(Theme.teal).font(.title3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -144,7 +140,7 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 80)
|
Spacer(minLength: 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.refreshable { await loadData(refresh: true) }
|
.refreshable { await loadData(refresh: true) }
|
||||||
@@ -153,27 +149,31 @@ struct DashboardView: View {
|
|||||||
Button(action: { addMode = .task; showAddSheet = true }) {
|
Button(action: { addMode = .task; showAddSheet = true }) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
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)
|
.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)
|
Image(systemName: "plus").font(.title2.bold()).foregroundColor(.white)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, 90)
|
.padding(.bottom, 100)
|
||||||
.padding(.trailing, 20)
|
.padding(.trailing, 20)
|
||||||
}
|
}
|
||||||
.task { await loadData() }
|
.task { await loadData() }
|
||||||
.sheet(isPresented: $showAddSheet) {
|
.sheet(isPresented: $showAddSheet) {
|
||||||
if addMode == .task {
|
if addMode == .task {
|
||||||
AddTaskView(isPresented: $showAddSheet) { await loadData(refresh: true) }
|
AddTaskView(isPresented: $showAddSheet) { await loadData(refresh: true) }
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.presentationBackground(Color(hex: "0a0a1a"))
|
.presentationBackground(Theme.bg)
|
||||||
} else {
|
} else {
|
||||||
AddHabitView(isPresented: $showAddSheet) { await loadData(refresh: true) }
|
AddHabitView(isPresented: $showAddSheet) { await loadData(refresh: true) }
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.presentationBackground(Color(hex: "0a0a1a"))
|
.presentationBackground(Theme.bg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("Ошибка", isPresented: $showError) {
|
.alert("Ошибка", isPresented: $showError) {
|
||||||
@@ -186,24 +186,63 @@ struct DashboardView: View {
|
|||||||
func loadData(refresh: Bool = false) async {
|
func loadData(refresh: Bool = false) async {
|
||||||
if !refresh { isLoading = true }
|
if !refresh { isLoading = true }
|
||||||
async let tasks = APIService.shared.getTodayTasks(token: authManager.token)
|
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)
|
async let stats = APIService.shared.getHabitsStats(token: authManager.token)
|
||||||
todayTasks = (try? await tasks) ?? []
|
todayTasks = (try? await tasks) ?? []
|
||||||
todayHabits = (try? await habits) ?? []
|
|
||||||
habitsStats = try? await stats
|
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
|
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
|
// MARK: - Actions
|
||||||
|
|
||||||
func toggleHabit(_ habit: Habit) async {
|
func toggleHabit(_ habit: Habit) async {
|
||||||
if habit.completedToday == true {
|
if habit.completedToday == true { return }
|
||||||
// Already done — undo will handle it
|
|
||||||
return
|
|
||||||
}
|
|
||||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
do {
|
|
||||||
let today = todayDateString()
|
let today = todayDateString()
|
||||||
|
do {
|
||||||
try await APIService.shared.logHabit(token: authManager.token, id: habit.id, date: today)
|
try await APIService.shared.logHabit(token: authManager.token, id: habit.id, date: today)
|
||||||
recentlyLoggedHabitId = habit.id
|
recentlyLoggedHabitId = habit.id
|
||||||
recentlyLoggedHabitLogDate = today
|
recentlyLoggedHabitLogDate = today
|
||||||
@@ -225,7 +264,6 @@ struct DashboardView: View {
|
|||||||
|
|
||||||
func undoHabitLog(_ habit: Habit) async {
|
func undoHabitLog(_ habit: Habit) async {
|
||||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
// Get logs and find today's log to delete
|
|
||||||
do {
|
do {
|
||||||
let logs = try await APIService.shared.getHabitLogs(token: authManager.token, habitId: habit.id, days: 1)
|
let logs = try await APIService.shared.getHabitLogs(token: authManager.token, habitId: habit.id, days: 1)
|
||||||
let today = todayDateString()
|
let today = todayDateString()
|
||||||
@@ -255,9 +293,7 @@ struct DashboardView: View {
|
|||||||
|
|
||||||
func undoTask(_ task: PulseTask) async {
|
func undoTask(_ task: PulseTask) async {
|
||||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
do {
|
do { try await APIService.shared.uncompleteTask(token: authManager.token, id: task.id) } catch {}
|
||||||
try await APIService.shared.uncompleteTask(token: authManager.token, id: task.id)
|
|
||||||
} catch {}
|
|
||||||
recentlyCompletedTaskId = nil
|
recentlyCompletedTaskId = nil
|
||||||
await loadData(refresh: true)
|
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
|
// MARK: - DashHabitRow
|
||||||
|
|
||||||
struct DashHabitRow: View {
|
struct DashHabitRow: View {
|
||||||
@@ -316,16 +327,23 @@ struct DashHabitRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
ZStack {
|
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)
|
Text(habit.displayIcon).font(.title3)
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(habit.name)
|
Text(habit.name)
|
||||||
.font(.callout.weight(.medium)).foregroundColor(.white)
|
.font(.callout.weight(.medium)).foregroundColor(.white)
|
||||||
HStack(spacing: 6) {
|
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 {
|
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 {
|
if isUndoVisible {
|
||||||
Button(action: { Task { await onUndo() } }) {
|
Button(action: { Task { await onUndo() } }) {
|
||||||
Text("Отмена").font(.caption.bold())
|
Text("Отмена").font(.caption.bold())
|
||||||
.foregroundColor(Color(hex: "ffa502"))
|
.foregroundColor(Theme.orange)
|
||||||
.padding(.horizontal, 10).padding(.vertical, 6)
|
.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() } }) {
|
Button(action: { guard !isDone else { return }; Task { await onToggle() } }) {
|
||||||
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
|
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundColor(isDone ? accentColor : Color(hex: "8888aa"))
|
.foregroundColor(isDone ? accentColor : Color(hex: "555566"))
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.padding(14)
|
.padding(14)
|
||||||
.background(
|
.glassCard(cornerRadius: 16)
|
||||||
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 16)
|
RoundedRectangle(cornerRadius: 16)
|
||||||
.fill(isDone ? accentColor.opacity(0.08) : Color.white.opacity(0.04))
|
.stroke(isDone ? accentColor.opacity(0.3) : Color.clear, lineWidth: 1)
|
||||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(isDone ? accentColor.opacity(0.3) : Color.clear, lineWidth: 1))
|
|
||||||
)
|
)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
@@ -368,25 +387,25 @@ struct DashTaskRow: View {
|
|||||||
Button(action: { Task { await onToggle() } }) {
|
Button(action: { Task { await onToggle() } }) {
|
||||||
Image(systemName: task.completed ? "checkmark.circle.fill" : "circle")
|
Image(systemName: task.completed ? "checkmark.circle.fill" : "circle")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundColor(task.completed ? Color(hex: "0D9488") : Color(hex: "8888aa"))
|
.foregroundColor(task.completed ? Theme.teal : Color(hex: "555566"))
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(task.title)
|
Text(task.title)
|
||||||
.foregroundColor(task.completed ? Color(hex: "8888aa") : .white)
|
.foregroundColor(task.completed ? Theme.textSecondary : .white)
|
||||||
.strikethrough(task.completed)
|
.strikethrough(task.completed)
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
if let due = task.dueDateFormatted {
|
if let due = task.dueDateFormatted {
|
||||||
Text(due)
|
Text(due)
|
||||||
.font(.caption2)
|
.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 {
|
if let p = task.priority, p > 1 {
|
||||||
Circle().fill(Color(hex: task.priorityColor)).frame(width: 6, height: 6)
|
Circle().fill(Color(hex: task.priorityColor)).frame(width: 6, height: 6)
|
||||||
}
|
}
|
||||||
if task.isRecurring == true {
|
if task.isRecurring == true {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
.font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
.font(.caption2).foregroundColor(Theme.textSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,14 +413,14 @@ struct DashTaskRow: View {
|
|||||||
if isUndoVisible {
|
if isUndoVisible {
|
||||||
Button(action: { Task { await onUndo() } }) {
|
Button(action: { Task { await onUndo() } }) {
|
||||||
Text("Отмена").font(.caption.bold())
|
Text("Отмена").font(.caption.bold())
|
||||||
.foregroundColor(Color(hex: "ffa502"))
|
.foregroundColor(Theme.orange)
|
||||||
.padding(.horizontal, 10).padding(.vertical, 6)
|
.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)
|
.padding(12)
|
||||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05)))
|
.glassCard(cornerRadius: 14)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
@@ -415,7 +434,7 @@ struct EmptyState: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Image(systemName: icon).font(.system(size: 32)).foregroundColor(Color(hex: "334155"))
|
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)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 24)
|
.padding(.vertical, 24)
|
||||||
|
|||||||
135
PulseHealth/Views/DesignSystem.swift
Normal file
135
PulseHealth/Views/DesignSystem.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,14 +10,16 @@ struct AddTransactionView: View {
|
|||||||
@State private var description = ""
|
@State private var description = ""
|
||||||
@State private var type = "expense"
|
@State private var type = "expense"
|
||||||
@State private var selectedCategoryId: Int? = nil
|
@State private var selectedCategoryId: Int? = nil
|
||||||
|
@State private var date = Date()
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
var filteredCategories: [FinanceCategory] { categories.filter { $0.type == type } }
|
var filteredCategories: [FinanceCategory] { categories.filter { $0.type == type } }
|
||||||
var isExpense: Bool { type == "expense" }
|
var isExpense: Bool { type == "expense" }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Handle
|
// Handle
|
||||||
@@ -92,6 +94,15 @@ struct AddTransactionView: View {
|
|||||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
.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
|
// Categories
|
||||||
if !filteredCategories.isEmpty {
|
if !filteredCategories.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -100,7 +111,7 @@ struct AddTransactionView: View {
|
|||||||
ForEach(filteredCategories) { cat in
|
ForEach(filteredCategories) { cat in
|
||||||
Button(action: { selectedCategoryId = selectedCategoryId == cat.id ? nil : cat.id }) {
|
Button(action: { selectedCategoryId = selectedCategoryId == cat.id ? nil : cat.id }) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(cat.icon ?? "").font(.callout)
|
Text(cat.emoji ?? "").font(.callout)
|
||||||
Text(cat.name).font(.caption).lineLimit(1)
|
Text(cat.name).font(.caption).lineLimit(1)
|
||||||
}
|
}
|
||||||
.foregroundColor(selectedCategoryId == cat.id ? .black : .white)
|
.foregroundColor(selectedCategoryId == cat.id ? .black : .white)
|
||||||
@@ -115,6 +126,13 @@ 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)
|
.padding(20)
|
||||||
}
|
}
|
||||||
@@ -125,11 +143,21 @@ struct AddTransactionView: View {
|
|||||||
func save() {
|
func save() {
|
||||||
guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")) else { return }
|
guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")) else { return }
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
|
||||||
|
let dateStr = df.string(from: date)
|
||||||
Task {
|
Task {
|
||||||
let req = CreateTransactionRequest(amount: a, categoryId: selectedCategoryId, description: description.isEmpty ? nil : description, type: type)
|
do {
|
||||||
try? await APIService.shared.createTransaction(token: authManager.token, request: req)
|
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 onAdded()
|
||||||
await MainActor.run { isPresented = false }
|
await MainActor.run { isPresented = false }
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ struct FinanceView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Header with month picker
|
// Header with month picker
|
||||||
HStack {
|
HStack {
|
||||||
@@ -37,6 +37,7 @@ struct FinanceView: View {
|
|||||||
Text("Обзор").tag(0)
|
Text("Обзор").tag(0)
|
||||||
Text("Транзакции").tag(1)
|
Text("Транзакции").tag(1)
|
||||||
Text("Аналитика").tag(2)
|
Text("Аналитика").tag(2)
|
||||||
|
Text("Категории").tag(3)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -45,7 +46,8 @@ struct FinanceView: View {
|
|||||||
switch selectedTab {
|
switch selectedTab {
|
||||||
case 0: FinanceOverviewTab(month: selectedMonth, year: selectedYear)
|
case 0: FinanceOverviewTab(month: selectedMonth, year: selectedYear)
|
||||||
case 1: FinanceTransactionsTab(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)
|
let pct = (cat.total ?? 0) / max(total, 1)
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(cat.icon ?? "💸").font(.subheadline)
|
Text(cat.emoji ?? "💸").font(.subheadline)
|
||||||
Text(cat.categoryName ?? "—").font(.callout).foregroundColor(.white)
|
Text(cat.categoryName ?? "—").font(.callout).foregroundColor(.white)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(formatAmt(cat.total ?? 0)).font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
|
Text(formatAmt(cat.total ?? 0)).font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
|
||||||
@@ -193,8 +195,8 @@ struct FinanceOverviewTab: View {
|
|||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
.onChange(of: month) { _ in Task { await load() } }
|
.onChange(of: month) { Task { await load() } }
|
||||||
.onChange(of: year) { _ in Task { await load() } }
|
.onChange(of: year) { Task { await load() } }
|
||||||
.refreshable { await load(refresh: true) }
|
.refreshable { await load(refresh: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +268,7 @@ struct FinanceTransactionsTab: View {
|
|||||||
@State private var categories: [FinanceCategory] = []
|
@State private var categories: [FinanceCategory] = []
|
||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
@State private var showAdd = false
|
@State private var showAdd = false
|
||||||
|
@State private var editingTransaction: FinanceTransaction?
|
||||||
|
|
||||||
var groupedByDay: [(key: String, value: [FinanceTransaction])] {
|
var groupedByDay: [(key: String, value: [FinanceTransaction])] {
|
||||||
let grouped = Dictionary(grouping: transactions) { $0.dateOnly }
|
let grouped = Dictionary(grouping: transactions) { $0.dateOnly }
|
||||||
@@ -291,6 +294,7 @@ struct FinanceTransactionsTab: View {
|
|||||||
FinanceTxRow(transaction: tx, categories: categories)
|
FinanceTxRow(transaction: tx, categories: categories)
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
.onTapGesture { editingTransaction = tx }
|
||||||
}
|
}
|
||||||
.onDelete { idx in
|
.onDelete { idx in
|
||||||
let toDelete = idx.map { section.value[$0] }
|
let toDelete = idx.map { section.value[$0] }
|
||||||
@@ -321,13 +325,22 @@ struct FinanceTransactionsTab: View {
|
|||||||
.padding(.trailing, 20)
|
.padding(.trailing, 20)
|
||||||
}
|
}
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
.onChange(of: month) { _ in Task { await load() } }
|
.onChange(of: month) { Task { await load() } }
|
||||||
.onChange(of: year) { _ in Task { await load() } }
|
.onChange(of: year) { Task { await load() } }
|
||||||
.sheet(isPresented: $showAdd) {
|
.sheet(isPresented: $showAdd) {
|
||||||
AddTransactionView(isPresented: $showAdd, categories: categories) { await load(refresh: true) }
|
AddTransactionView(isPresented: $showAdd, categories: categories) { await load(refresh: true) }
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.medium, .large])
|
||||||
.presentationDragIndicator(.visible)
|
.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()
|
Circle()
|
||||||
.fill((isIncome ? Color(hex: "0D9488") : Color(hex: "ff4757")).opacity(0.12))
|
.fill((isIncome ? Color(hex: "0D9488") : Color(hex: "ff4757")).opacity(0.12))
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40, height: 40)
|
||||||
Text(cat?.icon ?? (isIncome ? "💰" : "💸")).font(.title3)
|
Text(cat?.emoji ?? (isIncome ? "💰" : "💸")).font(.title3)
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(transaction.description ?? cat?.name ?? "Операция")
|
Text(transaction.description ?? cat?.name ?? "Операция")
|
||||||
@@ -434,8 +447,8 @@ struct FinanceAnalyticsTab: View {
|
|||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
.onChange(of: month) { _ in Task { await load() } }
|
.onChange(of: month) { Task { await load() } }
|
||||||
.onChange(of: year) { _ in Task { await load() } }
|
.onChange(of: year) { Task { await load() } }
|
||||||
.refreshable { await load(refresh: true) }
|
.refreshable { await load(refresh: true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,3 +494,346 @@ struct MonthComparisonCard: View {
|
|||||||
}
|
}
|
||||||
func formatAmt(_ v: Double) -> String { String(format: "%.0f ₽", v) }
|
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<Bool>, 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<Bool>, 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,21 +32,22 @@ struct AddHabitView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
RoundedRectangle(cornerRadius: 3)
|
RoundedRectangle(cornerRadius: 3)
|
||||||
.fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
.fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
||||||
HStack {
|
HStack {
|
||||||
Button("Отмена") { isPresented = false }.foregroundColor(Color(hex: "8888aa"))
|
Button("Отмена") { isPresented = false }
|
||||||
|
.font(.callout).foregroundColor(Color(hex: "8888aa"))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Новая привычка").font(.headline).foregroundColor(.white)
|
Text("Новая привычка").font(.headline).foregroundColor(.white)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: save) {
|
Button(action: save) {
|
||||||
if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) }
|
if isLoading { ProgressView().tint(Theme.teal).scaleEffect(0.8) }
|
||||||
else { Text("Добавить").foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) }
|
else { Text("Готово").font(.callout.bold()).foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Theme.teal) }
|
||||||
}.disabled(name.isEmpty || isLoading)
|
}.disabled(name.isEmpty || isLoading)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20).padding(.vertical, 16)
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
Divider().background(Color.white.opacity(0.1))
|
Divider().background(Color.white.opacity(0.1))
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
@@ -144,7 +145,7 @@ struct AddHabitView: View {
|
|||||||
// Color picker
|
// Color picker
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
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
|
ForEach(colors, id: \.self) { color in
|
||||||
Button(action: { selectedColor = color }) {
|
Button(action: { selectedColor = color }) {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -166,10 +167,11 @@ struct AddHabitView: View {
|
|||||||
func save() {
|
func save() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
Task {
|
Task {
|
||||||
|
let apiFrequency = (frequency == "interval" || frequency == "monthly") ? "custom" : frequency
|
||||||
var body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": description,
|
"description": description,
|
||||||
"frequency": frequency,
|
"frequency": apiFrequency,
|
||||||
"icon": selectedIcon,
|
"icon": selectedIcon,
|
||||||
"color": selectedColor,
|
"color": selectedColor,
|
||||||
"target_count": 1
|
"target_count": 1
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ struct EditHabitView: View {
|
|||||||
@State private var intervalDays: String
|
@State private var intervalDays: String
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var showArchiveConfirm = 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 weekdayNames = ["Вс","Пн","Вт","Ср","Чт","Пт","Сб"]
|
||||||
let icons = ["🔥", "💪", "🏃", "📚", "💧", "🧘", "🎯", "⭐️", "🌟", "✅",
|
let icons = ["🔥", "💪", "🏃", "📚", "💧", "🧘", "🎯", "⭐️", "🌟", "✅",
|
||||||
@@ -42,21 +47,22 @@ struct EditHabitView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
RoundedRectangle(cornerRadius: 3)
|
RoundedRectangle(cornerRadius: 3)
|
||||||
.fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
.fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
||||||
HStack {
|
HStack {
|
||||||
Button("Отмена") { isPresented = false }.foregroundColor(Color(hex: "8888aa"))
|
Button("Отмена") { isPresented = false }
|
||||||
|
.font(.callout).foregroundColor(Color(hex: "8888aa"))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Редактировать привычку").font(.headline).foregroundColor(.white)
|
Text("Редактировать").font(.headline).foregroundColor(.white)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: save) {
|
Button(action: save) {
|
||||||
if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) }
|
if isLoading { ProgressView().tint(Theme.teal).scaleEffect(0.8) }
|
||||||
else { Text("Сохранить").foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) }
|
else { Text("Готово").font(.callout.bold()).foregroundColor(name.isEmpty ? Color(hex: "8888aa") : Theme.teal) }
|
||||||
}.disabled(name.isEmpty || isLoading)
|
}.disabled(name.isEmpty || isLoading)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20).padding(.vertical, 16)
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
Divider().background(Color.white.opacity(0.1))
|
Divider().background(Color.white.opacity(0.1))
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
@@ -152,7 +158,7 @@ struct EditHabitView: View {
|
|||||||
// Color picker
|
// Color picker
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
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
|
ForEach(colors, id: \.self) { color in
|
||||||
Button(action: { selectedColor = color }) {
|
Button(action: { selectedColor = color }) {
|
||||||
Circle()
|
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
|
// Archive / Restore button
|
||||||
Button(action: { showArchiveConfirm = true }) {
|
Button(action: { showArchiveConfirm = true }) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -181,6 +254,7 @@ struct EditHabitView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task { freezes = (try? await APIService.shared.getHabitFreezes(token: authManager.token, habitId: habit.id)) ?? [] }
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
habit.isArchived == true ? "Восстановить привычку?" : "Архивировать привычку?",
|
habit.isArchived == true ? "Восстановить привычку?" : "Архивировать привычку?",
|
||||||
isPresented: $showArchiveConfirm,
|
isPresented: $showArchiveConfirm,
|
||||||
@@ -197,9 +271,10 @@ struct EditHabitView: View {
|
|||||||
func save() {
|
func save() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
Task {
|
Task {
|
||||||
|
let apiFrequency = (frequency == .interval || frequency == .monthly) ? "custom" : frequency.rawValue
|
||||||
var body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"name": name,
|
"name": name,
|
||||||
"frequency": frequency.rawValue,
|
"frequency": apiFrequency,
|
||||||
"icon": selectedIcon,
|
"icon": selectedIcon,
|
||||||
"color": selectedColor,
|
"color": selectedColor,
|
||||||
"target_count": 1
|
"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 {
|
func toggleArchive() async {
|
||||||
let params: [String: Any] = ["is_archived": !(habit.isArchived == true)]
|
let params: [String: Any] = ["is_archived": !(habit.isArchived == true)]
|
||||||
if let body = try? JSONSerialization.data(withJSONObject: params) {
|
if let body = try? JSONSerialization.data(withJSONObject: params) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ struct HabitsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
@@ -59,7 +59,7 @@ struct HabitsView: View {
|
|||||||
.sheet(isPresented: $showAddHabit) {
|
.sheet(isPresented: $showAddHabit) {
|
||||||
AddHabitView(isPresented: $showAddHabit) { await loadHabits(refresh: true) }
|
AddHabitView(isPresented: $showAddHabit) { await loadHabits(refresh: true) }
|
||||||
.presentationDetents([.large]).presentationDragIndicator(.visible)
|
.presentationDetents([.large]).presentationDragIndicator(.visible)
|
||||||
.presentationBackground(Color(hex: "0a0a1a"))
|
.presentationBackground(Color(hex: "06060f"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,212 +8,523 @@ struct HealthView: View {
|
|||||||
@State private var latest: LatestHealthResponse?
|
@State private var latest: LatestHealthResponse?
|
||||||
@State private var heatmapData: [HeatmapEntry] = []
|
@State private var heatmapData: [HeatmapEntry] = []
|
||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
|
|
||||||
// Toast state
|
|
||||||
@State private var showToast = false
|
@State private var showToast = false
|
||||||
@State private var toastMessage = ""
|
@State private var toastMessage = ""
|
||||||
@State private var toastSuccess = true
|
@State private var toastSuccess = true
|
||||||
|
@State private var showSleepDetail = false
|
||||||
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 "Доброй ночи"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var dateString: String {
|
var dateString: String {
|
||||||
let formatter = DateFormatter()
|
let f = DateFormatter(); f.locale = Locale(identifier: "ru_RU"); f.dateFormat = "d MMMM, EEEE"
|
||||||
formatter.locale = Locale(identifier: "ru_RU")
|
return f.string(from: Date())
|
||||||
formatter.dateFormat = "d MMMM, EEEE"
|
|
||||||
return formatter.string(from: Date())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a")
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 16) {
|
||||||
// MARK: - Header
|
// MARK: - Header
|
||||||
headerView
|
HStack {
|
||||||
.padding(.top, 8)
|
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 {
|
if isLoading {
|
||||||
loadingView
|
ProgressView().tint(Theme.teal).padding(.top, 60)
|
||||||
} else {
|
} else {
|
||||||
// MARK: - Readiness
|
// MARK: - Readiness
|
||||||
if let r = readiness {
|
if let r = readiness { ReadinessBanner(readiness: r) }
|
||||||
ReadinessCardView(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
|
// MARK: - Weekly Trends
|
||||||
metricsGrid
|
if heatmapData.count >= 2 {
|
||||||
|
WeeklyTrendsCard(heatmapData: heatmapData)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Weekly Chart
|
// MARK: - Weekly Chart
|
||||||
if !heatmapData.isEmpty {
|
if !heatmapData.isEmpty {
|
||||||
WeeklyChartCard(heatmapData: heatmapData)
|
WeeklyChartCard(heatmapData: heatmapData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Insights
|
// MARK: - Recovery Score
|
||||||
InsightsCard(readiness: readiness, latest: latest)
|
RecoveryCard(sleep: latest?.sleep, hrv: latest?.hrv, rhr: latest?.restingHeartRate)
|
||||||
|
|
||||||
|
// MARK: - Tips
|
||||||
|
TipsCard(readiness: readiness, latest: latest)
|
||||||
|
|
||||||
Spacer(minLength: 30)
|
Spacer(minLength: 30)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable { await loadData(refresh: true) }
|
||||||
await loadData(refresh: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.toast(isShowing: $showToast, message: toastMessage, isSuccess: toastSuccess)
|
.toast(isShowing: $showToast, message: toastMessage, isSuccess: toastSuccess)
|
||||||
.task { await loadData() }
|
.sheet(isPresented: $showSleepDetail) {
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
if let sleep = latest?.sleep {
|
if let sleep = latest?.sleep {
|
||||||
SleepCard(sleep: sleep)
|
SleepDetailView(sleep: sleep).presentationDetents([.large]).presentationBackground(Color(hex: "06060f"))
|
||||||
.frame(maxHeight: .infinity)
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
if healthKit.isAvailable { try? await healthKit.requestAuthorization() }
|
||||||
|
await loadData()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let rhr = latest?.restingHeartRate {
|
// MARK: - Statuses
|
||||||
MetricCardView(
|
|
||||||
icon: "heart.fill",
|
var sleepStatus: MetricStatus {
|
||||||
title: "Пульс покоя",
|
guard let s = latest?.sleep?.totalSleep, s > 0 else { return .noData }
|
||||||
value: "\(Int(rhr.value ?? 0)) уд/мин",
|
if s >= 7.5 { return .good("Отличный сон") }
|
||||||
subtitle: latest?.heartRate != nil ? "Avg: \(latest?.heartRate?.avg ?? 0) уд/мин" : "",
|
if s >= 6 { return .ok("Можно лучше") }
|
||||||
color: Color(hex: "ff4757"),
|
return .bad("Мало сна")
|
||||||
gradientColors: [Color(hex: "ff4757"), Color(hex: "ff6b81")]
|
}
|
||||||
)
|
var rhrStatus: MetricStatus {
|
||||||
.frame(maxHeight: .infinity)
|
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("Мало")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let hrv = latest?.hrv {
|
// MARK: - Data
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Load Data
|
|
||||||
|
|
||||||
func loadData(refresh: Bool = false) async {
|
func loadData(refresh: Bool = false) async {
|
||||||
if !refresh { isLoading = true }
|
if !refresh { isLoading = true }
|
||||||
|
|
||||||
async let r = HealthAPIService.shared.getReadiness()
|
async let r = HealthAPIService.shared.getReadiness()
|
||||||
async let l = HealthAPIService.shared.getLatest()
|
async let l = HealthAPIService.shared.getLatest()
|
||||||
async let h = HealthAPIService.shared.getHeatmap(days: 7)
|
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
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sync HealthKit
|
|
||||||
|
|
||||||
func syncHealthKit() async {
|
func syncHealthKit() async {
|
||||||
guard healthKit.isAvailable else {
|
guard healthKit.isAvailable else { showToastMsg("HealthKit недоступен", success: false); return }
|
||||||
showToastMessage("HealthKit недоступен на этом устройстве", success: false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await healthKit.syncToServer(apiKey: authManager.healthApiKey)
|
try await healthKit.syncToServer(apiKey: authManager.healthApiKey)
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||||
showToastMessage("Данные синхронизированы ✓", success: true)
|
showToastMsg("Синхронизировано", success: true)
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch {
|
} catch {
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||||
showToastMessage(error.localizedDescription, success: false)
|
showToastMsg(error.localizedDescription, success: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showToastMessage(_ message: String, success: Bool) {
|
private func showToastMsg(_ msg: String, success: Bool) {
|
||||||
toastMessage = message
|
toastMessage = msg; toastSuccess = success; withAnimation { showToast = true }
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,24 +9,20 @@ struct GradientIcon: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(colors.first?.opacity(0.25) ?? Color.clear)
|
||||||
|
.frame(width: size * 1.2, height: size * 1.2)
|
||||||
|
.blur(radius: 10)
|
||||||
Circle()
|
Circle()
|
||||||
.fill(
|
.fill(
|
||||||
LinearGradient(
|
LinearGradient(colors: colors.map { $0.opacity(0.15) },
|
||||||
colors: colors.map { $0.opacity(0.2) },
|
startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
|
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: size * 0.4))
|
.font(.system(size: size * 0.4))
|
||||||
.foregroundStyle(
|
.foregroundStyle(
|
||||||
LinearGradient(
|
LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||||
colors: colors,
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,79 +37,26 @@ struct MetricCardView: View {
|
|||||||
let subtitle: String
|
let subtitle: String
|
||||||
let color: Color
|
let color: Color
|
||||||
var gradientColors: [Color]? = nil
|
var gradientColors: [Color]? = nil
|
||||||
var progress: Double? = nil
|
|
||||||
var progressMax: Double = 1.0
|
|
||||||
|
|
||||||
@State private var appeared = false
|
@State private var appeared = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
GradientIcon(icon: icon, colors: gradientColors ?? [color, color.opacity(0.6)])
|
GradientIcon(icon: icon, colors: gradientColors ?? [color, color.opacity(0.6)])
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
Text(value).font(.title2.bold()).foregroundColor(.white)
|
||||||
Text(value)
|
Text(title).font(.subheadline.weight(.medium)).foregroundColor(.white.opacity(0.7))
|
||||||
.font(.title2.bold())
|
if !subtitle.isEmpty {
|
||||||
.foregroundColor(.white)
|
Text(subtitle).font(.caption).foregroundColor(Theme.textSecondary).lineLimit(2)
|
||||||
|
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(
|
.glassCard(cornerRadius: 20)
|
||||||
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)
|
|
||||||
.opacity(appeared ? 1 : 0)
|
.opacity(appeared ? 1 : 0)
|
||||||
.offset(y: appeared ? 0 : 15)
|
.offset(y: appeared ? 0 : 15)
|
||||||
.onAppear {
|
.onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.1)) { appeared = true } }
|
||||||
withAnimation(.easeOut(duration: 0.5).delay(0.1)) {
|
|
||||||
appeared = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,90 +67,32 @@ struct SleepCard: View {
|
|||||||
@State private var appeared = false
|
@State private var appeared = false
|
||||||
|
|
||||||
var totalHours: Double { sleep.totalSleep ?? 0 }
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
GradientIcon(icon: "moon.fill", colors: [Color(hex: "7c3aed"), Color(hex: "a78bfa")])
|
GradientIcon(icon: "moon.fill", colors: [Theme.purple, Color(hex: "a78bfa")])
|
||||||
Spacer()
|
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
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.08))
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.fill(Color.white.opacity(0.08))
|
.fill(LinearGradient(colors: [Theme.purple, Color(hex: "a78bfa")], startPoint: .leading, endPoint: .trailing))
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color(hex: "7c3aed"), Color(hex: "a78bfa")],
|
|
||||||
startPoint: .leading,
|
|
||||||
endPoint: .trailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.frame(width: geo.size.width * min(CGFloat(totalHours / 9.0), 1.0))
|
.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)
|
.frame(height: 6)
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(
|
.glassCard(cornerRadius: 20)
|
||||||
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)
|
|
||||||
.opacity(appeared ? 1 : 0)
|
.opacity(appeared ? 1 : 0)
|
||||||
.offset(y: appeared ? 0 : 15)
|
.offset(y: appeared ? 0 : 15)
|
||||||
.onAppear {
|
.onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.15)) { appeared = true } }
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,68 +104,40 @@ struct StepsCard: View {
|
|||||||
@State private var appeared = false
|
@State private var appeared = false
|
||||||
|
|
||||||
var progress: Double { Double(steps) / Double(goal) }
|
var progress: Double { Double(steps) / Double(goal) }
|
||||||
var percent: Int { Int(progress * 100) }
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
GradientIcon(icon: "figure.walk", colors: [Color(hex: "ffa502"), Color(hex: "ff6348")])
|
GradientIcon(icon: "figure.walk", colors: [Theme.orange, Color(hex: "ff6348")])
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
Text(formatSteps(steps)).font(.title2.bold()).foregroundColor(.white)
|
||||||
Text(formatSteps(steps))
|
Text("Шаги").font(.subheadline.weight(.medium)).foregroundColor(.white.opacity(0.7))
|
||||||
.font(.title2.bold())
|
|
||||||
.foregroundColor(.white)
|
|
||||||
|
|
||||||
Text("Шаги")
|
|
||||||
.font(.subheadline.weight(.medium))
|
|
||||||
.foregroundColor(.white.opacity(0.7))
|
|
||||||
|
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
|
RoundedRectangle(cornerRadius: 4).fill(Color.white.opacity(0.08))
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.fill(Color.white.opacity(0.08))
|
.fill(LinearGradient(colors: [Theme.orange, Color(hex: "ff6348")], startPoint: .leading, endPoint: .trailing))
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color(hex: "ffa502"), Color(hex: "ff6348")],
|
|
||||||
startPoint: .leading,
|
|
||||||
endPoint: .trailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.frame(width: geo.size.width * min(CGFloat(progress), 1.0))
|
.frame(width: geo.size.width * min(CGFloat(progress), 1.0))
|
||||||
|
.shadow(color: Theme.orange.opacity(0.5), radius: 4, y: 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 6)
|
.frame(height: 6)
|
||||||
|
|
||||||
Text("\(percent)% от цели")
|
Text("\(Int(progress * 100))% от цели")
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10)).foregroundColor(Theme.textSecondary)
|
||||||
.foregroundColor(Color(hex: "8888aa"))
|
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(
|
.glassCard(cornerRadius: 20)
|
||||||
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)
|
|
||||||
.opacity(appeared ? 1 : 0)
|
.opacity(appeared ? 1 : 0)
|
||||||
.offset(y: appeared ? 0 : 15)
|
.offset(y: appeared ? 0 : 15)
|
||||||
.onAppear {
|
.onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.25)) { appeared = true } }
|
||||||
withAnimation(.easeOut(duration: 0.5).delay(0.25)) {
|
|
||||||
appeared = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatSteps(_ n: Int) -> String {
|
private func formatSteps(_ n: Int) -> String {
|
||||||
let formatter = NumberFormatter()
|
let f = NumberFormatter(); f.numberStyle = .decimal; f.groupingSeparator = " "
|
||||||
formatter.numberStyle = .decimal
|
return f.string(from: NSNumber(value: n)) ?? "\(n)"
|
||||||
formatter.groupingSeparator = " "
|
|
||||||
return formatter.string(from: NSNumber(value: n)) ?? "\(n)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,81 +146,121 @@ struct StepsCard: View {
|
|||||||
struct InsightsCard: View {
|
struct InsightsCard: View {
|
||||||
let readiness: ReadinessResponse?
|
let readiness: ReadinessResponse?
|
||||||
let latest: LatestHealthResponse?
|
let latest: LatestHealthResponse?
|
||||||
@State private var appeared = false
|
|
||||||
|
|
||||||
var insights: [(icon: String, text: String, color: Color)] {
|
var insights: [(icon: String, text: String, color: Color)] {
|
||||||
var result: [(String, String, Color)] = []
|
var result: [(String, String, Color)] = []
|
||||||
|
|
||||||
if let r = readiness {
|
if let r = readiness {
|
||||||
if r.score >= 80 {
|
if r.score >= 80 { result.append(("bolt.fill", "Отличный день для тренировки!", Theme.teal)) }
|
||||||
result.append(("bolt.fill", "Отличный день для тренировки!", Color(hex: "00d4aa")))
|
else if r.score < 60 { result.append(("bed.double.fill", "Сегодня лучше отдохнуть", Theme.red)) }
|
||||||
} else if r.score < 60 {
|
|
||||||
result.append(("bed.double.fill", "Сегодня лучше отдохнуть", Color(hex: "ff4757")))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if let sleep = latest?.sleep?.totalSleep, sleep < 7 {
|
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 {
|
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 {
|
} 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 {
|
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 {
|
if result.isEmpty {
|
||||||
result.append(("sparkles", "Данные обновятся после синхронизации", Color(hex: "8888aa")))
|
result.append(("sparkles", "Данные обновятся после синхронизации", Theme.textSecondary))
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
HStack {
|
HStack {
|
||||||
GradientIcon(icon: "lightbulb.fill", colors: [Color(hex: "ffa502"), Color(hex: "ff6348")])
|
GradientIcon(icon: "lightbulb.fill", colors: [Theme.orange, Color(hex: "ff6348")])
|
||||||
Text("Инсайты")
|
Text("Инсайты").font(.headline.weight(.semibold)).foregroundColor(.white)
|
||||||
.font(.headline.weight(.semibold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in
|
ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: insight.icon)
|
Image(systemName: insight.icon).font(.subheadline).foregroundColor(insight.color).frame(width: 24)
|
||||||
.font(.subheadline)
|
Text(insight.text).font(.subheadline).foregroundColor(.white.opacity(0.85)).lineLimit(2)
|
||||||
.foregroundColor(insight.color)
|
|
||||||
.frame(width: 24)
|
|
||||||
|
|
||||||
Text(insight.text)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.white.opacity(0.85))
|
|
||||||
.lineLimit(2)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.background(
|
.glassCard(cornerRadius: 20)
|
||||||
RoundedRectangle(cornerRadius: 20)
|
.padding(.horizontal)
|
||||||
.fill(.ultraThinMaterial)
|
}
|
||||||
.overlay(
|
}
|
||||||
RoundedRectangle(cornerRadius: 20)
|
|
||||||
.fill(Color(hex: "12122a").opacity(0.7))
|
// MARK: - Sleep Phases Card
|
||||||
)
|
|
||||||
)
|
struct SleepPhasesCard: View {
|
||||||
.shadow(color: .black.opacity(0.2), radius: 10, y: 5)
|
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)
|
.padding(.horizontal)
|
||||||
.opacity(appeared ? 1 : 0)
|
.opacity(appeared ? 1 : 0)
|
||||||
.offset(y: appeared ? 0 : 20)
|
.offset(y: appeared ? 0 : 15)
|
||||||
.onAppear {
|
.onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.2)) { appeared = true } }
|
||||||
withAnimation(.easeOut(duration: 0.5).delay(0.3)) {
|
}
|
||||||
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)м"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,152 +1,3 @@
|
|||||||
|
// ReadinessCardView — replaced by ReadinessBanner in HealthView.swift
|
||||||
|
// This file is intentionally empty.
|
||||||
import SwiftUI
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
249
PulseHealth/Views/Health/SleepDetailView.swift
Normal file
249
PulseHealth/Views/Health/SleepDetailView.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,14 +16,9 @@ struct WeeklyChartCard: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Header
|
|
||||||
HStack {
|
HStack {
|
||||||
GradientIcon(icon: "chart.bar.fill", colors: [Color(hex: "7c3aed"), Color(hex: "00d4aa")])
|
GradientIcon(icon: "chart.xyaxis.line", colors: [Theme.purple, Theme.teal])
|
||||||
|
Text("За неделю").font(.headline.weight(.semibold)).foregroundColor(.white)
|
||||||
Text("За неделю")
|
|
||||||
.font(.headline.weight(.semibold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,19 +26,16 @@ struct WeeklyChartCard: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
ForEach(ChartType.allCases, id: \.self) { type in
|
ForEach(ChartType.allCases, id: \.self) { type in
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) { selectedChart = type }
|
||||||
selectedChart = type
|
|
||||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Text(type.rawValue)
|
Text(type.rawValue)
|
||||||
.font(.caption.weight(.medium))
|
.font(.caption.weight(.medium))
|
||||||
.foregroundColor(selectedChart == type ? .white : Color(hex: "8888aa"))
|
.foregroundColor(selectedChart == type ? .white : Theme.textSecondary)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16).padding(.vertical, 8)
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(
|
.background(
|
||||||
selectedChart == type
|
selectedChart == type
|
||||||
? Color(hex: "7c3aed").opacity(0.5)
|
? chartColor.opacity(0.3)
|
||||||
: Color.clear
|
: Color.clear
|
||||||
)
|
)
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
@@ -51,34 +43,23 @@ struct WeeklyChartCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(4)
|
.padding(4)
|
||||||
.background(Color(hex: "1a1a3e"))
|
.background(Color.white.opacity(0.06))
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
|
|
||||||
// Chart
|
// Line Chart
|
||||||
BarChartView(
|
LineChartView(
|
||||||
values: chartValues,
|
values: chartValues,
|
||||||
color: chartColor,
|
color: chartColor,
|
||||||
maxValue: chartMaxValue,
|
|
||||||
unit: chartUnit,
|
unit: chartUnit,
|
||||||
appeared: appeared
|
appeared: appeared
|
||||||
)
|
)
|
||||||
.frame(height: 160)
|
.frame(height: 160)
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.background(
|
.glassCard(cornerRadius: 20)
|
||||||
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)
|
.padding(.horizontal)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withAnimation(.easeOut(duration: 0.8).delay(0.3)) {
|
withAnimation(.easeOut(duration: 0.8).delay(0.3)) { appeared = true }
|
||||||
appeared = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,17 +77,9 @@ struct WeeklyChartCard: View {
|
|||||||
|
|
||||||
private var chartColor: Color {
|
private var chartColor: Color {
|
||||||
switch selectedChart {
|
switch selectedChart {
|
||||||
case .sleep: return Color(hex: "7c3aed")
|
case .sleep: return Theme.purple
|
||||||
case .hrv: return Color(hex: "00d4aa")
|
case .hrv: return Theme.teal
|
||||||
case .steps: return Color(hex: "ffa502")
|
case .steps: return Theme.orange
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var chartMaxValue: Double {
|
|
||||||
switch selectedChart {
|
|
||||||
case .sleep: return 10
|
|
||||||
case .hrv: return 120
|
|
||||||
case .steps: return 12000
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 values: [(date: String, value: Double)]
|
||||||
let color: Color
|
let color: Color
|
||||||
let maxValue: Double
|
|
||||||
let unit: String
|
let unit: String
|
||||||
let appeared: Bool
|
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 {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let barWidth = max((geo.size.width - CGFloat(values.count - 1) * 8) / CGFloat(max(values.count, 1)), 10)
|
let w = geo.size.width
|
||||||
let chartHeight = geo.size.height - 30
|
let h = geo.size.height - 24 // space for labels
|
||||||
|
let count = max(values.count - 1, 1)
|
||||||
|
|
||||||
HStack(alignment: .bottom, spacing: 8) {
|
ZStack(alignment: .topLeading) {
|
||||||
ForEach(Array(values.enumerated()), id: \.offset) { index, item in
|
// Grid lines
|
||||||
VStack(spacing: 4) {
|
ForEach(0..<4, id: \.self) { i in
|
||||||
// Value label
|
let y = h * CGFloat(i) / 3.0
|
||||||
if item.value > 0 {
|
Path { path in
|
||||||
Text(formatValue(item.value))
|
path.move(to: CGPoint(x: 0, y: y))
|
||||||
.font(.system(size: 9, weight: .medium))
|
path.addLine(to: CGPoint(x: w, y: y))
|
||||||
.foregroundColor(Color(hex: "8888aa"))
|
}
|
||||||
|
.stroke(Color.white.opacity(0.04), lineWidth: 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bar
|
if values.count >= 2 {
|
||||||
RoundedRectangle(cornerRadius: 6)
|
// 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(
|
.fill(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [color, color.opacity(0.5)],
|
colors: [color.opacity(appeared ? 0.25 : 0), color.opacity(0)],
|
||||||
startPoint: .top,
|
startPoint: .top, endPoint: .bottom
|
||||||
endPoint: .bottom
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.frame(
|
.animation(.easeOut(duration: 1), value: appeared)
|
||||||
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
|
// Line
|
||||||
Text(item.date)
|
Path { path in
|
||||||
.font(.system(size: 10))
|
for (i, val) in values.enumerated() {
|
||||||
.foregroundColor(Color(hex: "8888aa"))
|
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)
|
.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 {
|
private func formatValue(_ value: Double) -> String {
|
||||||
if value >= 1000 {
|
if value >= 1000 { return String(format: "%.0fк", value / 1000) }
|
||||||
return String(format: "%.1fк", value / 1000)
|
if value == floor(value) { return "\(Int(value))\(unit)" }
|
||||||
} else if value == floor(value) {
|
return String(format: "%.1f", value)
|
||||||
return "\(Int(value))\(unit)"
|
|
||||||
} else {
|
|
||||||
return String(format: "%.1f\(unit)", value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ struct LoginView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a")
|
Color(hex: "06060f")
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 32) {
|
VStack(spacing: 32) {
|
||||||
@@ -126,7 +126,7 @@ struct LoginView: View {
|
|||||||
password: password
|
password: password
|
||||||
)
|
)
|
||||||
await MainActor.run {
|
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 {
|
} catch let error as APIError {
|
||||||
await MainActor.run { errorMessage = error.errorDescription ?? "Ошибка"; isLoading = false }
|
await MainActor.run { errorMessage = error.errorDescription ?? "Ошибка"; isLoading = false }
|
||||||
@@ -146,7 +146,7 @@ struct LoginView: View {
|
|||||||
name: name
|
name: name
|
||||||
)
|
)
|
||||||
await MainActor.run {
|
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 {
|
} catch let error as APIError {
|
||||||
await MainActor.run { errorMessage = error.errorDescription ?? "Ошибка"; isLoading = false }
|
await MainActor.run { errorMessage = error.errorDescription ?? "Ошибка"; isLoading = false }
|
||||||
@@ -167,7 +167,7 @@ struct ForgotPasswordView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
Text("Сброс пароля").font(.title2.bold()).foregroundColor(.white)
|
Text("Сброс пароля").font(.title2.bold()).foregroundColor(.white)
|
||||||
if isSent {
|
if isSent {
|
||||||
|
|||||||
@@ -3,29 +3,104 @@ import SwiftUI
|
|||||||
struct MainTabView: View {
|
struct MainTabView: View {
|
||||||
@EnvironmentObject var authManager: AuthManager
|
@EnvironmentObject var authManager: AuthManager
|
||||||
@AppStorage("colorScheme") private var colorSchemeRaw: String = "dark"
|
@AppStorage("colorScheme") private var colorSchemeRaw: String = "dark"
|
||||||
|
@State private var selectedTab = 0
|
||||||
|
|
||||||
var preferredColorScheme: ColorScheme? {
|
var preferredColorScheme: ColorScheme? {
|
||||||
colorSchemeRaw == "light" ? .light : .dark
|
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 {
|
var body: some View {
|
||||||
TabView {
|
VStack(spacing: 0) {
|
||||||
DashboardView()
|
// Content
|
||||||
.tabItem { Label("Главная", systemImage: "house.fill") }
|
Group {
|
||||||
|
switch selectedTab {
|
||||||
TrackerView()
|
case 0: DashboardView()
|
||||||
.tabItem { Label("Трекер", systemImage: "chart.bar.fill") }
|
case 1: TrackerView()
|
||||||
|
case 2: HealthView()
|
||||||
HealthView()
|
case 3: SavingsView()
|
||||||
.tabItem { Label("Здоровье", systemImage: "heart.fill") }
|
case 4: SettingsView()
|
||||||
|
default: DashboardView()
|
||||||
SavingsView()
|
}
|
||||||
.tabItem { Label("Накопления", systemImage: "building.columns.fill") }
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
SettingsView()
|
|
||||||
.tabItem { Label("Настройки", systemImage: "gearshape.fill") }
|
// Custom Tab Bar
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(0..<tabs.count, id: \.self) { index in
|
||||||
|
TabBarButton(
|
||||||
|
icon: tabs[index].icon,
|
||||||
|
label: tabs[index].label,
|
||||||
|
color: tabs[index].color,
|
||||||
|
isSelected: selectedTab == index
|
||||||
|
) {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
selectedTab = index
|
||||||
|
}
|
||||||
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
.background(
|
||||||
|
Color(hex: "06060f")
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.white.opacity(0.06))
|
||||||
|
.frame(height: 0.5)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.accentColor(Color(hex: "0D9488"))
|
|
||||||
.preferredColorScheme(preferredColorScheme)
|
.preferredColorScheme(preferredColorScheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Tab Bar Button
|
||||||
|
|
||||||
|
struct TabBarButton: View {
|
||||||
|
let icon: String
|
||||||
|
let label: String
|
||||||
|
let color: Color
|
||||||
|
let isSelected: Bool
|
||||||
|
let action: () -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ struct ChangePasswordView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
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)
|
Text("Смена пароля").font(.title3.bold()).foregroundColor(.white).padding(.top, 4)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ struct EditSavingsCategoryView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ struct SavingsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Накопления").font(.title.bold()).foregroundColor(.white)
|
Text("Накопления").font(.title.bold()).foregroundColor(.white)
|
||||||
@@ -44,8 +44,9 @@ struct SavingsOverviewTab2: View {
|
|||||||
@State private var stats: SavingsStats?
|
@State private var stats: SavingsStats?
|
||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
|
|
||||||
var recurringCategories: [SavingsCategory] { categories.filter { $0.isRecurring == true } }
|
var monthlyDetails: [MonthlyPaymentDetail] { stats?.monthlyPaymentDetails ?? [] }
|
||||||
var hasOverdue: Bool { (stats?.overdueCount ?? 0) > 0 }
|
var overdues: [OverduePayment] { stats?.overdues ?? [] }
|
||||||
|
var hasOverdue: Bool { !overdues.isEmpty }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -88,24 +89,82 @@ struct SavingsOverviewTab2: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overdue block
|
// Monthly payments from API
|
||||||
if hasOverdue, let s = stats {
|
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) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
ZStack {
|
||||||
.foregroundColor(Color(hex: "ff4757"))
|
Circle().fill(Color(hex: "ffa502").opacity(0.15)).frame(width: 40, height: 40)
|
||||||
.font(.title3)
|
Image(systemName: "calendar.badge.clock").foregroundColor(Color(hex: "ffa502")).font(.body)
|
||||||
|
}
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Просроченные платежи").font(.callout.bold()).foregroundColor(Color(hex: "ff4757"))
|
Text(detail.categoryName).font(.callout).foregroundColor(.white)
|
||||||
Text("\(s.overdueCount ?? 0) платежей на сумму \(formatAmt(s.overdueAmount ?? 0))")
|
Text("\(detail.day) числа каждого месяца").font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
||||||
.font(.caption).foregroundColor(.white.opacity(0.7))
|
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Text(formatAmt(detail.amount))
|
||||||
|
.font(.callout.bold()).foregroundColor(Color(hex: "ffa502"))
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(14)
|
||||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(hex: "ff4757").opacity(0.12)))
|
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.04)))
|
||||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(hex: "ff4757").opacity(0.3), lineWidth: 1))
|
|
||||||
.padding(.horizontal)
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Categories progress
|
// Categories progress
|
||||||
if !categories.isEmpty {
|
if !categories.isEmpty {
|
||||||
@@ -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)
|
Spacer(minLength: 80)
|
||||||
}
|
}
|
||||||
@@ -176,6 +207,7 @@ struct SavingsCategoriesTab: View {
|
|||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
@State private var showAdd = false
|
@State private var showAdd = false
|
||||||
@State private var editingCategory: SavingsCategory?
|
@State private var editingCategory: SavingsCategory?
|
||||||
|
@State private var recurringPlansCategory: SavingsCategory?
|
||||||
|
|
||||||
var active: [SavingsCategory] { categories.filter { $0.isClosed != true } }
|
var active: [SavingsCategory] { categories.filter { $0.isClosed != true } }
|
||||||
var closed: [SavingsCategory] { categories.filter { $0.isClosed == true } }
|
var closed: [SavingsCategory] { categories.filter { $0.isClosed == true } }
|
||||||
@@ -192,7 +224,9 @@ struct SavingsCategoriesTab: View {
|
|||||||
List {
|
List {
|
||||||
Section(header: Text("Активные").foregroundColor(Color(hex: "8888aa"))) {
|
Section(header: Text("Активные").foregroundColor(Color(hex: "8888aa"))) {
|
||||||
ForEach(active) { cat in
|
ForEach(active) { cat in
|
||||||
SavingsCategoryRow(category: cat)
|
SavingsCategoryRow(category: cat, showRecurringButton: cat.isRecurring == true) {
|
||||||
|
recurringPlansCategory = cat
|
||||||
|
}
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.onTapGesture { editingCategory = cat }
|
.onTapGesture { editingCategory = cat }
|
||||||
@@ -238,13 +272,19 @@ struct SavingsCategoriesTab: View {
|
|||||||
AddSavingsCategoryView(isPresented: $showAdd) { await load(refresh: true) }
|
AddSavingsCategoryView(isPresented: $showAdd) { await load(refresh: true) }
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.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) }
|
EditSavingsCategoryView(isPresented: .constant(true), category: cat) { await load(refresh: true) }
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.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 {
|
struct SavingsCategoryRow: View {
|
||||||
let category: SavingsCategory
|
let category: SavingsCategory
|
||||||
|
var showRecurringButton: Bool = false
|
||||||
|
var onRecurringTap: (() -> Void)? = nil
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -273,6 +315,14 @@ struct SavingsCategoryRow: View {
|
|||||||
Text(category.typeLabel).font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Text(category.typeLabel).font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if showRecurringButton {
|
||||||
|
Button(action: { onRecurringTap?() }) {
|
||||||
|
Image(systemName: "calendar.badge.clock")
|
||||||
|
.foregroundColor(Color(hex: "0D9488"))
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
Text(formatAmt(category.currentAmount ?? 0))
|
Text(formatAmt(category.currentAmount ?? 0))
|
||||||
.font(.callout.bold()).foregroundColor(Color(hex: category.colorHex))
|
.font(.callout.bold()).foregroundColor(Color(hex: category.colorHex))
|
||||||
}
|
}
|
||||||
@@ -302,7 +352,7 @@ struct AddSavingsCategoryView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
||||||
HStack {
|
HStack {
|
||||||
@@ -400,6 +450,7 @@ struct SavingsOperationsTab: View {
|
|||||||
@State private var selectedCategoryId: Int? = nil
|
@State private var selectedCategoryId: Int? = nil
|
||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
@State private var showAdd = false
|
@State private var showAdd = false
|
||||||
|
@State private var editingTransaction: SavingsTransaction?
|
||||||
|
|
||||||
var filtered: [SavingsTransaction] {
|
var filtered: [SavingsTransaction] {
|
||||||
guard let cid = selectedCategoryId else { return transactions }
|
guard let cid = selectedCategoryId else { return transactions }
|
||||||
@@ -434,6 +485,7 @@ struct SavingsOperationsTab: View {
|
|||||||
SavingsTransactionRow2(transaction: tx)
|
SavingsTransactionRow2(transaction: tx)
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
.onTapGesture { editingTransaction = tx }
|
||||||
}
|
}
|
||||||
.onDelete { idx in
|
.onDelete { idx in
|
||||||
let toDelete = idx.map { filtered[$0] }
|
let toDelete = idx.map { filtered[$0] }
|
||||||
@@ -466,7 +518,16 @@ struct SavingsOperationsTab: View {
|
|||||||
AddSavingsTransactionView(isPresented: $showAdd, categories: categories) { await load(refresh: true) }
|
AddSavingsTransactionView(isPresented: $showAdd, categories: categories) { await load(refresh: true) }
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.medium, .large])
|
||||||
.presentationDragIndicator(.visible)
|
.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 description = ""
|
||||||
@State private var type = "deposit"
|
@State private var type = "deposit"
|
||||||
@State private var selectedCategoryId: Int? = nil
|
@State private var selectedCategoryId: Int? = nil
|
||||||
|
@State private var date = Date()
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
var isDeposit: Bool { type == "deposit" }
|
var isDeposit: Bool { type == "deposit" }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
||||||
HStack {
|
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) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label("Описание", systemImage: "text.alignleft").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Label("Описание", systemImage: "text.alignleft").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
TextField("Комментарий...", text: $description)
|
TextField("Комментарий...", text: $description)
|
||||||
.foregroundColor(.white).padding(14)
|
.foregroundColor(.white).padding(14)
|
||||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.07)))
|
.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)
|
}.padding(20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,11 +702,21 @@ struct AddSavingsTransactionView: View {
|
|||||||
guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")),
|
guard let a = Double(amount.replacingOccurrences(of: ",", with: ".")),
|
||||||
let cid = selectedCategoryId else { return }
|
let cid = selectedCategoryId else { return }
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
|
||||||
|
let dateStr = df.string(from: date)
|
||||||
Task {
|
Task {
|
||||||
let req = CreateSavingsTransactionRequest(categoryId: cid, amount: a, type: type, description: description.isEmpty ? nil : description)
|
do {
|
||||||
try? await APIService.shared.createSavingsTransaction(token: authManager.token, request: req)
|
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 onAdded()
|
||||||
await MainActor.run { isPresented = false }
|
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)
|
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<Bool>, 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])"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,13 +12,21 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
// Profile fields
|
// Profile fields
|
||||||
@State private var telegramChatId = ""
|
@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 timezone = "Europe/Moscow"
|
||||||
@State private var username = ""
|
@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" }
|
var isDark: Bool { colorSchemeRaw != "light" }
|
||||||
|
|
||||||
let timezones = [
|
let timezones = [
|
||||||
@@ -30,7 +38,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Header / Avatar
|
// 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
|
// MARK: Notifications
|
||||||
SettingsSection(title: "Уведомления") {
|
SettingsSection(title: "Уведомления") {
|
||||||
VStack(spacing: 12) {
|
if !notifAuthorized {
|
||||||
SettingsToggle(icon: "sunrise.fill", title: "Утренние уведомления", color: "ffa502", isOn: morningNotification) {
|
Button(action: {
|
||||||
morningNotification.toggle()
|
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)
|
||||||
}
|
}
|
||||||
if morningNotification {
|
|
||||||
HStack {
|
|
||||||
Text("Время").font(.callout).foregroundColor(Color(hex: "8888aa"))
|
|
||||||
Spacer()
|
Spacer()
|
||||||
TextField("09:00", text: $morningTime)
|
Image(systemName: "arrow.right.circle.fill").foregroundColor(Theme.teal)
|
||||||
.keyboardType(.numbersAndPunctuation)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.frame(width: 60)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
Divider().background(Color.white.opacity(0.08))
|
VStack(spacing: 14) {
|
||||||
|
// Morning
|
||||||
SettingsToggle(icon: "moon.stars.fill", title: "Вечерние уведомления", color: "6366f1", isOn: eveningNotification) {
|
NotifRow(icon: "sunrise.fill", title: "Утреннее", color: Theme.orange,
|
||||||
eveningNotification.toggle()
|
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)
|
||||||
}
|
}
|
||||||
if eveningNotification {
|
|
||||||
HStack {
|
|
||||||
Text("Время").font(.callout).foregroundColor(Color(hex: "8888aa"))
|
|
||||||
Spacer()
|
Spacer()
|
||||||
TextField("21:00", text: $eveningTime)
|
Toggle("", isOn: $paymentNotif).tint(Theme.teal).labelsHidden()
|
||||||
.keyboardType(.numbersAndPunctuation)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.frame(width: 60)
|
|
||||||
}
|
}
|
||||||
.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
|
// MARK: Timezone
|
||||||
SettingsSection(title: "Часовой пояс") {
|
SettingsSection(title: "Часовой пояс") {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
HStack(spacing: 14) {
|
||||||
Label("Выберите часовой пояс", systemImage: "clock.fill")
|
GlowIcon(systemName: "clock.fill", color: Theme.blue, size: 36, iconSize: .subheadline)
|
||||||
.font(.caption).foregroundColor(Color(hex: "8888aa"))
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Picker("Часовой пояс", selection: $timezone) {
|
Text("Часовой пояс").font(.callout).foregroundColor(.white)
|
||||||
ForEach(timezones, id: \.self) { tz in Text(tz).tag(tz) }
|
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
|
// MARK: Save Button
|
||||||
@@ -186,20 +195,19 @@ struct SettingsView: View {
|
|||||||
ChangePasswordView(isPresented: $showPasswordChange)
|
ChangePasswordView(isPresented: $showPasswordChange)
|
||||||
.presentationDetents([.medium])
|
.presentationDetents([.medium])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.presentationBackground(Color(hex: "0a0a1a"))
|
.presentationBackground(Color(hex: "06060f"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadProfile() async {
|
func loadProfile() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
username = authManager.userName
|
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) {
|
if let p = try? await APIService.shared.getProfile(token: authManager.token) {
|
||||||
profile = p
|
profile = p
|
||||||
telegramChatId = p.telegramChatId ?? ""
|
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"
|
timezone = p.timezone ?? "Europe/Moscow"
|
||||||
}
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
@@ -209,10 +217,6 @@ struct SettingsView: View {
|
|||||||
isSaving = true
|
isSaving = true
|
||||||
let req = UpdateProfileRequest(
|
let req = UpdateProfileRequest(
|
||||||
telegramChatId: telegramChatId.isEmpty ? nil : telegramChatId,
|
telegramChatId: telegramChatId.isEmpty ? nil : telegramChatId,
|
||||||
morningNotification: morningNotification,
|
|
||||||
eveningNotification: eveningNotification,
|
|
||||||
morningTime: morningTime,
|
|
||||||
eveningTime: eveningTime,
|
|
||||||
timezone: timezone
|
timezone: timezone
|
||||||
)
|
)
|
||||||
_ = try? await APIService.shared.updateProfile(token: authManager.token, request: req)
|
_ = try? await APIService.shared.updateProfile(token: authManager.token, request: req)
|
||||||
@@ -228,6 +232,51 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
isSaving = false
|
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
|
// MARK: - SettingsSection
|
||||||
@@ -242,7 +291,7 @@ struct SettingsSection<Content: View>: View {
|
|||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.background(RoundedRectangle(cornerRadius: 16).fill(Color.white.opacity(0.04)))
|
.glassCard(cornerRadius: 16)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,14 +307,11 @@ struct SettingsToggle: View {
|
|||||||
let onToggle: () -> Void
|
let onToggle: () -> Void
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
ZStack {
|
GlowIcon(systemName: icon, color: Color(hex: color), size: 36, iconSize: .subheadline)
|
||||||
RoundedRectangle(cornerRadius: 8).fill(Color(hex: color).opacity(0.2)).frame(width: 36, height: 36)
|
|
||||||
Image(systemName: icon).foregroundColor(Color(hex: color)).font(.subheadline)
|
|
||||||
}
|
|
||||||
Text(title).font(.callout).foregroundColor(.white)
|
Text(title).font(.callout).foregroundColor(.white)
|
||||||
Spacer()
|
Spacer()
|
||||||
Toggle("", isOn: Binding(get: { isOn }, set: { _ in onToggle() }))
|
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 {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
ZStack {
|
GlowIcon(systemName: icon, color: Color(hex: color), size: 36, iconSize: .subheadline)
|
||||||
RoundedRectangle(cornerRadius: 8).fill(Color(hex: color).opacity(0.2)).frame(width: 36, height: 36)
|
|
||||||
Image(systemName: icon).foregroundColor(Color(hex: color)).font(.subheadline)
|
|
||||||
}
|
|
||||||
Text(title).font(.callout).foregroundColor(.white)
|
Text(title).font(.callout).foregroundColor(.white)
|
||||||
Spacer()
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,25 @@ struct AddTaskView: View {
|
|||||||
@State private var selectedColor = "#0D9488"
|
@State private var selectedColor = "#0D9488"
|
||||||
@State private var hasDueDate = false
|
@State private var hasDueDate = false
|
||||||
@State private var dueDate = Date()
|
@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 isLoading = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
let recurrenceTypes: [(String, String)] = [
|
||||||
|
("daily", "Ежедневно"),
|
||||||
|
("weekly", "Еженедельно"),
|
||||||
|
("monthly", "Ежемесячно"),
|
||||||
|
("custom", "Каждые N дней")
|
||||||
|
]
|
||||||
|
|
||||||
let priorities: [(Int, String, String)] = [
|
let priorities: [(Int, String, String)] = [
|
||||||
(1, "Низкий", "8888aa"),
|
(1, "Низкий", "8888aa"),
|
||||||
(2, "Средний", "ffa502"),
|
(2, "Средний", "ffa502"),
|
||||||
(3, "Высокий", "ff4757"),
|
(3, "Высокий", "ff4757")
|
||||||
(4, "Срочный", "ff0000")
|
|
||||||
]
|
]
|
||||||
|
|
||||||
let icons = ["✅","📌","🎯","💼","🏠","🛒","📞","🎓","💊","🚗",
|
let icons = ["✅","📌","🎯","💼","🏠","🛒","📞","🎓","💊","🚗",
|
||||||
@@ -29,21 +41,22 @@ struct AddTaskView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
RoundedRectangle(cornerRadius: 3)
|
RoundedRectangle(cornerRadius: 3)
|
||||||
.fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
.fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
||||||
HStack {
|
HStack {
|
||||||
Button("Отмена") { isPresented = false }.foregroundColor(Color(hex: "8888aa"))
|
Button("Отмена") { isPresented = false }
|
||||||
|
.font(.callout).foregroundColor(Color(hex: "8888aa"))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Новая задача").font(.headline).foregroundColor(.white)
|
Text("Новая задача").font(.headline).foregroundColor(.white)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: save) {
|
Button(action: save) {
|
||||||
if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) }
|
if isLoading { ProgressView().tint(Theme.teal).scaleEffect(0.8) }
|
||||||
else { Text("Добавить").foregroundColor(title.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) }
|
else { Text("Готово").font(.callout.bold()).foregroundColor(title.isEmpty ? Color(hex: "8888aa") : Theme.teal) }
|
||||||
}.disabled(title.isEmpty || isLoading)
|
}.disabled(title.isEmpty || isLoading)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20).padding(.vertical, 16)
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
Divider().background(Color.white.opacity(0.1))
|
Divider().background(Color.white.opacity(0.1))
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
@@ -106,7 +119,7 @@ struct AddTaskView: View {
|
|||||||
// Color
|
// Color
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
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
|
ForEach(colors, id: \.self) { c in
|
||||||
Button(action: { selectedColor = c }) {
|
Button(action: { selectedColor = c }) {
|
||||||
Circle().fill(Color(hex: String(c.dropFirst()))).frame(width: 32, height: 32)
|
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)
|
}.padding(20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,20 +188,34 @@ struct AddTaskView: View {
|
|||||||
|
|
||||||
func save() {
|
func save() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
|
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
|
||||||
let dueDateStr = hasDueDate ? df.string(from: dueDate) : nil
|
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 {
|
Task {
|
||||||
|
do {
|
||||||
let req = CreateTaskRequest(
|
let req = CreateTaskRequest(
|
||||||
title: title,
|
title: title,
|
||||||
description: description.isEmpty ? nil : description,
|
description: description.isEmpty ? nil : description,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
dueDate: dueDateStr,
|
dueDate: dueDateStr,
|
||||||
icon: selectedIcon,
|
icon: selectedIcon,
|
||||||
color: selectedColor
|
color: selectedColor,
|
||||||
|
isRecurring: isRecurring ? true : nil,
|
||||||
|
recurrenceType: isRecurring ? recurrenceType : nil,
|
||||||
|
recurrenceInterval: interval,
|
||||||
|
recurrenceEndDate: recEndStr
|
||||||
)
|
)
|
||||||
try? await APIService.shared.createTask(token: authManager.token, request: req)
|
try await APIService.shared.createTask(token: authManager.token, request: req)
|
||||||
await onAdded()
|
await onAdded()
|
||||||
await MainActor.run { isPresented = false }
|
await MainActor.run { isPresented = false }
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,24 @@ struct EditTaskView: View {
|
|||||||
@State private var selectedColor: String
|
@State private var selectedColor: String
|
||||||
@State private var hasDueDate: Bool
|
@State private var hasDueDate: Bool
|
||||||
@State private var dueDate: Date
|
@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
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
let recurrenceTypes: [(String, String)] = [
|
||||||
|
("daily", "Ежедневно"),
|
||||||
|
("weekly", "Еженедельно"),
|
||||||
|
("monthly", "Ежемесячно"),
|
||||||
|
("custom", "Каждые N дней")
|
||||||
|
]
|
||||||
|
|
||||||
let priorities: [(Int, String, String)] = [
|
let priorities: [(Int, String, String)] = [
|
||||||
(1, "Низкий", "8888aa"),
|
(1, "Низкий", "8888aa"),
|
||||||
(2, "Средний", "ffa502"),
|
(2, "Средний", "ffa502"),
|
||||||
(3, "Высокий", "ff4757"),
|
(3, "Высокий", "ff4757")
|
||||||
(4, "Срочный", "ff0000")
|
|
||||||
]
|
]
|
||||||
let icons = ["✅","📌","🎯","💼","🏠","🛒","📞","🎓","💊","🚗",
|
let icons = ["✅","📌","🎯","💼","🏠","🛒","📞","🎓","💊","🚗",
|
||||||
"📅","⚡","🔧","📬","💡","🏋️","🌿","🎵","✍️","🌏"]
|
"📅","⚡","🔧","📬","💡","🏋️","🌿","🎵","✍️","🌏"]
|
||||||
@@ -42,6 +53,16 @@ struct EditTaskView: View {
|
|||||||
self._hasDueDate = State(initialValue: false)
|
self._hasDueDate = State(initialValue: false)
|
||||||
self._dueDate = State(initialValue: Date())
|
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? {
|
static func parseDate(_ str: String) -> Date? {
|
||||||
@@ -52,21 +73,22 @@ struct EditTaskView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
RoundedRectangle(cornerRadius: 3)
|
RoundedRectangle(cornerRadius: 3)
|
||||||
.fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
.fill(Color.white.opacity(0.2)).frame(width: 40, height: 4).padding(.top, 12)
|
||||||
HStack {
|
HStack {
|
||||||
Button("Отмена") { isPresented = false }.foregroundColor(Color(hex: "8888aa"))
|
Button("Отмена") { isPresented = false }
|
||||||
|
.font(.callout).foregroundColor(Color(hex: "8888aa"))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Редактировать задачу").font(.headline).foregroundColor(.white)
|
Text("Редактировать").font(.headline).foregroundColor(.white)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: save) {
|
Button(action: save) {
|
||||||
if isLoading { ProgressView().tint(Color(hex: "0D9488")).scaleEffect(0.8) }
|
if isLoading { ProgressView().tint(Theme.teal).scaleEffect(0.8) }
|
||||||
else { Text("Сохранить").foregroundColor(title.isEmpty ? Color(hex: "8888aa") : Color(hex: "0D9488")).fontWeight(.semibold) }
|
else { Text("Готово").font(.callout.bold()).foregroundColor(title.isEmpty ? Color(hex: "8888aa") : Theme.teal) }
|
||||||
}.disabled(title.isEmpty || isLoading)
|
}.disabled(title.isEmpty || isLoading)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20).padding(.vertical, 16)
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
||||||
Divider().background(Color.white.opacity(0.1))
|
Divider().background(Color.white.opacity(0.1))
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
@@ -123,7 +145,7 @@ struct EditTaskView: View {
|
|||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label("Цвет", systemImage: "paintpalette").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
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
|
ForEach(colors, id: \.self) { c in
|
||||||
Button(action: { selectedColor = c }) {
|
Button(action: { selectedColor = c }) {
|
||||||
Circle().fill(Color(hex: String(c.dropFirst()))).frame(width: 32, height: 32)
|
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)
|
}.padding(20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,13 +208,21 @@ struct EditTaskView: View {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
|
let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
|
||||||
let dueDateStr = hasDueDate ? df.string(from: dueDate) : nil
|
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 {
|
Task {
|
||||||
let req = UpdateTaskRequest(
|
let req = UpdateTaskRequest(
|
||||||
title: title,
|
title: title,
|
||||||
description: description.isEmpty ? nil : description,
|
description: description.isEmpty ? nil : description,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
dueDate: dueDateStr,
|
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)
|
try? await APIService.shared.updateTask(token: authManager.token, id: task.id, request: req)
|
||||||
await onSaved()
|
await onSaved()
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ struct TasksView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Header
|
// Header
|
||||||
HStack {
|
HStack {
|
||||||
@@ -76,9 +76,9 @@ struct TasksView: View {
|
|||||||
}
|
}
|
||||||
.sheet(isPresented: $showAddTask) {
|
.sheet(isPresented: $showAddTask) {
|
||||||
AddTaskView(isPresented: $showAddTask) { await loadTasks() }
|
AddTaskView(isPresented: $showAddTask) { await loadTasks() }
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.presentationBackground(Color(hex: "0a0a1a"))
|
.presentationBackground(Color(hex: "06060f"))
|
||||||
}
|
}
|
||||||
.task { await loadTasks() }
|
.task { await loadTasks() }
|
||||||
.refreshable { await loadTasks(refresh: true) }
|
.refreshable { await loadTasks(refresh: true) }
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ struct TrackerView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
Color(hex: "06060f").ignoresSafeArea()
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Header
|
// Header
|
||||||
HStack {
|
HStack {
|
||||||
@@ -64,11 +64,10 @@ struct HabitListView: View {
|
|||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(activeHabits) { habit in
|
ForEach(activeHabits) { habit in
|
||||||
HabitTrackerRow(habit: habit) { await toggleHabit(habit) }
|
HabitTrackerRow(habit: habit, onToggle: { await toggleHabit(habit) }, onEdit: { editingHabit = habit })
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(EdgeInsets(top: 3, leading: 16, bottom: 3, trailing: 16))
|
.listRowInsets(EdgeInsets(top: 3, leading: 16, bottom: 3, trailing: 16))
|
||||||
.onTapGesture { editingHabit = habit }
|
|
||||||
}
|
}
|
||||||
.onDelete { idx in
|
.onDelete { idx in
|
||||||
let toDelete = idx.map { activeHabits[$0] }
|
let toDelete = idx.map { activeHabits[$0] }
|
||||||
@@ -83,7 +82,7 @@ struct HabitListView: View {
|
|||||||
if !archivedHabits.isEmpty {
|
if !archivedHabits.isEmpty {
|
||||||
Section(header: Text("Архив").foregroundColor(Color(hex: "8888aa"))) {
|
Section(header: Text("Архив").foregroundColor(Color(hex: "8888aa"))) {
|
||||||
ForEach(archivedHabits) { habit in
|
ForEach(archivedHabits) { habit in
|
||||||
HabitTrackerRow(habit: habit, isArchived: true) {}
|
HabitTrackerRow(habit: habit, isArchived: true, onToggle: {})
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
}
|
}
|
||||||
@@ -113,13 +112,13 @@ struct HabitListView: View {
|
|||||||
AddHabitView(isPresented: $showAddHabit) { await loadHabits(refresh: true) }
|
AddHabitView(isPresented: $showAddHabit) { await loadHabits(refresh: true) }
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.presentationBackground(Color(hex: "0a0a1a"))
|
.presentationBackground(Color(hex: "06060f"))
|
||||||
}
|
}
|
||||||
.sheet(item: $editingHabit) { habit in
|
.sheet(item: $editingHabit) { habit in
|
||||||
EditHabitView(isPresented: .constant(true), habit: habit) { await loadHabits(refresh: true) }
|
EditHabitView(isPresented: .constant(true), habit: habit) { await loadHabits(refresh: true) }
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.presentationBackground(Color(hex: "0a0a1a"))
|
.presentationBackground(Color(hex: "06060f"))
|
||||||
}
|
}
|
||||||
.alert("Ошибка", isPresented: $showError) { Button("OK", role: .cancel) {} }
|
.alert("Ошибка", isPresented: $showError) { Button("OK", role: .cancel) {} }
|
||||||
message: { Text(errorMsg ?? "") }
|
message: { Text(errorMsg ?? "") }
|
||||||
@@ -127,7 +126,14 @@ struct HabitListView: View {
|
|||||||
|
|
||||||
func loadHabits(refresh: Bool = false) async {
|
func loadHabits(refresh: Bool = false) async {
|
||||||
if !refresh { isLoading = true }
|
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
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +147,7 @@ struct HabitListView: View {
|
|||||||
try await APIService.shared.unlogHabit(token: authManager.token, habitId: habit.id, logId: log.id)
|
try await APIService.shared.unlogHabit(token: authManager.token, habitId: habit.id, logId: log.id)
|
||||||
}
|
}
|
||||||
} else {
|
} 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)
|
await loadHabits(refresh: true)
|
||||||
} catch APIError.serverError(let code, _) where code == 409 {
|
} catch APIError.serverError(let code, _) where code == 409 {
|
||||||
@@ -172,11 +178,14 @@ struct HabitTrackerRow: View {
|
|||||||
let habit: Habit
|
let habit: Habit
|
||||||
var isArchived: Bool = false
|
var isArchived: Bool = false
|
||||||
let onToggle: () async -> Void
|
let onToggle: () async -> Void
|
||||||
|
var onEdit: (() -> Void)? = nil
|
||||||
|
|
||||||
var accentColor: Color { Color(hex: habit.accentColorHex.replacingOccurrences(of: "#", with: "")) }
|
var accentColor: Color { Color(hex: habit.accentColorHex.replacingOccurrences(of: "#", with: "")) }
|
||||||
var isDone: Bool { habit.completedToday == true }
|
var isDone: Bool { habit.completedToday == true }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
// Tappable area for edit
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle().fill(accentColor.opacity(isArchived ? 0.05 : isDone ? 0.3 : 0.15)).frame(width: 44, height: 44)
|
Circle().fill(accentColor.opacity(isArchived ? 0.05 : isDone ? 0.3 : 0.15)).frame(width: 44, height: 44)
|
||||||
@@ -197,11 +206,16 @@ struct HabitTrackerRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { onEdit?() }
|
||||||
|
|
||||||
if !isArchived {
|
if !isArchived {
|
||||||
Button(action: { Task { await onToggle() } }) {
|
Button(action: { Task { await onToggle() } }) {
|
||||||
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
|
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
|
||||||
.font(.title2).foregroundColor(isDone ? accentColor : Color(hex: "8888aa"))
|
.font(.title2).foregroundColor(isDone ? accentColor : Color(hex: "8888aa"))
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
} else {
|
} else {
|
||||||
Text("Архив").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
Text("Архив").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||||
@@ -259,11 +273,10 @@ struct TaskListView: View {
|
|||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(filtered) { task in
|
ForEach(filtered) { task in
|
||||||
TrackerTaskRow(task: task, onToggle: { await toggleTask(task) })
|
TrackerTaskRow(task: task, onToggle: { await toggleTask(task) }, onEdit: { editingTask = task })
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16))
|
.listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16))
|
||||||
.onTapGesture { editingTask = task }
|
|
||||||
}
|
}
|
||||||
.onDelete { idx in
|
.onDelete { idx in
|
||||||
let toDelete = idx.map { filtered[$0] }
|
let toDelete = idx.map { filtered[$0] }
|
||||||
@@ -294,15 +307,15 @@ struct TaskListView: View {
|
|||||||
.task { await loadTasks() }
|
.task { await loadTasks() }
|
||||||
.sheet(isPresented: $showAddTask) {
|
.sheet(isPresented: $showAddTask) {
|
||||||
AddTaskView(isPresented: $showAddTask) { await loadTasks(refresh: true) }
|
AddTaskView(isPresented: $showAddTask) { await loadTasks(refresh: true) }
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.presentationBackground(Color(hex: "0a0a1a"))
|
.presentationBackground(Color(hex: "06060f"))
|
||||||
}
|
}
|
||||||
.sheet(item: $editingTask) { task in
|
.sheet(item: $editingTask) { task in
|
||||||
EditTaskView(isPresented: .constant(true), task: task) { await loadTasks(refresh: true) }
|
EditTaskView(isPresented: .constant(true), task: task) { await loadTasks(refresh: true) }
|
||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.presentationBackground(Color(hex: "0a0a1a"))
|
.presentationBackground(Color(hex: "06060f"))
|
||||||
}
|
}
|
||||||
.alert("Ошибка", isPresented: $showError) { Button("OK", role: .cancel) {} }
|
.alert("Ошибка", isPresented: $showError) { Button("OK", role: .cancel) {} }
|
||||||
message: { Text(errorMsg ?? "") }
|
message: { Text(errorMsg ?? "") }
|
||||||
@@ -332,14 +345,19 @@ struct TaskListView: View {
|
|||||||
struct TrackerTaskRow: View {
|
struct TrackerTaskRow: View {
|
||||||
let task: PulseTask
|
let task: PulseTask
|
||||||
let onToggle: () async -> Void
|
let onToggle: () async -> Void
|
||||||
|
var onEdit: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Button(action: { Task { await onToggle() } }) {
|
Button(action: { Task { await onToggle() } }) {
|
||||||
Image(systemName: task.completed ? "checkmark.circle.fill" : "circle")
|
Image(systemName: task.completed ? "checkmark.circle.fill" : "circle")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundColor(task.completed ? Color(hex: "0D9488") : Color(hex: "8888aa"))
|
.foregroundColor(task.completed ? Theme.teal : Color(hex: "8888aa"))
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
// Tappable area for edit
|
||||||
|
HStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(task.title)
|
Text(task.title)
|
||||||
.strikethrough(task.completed)
|
.strikethrough(task.completed)
|
||||||
@@ -354,7 +372,7 @@ struct TrackerTaskRow: View {
|
|||||||
.background(RoundedRectangle(cornerRadius: 4).fill(Color(hex: task.priorityColor).opacity(0.15)))
|
.background(RoundedRectangle(cornerRadius: 4).fill(Color(hex: task.priorityColor).opacity(0.15)))
|
||||||
}
|
}
|
||||||
if let due = task.dueDateFormatted {
|
if let due = task.dueDateFormatted {
|
||||||
Text(due).font(.caption2).foregroundColor(task.isOverdue ? Color(hex: "ff4757") : Color(hex: "8888aa"))
|
Text(due).font(.caption2).foregroundColor(task.isOverdue ? Theme.red : Color(hex: "8888aa"))
|
||||||
}
|
}
|
||||||
if task.isRecurring == true {
|
if task.isRecurring == true {
|
||||||
Image(systemName: "arrow.clockwise").font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
Image(systemName: "arrow.clockwise").font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
||||||
@@ -363,6 +381,9 @@ struct TrackerTaskRow: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { onEdit?() }
|
||||||
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05)))
|
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05)))
|
||||||
}
|
}
|
||||||
|
|||||||
10
project.yml
10
project.yml
@@ -10,8 +10,6 @@ targets:
|
|||||||
sources: PulseHealth
|
sources: PulseHealth
|
||||||
entitlements:
|
entitlements:
|
||||||
path: PulseHealth/PulseHealth.entitlements
|
path: PulseHealth/PulseHealth.entitlements
|
||||||
capabilities:
|
|
||||||
- healthkit
|
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.daniil.pulsehealth
|
PRODUCT_BUNDLE_IDENTIFIER: com.daniil.pulsehealth
|
||||||
@@ -19,4 +17,10 @@ targets:
|
|||||||
INFOPLIST_FILE: PulseHealth/Info.plist
|
INFOPLIST_FILE: PulseHealth/Info.plist
|
||||||
CODE_SIGN_STYLE: Automatic
|
CODE_SIGN_STYLE: Automatic
|
||||||
CODE_SIGN_ENTITLEMENTS: PulseHealth/PulseHealth.entitlements
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user