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:
@@ -8,7 +8,7 @@ struct TrackerView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(hex: "0a0a1a").ignoresSafeArea()
|
||||
Color(hex: "06060f").ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
@@ -64,11 +64,10 @@ struct HabitListView: View {
|
||||
} else {
|
||||
List {
|
||||
ForEach(activeHabits) { habit in
|
||||
HabitTrackerRow(habit: habit) { await toggleHabit(habit) }
|
||||
HabitTrackerRow(habit: habit, onToggle: { await toggleHabit(habit) }, onEdit: { editingHabit = habit })
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 3, leading: 16, bottom: 3, trailing: 16))
|
||||
.onTapGesture { editingHabit = habit }
|
||||
}
|
||||
.onDelete { idx in
|
||||
let toDelete = idx.map { activeHabits[$0] }
|
||||
@@ -83,7 +82,7 @@ struct HabitListView: View {
|
||||
if !archivedHabits.isEmpty {
|
||||
Section(header: Text("Архив").foregroundColor(Color(hex: "8888aa"))) {
|
||||
ForEach(archivedHabits) { habit in
|
||||
HabitTrackerRow(habit: habit, isArchived: true) {}
|
||||
HabitTrackerRow(habit: habit, isArchived: true, onToggle: {})
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
@@ -113,13 +112,13 @@ struct HabitListView: View {
|
||||
AddHabitView(isPresented: $showAddHabit) { await loadHabits(refresh: true) }
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(Color(hex: "0a0a1a"))
|
||||
.presentationBackground(Color(hex: "06060f"))
|
||||
}
|
||||
.sheet(item: $editingHabit) { habit in
|
||||
EditHabitView(isPresented: .constant(true), habit: habit) { await loadHabits(refresh: true) }
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(Color(hex: "0a0a1a"))
|
||||
.presentationBackground(Color(hex: "06060f"))
|
||||
}
|
||||
.alert("Ошибка", isPresented: $showError) { Button("OK", role: .cancel) {} }
|
||||
message: { Text(errorMsg ?? "") }
|
||||
@@ -127,7 +126,14 @@ struct HabitListView: View {
|
||||
|
||||
func loadHabits(refresh: Bool = false) async {
|
||||
if !refresh { isLoading = true }
|
||||
habits = (try? await APIService.shared.getHabits(token: authManager.token, includeArchived: true)) ?? []
|
||||
var loaded = (try? await APIService.shared.getHabits(token: authManager.token, includeArchived: true)) ?? []
|
||||
// Enrich with completedToday
|
||||
let today = todayStr()
|
||||
for i in loaded.indices where loaded[i].isArchived != true {
|
||||
let logs = (try? await APIService.shared.getHabitLogs(token: authManager.token, habitId: loaded[i].id, days: 1)) ?? []
|
||||
loaded[i].completedToday = logs.contains { $0.dateOnly == today }
|
||||
}
|
||||
habits = loaded
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@@ -141,7 +147,7 @@ struct HabitListView: View {
|
||||
try await APIService.shared.unlogHabit(token: authManager.token, habitId: habit.id, logId: log.id)
|
||||
}
|
||||
} else {
|
||||
try await APIService.shared.logHabit(token: authManager.token, id: habit.id)
|
||||
try await APIService.shared.logHabit(token: authManager.token, id: habit.id, date: todayStr())
|
||||
}
|
||||
await loadHabits(refresh: true)
|
||||
} catch APIError.serverError(let code, _) where code == 409 {
|
||||
@@ -172,36 +178,44 @@ struct HabitTrackerRow: View {
|
||||
let habit: Habit
|
||||
var isArchived: Bool = false
|
||||
let onToggle: () async -> Void
|
||||
var onEdit: (() -> Void)? = nil
|
||||
|
||||
var accentColor: Color { Color(hex: habit.accentColorHex.replacingOccurrences(of: "#", with: "")) }
|
||||
var isDone: Bool { habit.completedToday == true }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle().fill(accentColor.opacity(isArchived ? 0.05 : isDone ? 0.3 : 0.15)).frame(width: 44, height: 44)
|
||||
Text(habit.displayIcon).font(.title3).opacity(isArchived ? 0.4 : 1)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(habit.name)
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundColor(isArchived ? Color(hex: "8888aa") : .white)
|
||||
HStack(spacing: 8) {
|
||||
Text(habit.frequencyLabel).font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||
if let streak = habit.currentStreak, streak > 0 {
|
||||
HStack(spacing: 2) {
|
||||
Text("🔥").font(.caption2)
|
||||
Text("\(streak) дн.").font(.caption).foregroundColor(Color(hex: "ffa502"))
|
||||
// Tappable area for edit
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle().fill(accentColor.opacity(isArchived ? 0.05 : isDone ? 0.3 : 0.15)).frame(width: 44, height: 44)
|
||||
Text(habit.displayIcon).font(.title3).opacity(isArchived ? 0.4 : 1)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(habit.name)
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundColor(isArchived ? Color(hex: "8888aa") : .white)
|
||||
HStack(spacing: 8) {
|
||||
Text(habit.frequencyLabel).font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||
if let streak = habit.currentStreak, streak > 0 {
|
||||
HStack(spacing: 2) {
|
||||
Text("🔥").font(.caption2)
|
||||
Text("\(streak) дн.").font(.caption).foregroundColor(Color(hex: "ffa502"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onEdit?() }
|
||||
|
||||
if !isArchived {
|
||||
Button(action: { Task { await onToggle() } }) {
|
||||
Image(systemName: isDone ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title2).foregroundColor(isDone ? accentColor : Color(hex: "8888aa"))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Text("Архив").font(.caption).foregroundColor(Color(hex: "8888aa"))
|
||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||
@@ -259,11 +273,10 @@ struct TaskListView: View {
|
||||
} else {
|
||||
List {
|
||||
ForEach(filtered) { task in
|
||||
TrackerTaskRow(task: task, onToggle: { await toggleTask(task) })
|
||||
TrackerTaskRow(task: task, onToggle: { await toggleTask(task) }, onEdit: { editingTask = task })
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 2, leading: 16, bottom: 2, trailing: 16))
|
||||
.onTapGesture { editingTask = task }
|
||||
}
|
||||
.onDelete { idx in
|
||||
let toDelete = idx.map { filtered[$0] }
|
||||
@@ -294,15 +307,15 @@ struct TaskListView: View {
|
||||
.task { await loadTasks() }
|
||||
.sheet(isPresented: $showAddTask) {
|
||||
AddTaskView(isPresented: $showAddTask) { await loadTasks(refresh: true) }
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(Color(hex: "0a0a1a"))
|
||||
.presentationBackground(Color(hex: "06060f"))
|
||||
}
|
||||
.sheet(item: $editingTask) { task in
|
||||
EditTaskView(isPresented: .constant(true), task: task) { await loadTasks(refresh: true) }
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
.presentationBackground(Color(hex: "0a0a1a"))
|
||||
.presentationBackground(Color(hex: "06060f"))
|
||||
}
|
||||
.alert("Ошибка", isPresented: $showError) { Button("OK", role: .cancel) {} }
|
||||
message: { Text(errorMsg ?? "") }
|
||||
@@ -332,36 +345,44 @@ struct TaskListView: View {
|
||||
struct TrackerTaskRow: View {
|
||||
let task: PulseTask
|
||||
let onToggle: () async -> Void
|
||||
var onEdit: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: { Task { await onToggle() } }) {
|
||||
Image(systemName: task.completed ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title3)
|
||||
.foregroundColor(task.completed ? Color(hex: "0D9488") : Color(hex: "8888aa"))
|
||||
.foregroundColor(task.completed ? Theme.teal : Color(hex: "8888aa"))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(task.title)
|
||||
.strikethrough(task.completed)
|
||||
.foregroundColor(task.completed ? Color(hex: "8888aa") : .white)
|
||||
.font(.callout)
|
||||
HStack(spacing: 6) {
|
||||
if let p = task.priority, p > 0 {
|
||||
Text(task.priorityDisplayName)
|
||||
.font(.caption2)
|
||||
.foregroundColor(Color(hex: task.priorityColor))
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(Color(hex: task.priorityColor).opacity(0.15)))
|
||||
}
|
||||
if let due = task.dueDateFormatted {
|
||||
Text(due).font(.caption2).foregroundColor(task.isOverdue ? Color(hex: "ff4757") : Color(hex: "8888aa"))
|
||||
}
|
||||
if task.isRecurring == true {
|
||||
Image(systemName: "arrow.clockwise").font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Tappable area for edit
|
||||
HStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(task.title)
|
||||
.strikethrough(task.completed)
|
||||
.foregroundColor(task.completed ? Color(hex: "8888aa") : .white)
|
||||
.font(.callout)
|
||||
HStack(spacing: 6) {
|
||||
if let p = task.priority, p > 0 {
|
||||
Text(task.priorityDisplayName)
|
||||
.font(.caption2)
|
||||
.foregroundColor(Color(hex: task.priorityColor))
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(Color(hex: task.priorityColor).opacity(0.15)))
|
||||
}
|
||||
if let due = task.dueDateFormatted {
|
||||
Text(due).font(.caption2).foregroundColor(task.isOverdue ? Theme.red : Color(hex: "8888aa"))
|
||||
}
|
||||
if task.isRecurring == true {
|
||||
Image(systemName: "arrow.clockwise").font(.caption2).foregroundColor(Color(hex: "8888aa"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onEdit?() }
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 12).fill(Color.white.opacity(0.05)))
|
||||
|
||||
Reference in New Issue
Block a user