feat: floating tab bar, NLP date parsing, and task date picker
- Tab bar: TickTick-style floating pill (cornerRadius 28, shadow, systemGray5 active highlight) - NLP: month+day parsing (8th June, June 8), recurrence (every year/week/etc), intent stripping (remind me of) - Recurrence chip in add-task toolbar; isRecurring saved with task - Date chip defaults to Today on new tasks; tapping opens full date picker - Date picker: calendar grid, inline time wheel, Repeat menu (Date tab) - Duration tab: Start/End column selector, live duration counter, All Day toggle - TaskItem: added endDate and isAllDay fields - Fixed hidden Unicode character corrupting + operator in TodayView Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
This commit is contained in:
@@ -77,6 +77,7 @@ struct ContentView: View {
|
|||||||
.preferredColorScheme(preferredScheme)
|
.preferredColorScheme(preferredScheme)
|
||||||
.fullScreenCover(isPresented: $showOnboarding) {
|
.fullScreenCover(isPresented: $showOnboarding) {
|
||||||
OnboardingView(isPresented: $showOnboarding)
|
OnboardingView(isPresented: $showOnboarding)
|
||||||
|
.environmentObject(workoutVM)
|
||||||
.onDisappear { hasOnboarded = true }
|
.onDisappear { hasOnboarded = true }
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -101,6 +102,8 @@ struct ContentView: View {
|
|||||||
showQuickAddTask = true
|
showQuickAddTask = true
|
||||||
case .today:
|
case .today:
|
||||||
withAnimation(KisaniSpring.micro) { selectedTab = 0 }
|
withAnimation(KisaniSpring.micro) { selectedTab = 0 }
|
||||||
|
case .next3:
|
||||||
|
withAnimation(KisaniSpring.micro) { selectedTab = 0 }
|
||||||
case .calendar:
|
case .calendar:
|
||||||
withAnimation(KisaniSpring.micro) { selectedTab = 1 }
|
withAnimation(KisaniSpring.micro) { selectedTab = 1 }
|
||||||
case .workout:
|
case .workout:
|
||||||
@@ -165,11 +168,8 @@ struct ContentView: View {
|
|||||||
selection: $selectedTab,
|
selection: $selectedTab,
|
||||||
calIcon: calIconImage,
|
calIcon: calIconImage,
|
||||||
showMatrix: showMatrixTab,
|
showMatrix: showMatrixTab,
|
||||||
showWorkout: showWorkoutTab,
|
showWorkout: showWorkoutTab
|
||||||
isCollapsed: tabState.isCollapsed
|
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
}
|
}
|
||||||
.onAppear { UITabBar.appearance().isHidden = true }
|
.onAppear { UITabBar.appearance().isHidden = true }
|
||||||
}
|
}
|
||||||
@@ -202,12 +202,10 @@ struct ContentView: View {
|
|||||||
|
|
||||||
// MARK: - Custom Tab Bar
|
// MARK: - Custom Tab Bar
|
||||||
|
|
||||||
// Adds a transparent bottom safe-area inset equal to the floating tab bar height,
|
|
||||||
// so scroll content and FABs inside each tab clear the bar.
|
|
||||||
private extension View {
|
private extension View {
|
||||||
func tabBarInset() -> some View {
|
func tabBarInset() -> some View {
|
||||||
safeAreaInset(edge: .bottom, spacing: 0) {
|
safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
Color.clear.frame(height: 62)
|
Color.clear.frame(height: 64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,7 +215,6 @@ struct KisaniTabBar: View {
|
|||||||
let calIcon: Image
|
let calIcon: Image
|
||||||
var showMatrix: Bool
|
var showMatrix: Bool
|
||||||
var showWorkout: Bool
|
var showWorkout: Bool
|
||||||
var isCollapsed: Bool = false
|
|
||||||
@Environment(\.colorScheme) private var cs
|
@Environment(\.colorScheme) private var cs
|
||||||
|
|
||||||
private struct Entry: Identifiable {
|
private struct Entry: Identifiable {
|
||||||
@@ -244,33 +241,35 @@ struct KisaniTabBar: View {
|
|||||||
} label: {
|
} label: {
|
||||||
ZStack {
|
ZStack {
|
||||||
if selection == entry.id {
|
if selection == entry.id {
|
||||||
RoundedRectangle(cornerRadius: 14)
|
RoundedRectangle(cornerRadius: 16)
|
||||||
.fill(AppColors.surface2(cs))
|
.fill(Color(uiColor: cs == .dark ? .tertiarySystemFill : .systemGray5))
|
||||||
.frame(width: 52, height: 44)
|
.frame(width: 62, height: 42)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
.transition(.opacity.combined(with: .scale(scale: 0.88)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let img = entry.custom {
|
if let img = entry.custom {
|
||||||
img
|
img
|
||||||
.font(.system(size: isCollapsed ? 17 : 20))
|
.font(.system(size: 22))
|
||||||
.foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text(cs))
|
.foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text3(cs))
|
||||||
} else if let icon = entry.sfIcon {
|
} else if let icon = entry.sfIcon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: isCollapsed ? 15 : 17, weight: selection == entry.id ? .semibold : .regular))
|
.font(.system(size: 22, weight: selection == entry.id ? .semibold : .regular))
|
||||||
.foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text(cs))
|
.foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text3(cs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity).frame(height: isCollapsed ? 38 : 50)
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 52)
|
||||||
}
|
}
|
||||||
.buttonStyle(PressButtonStyle(scale: 0.88))
|
.buttonStyle(PressButtonStyle(scale: 0.90))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 8)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 28)
|
RoundedRectangle(cornerRadius: 28)
|
||||||
.fill(AppColors.surface(cs))
|
.fill(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground))
|
||||||
.shadow(color: .black.opacity(isCollapsed ? 0.06 : 0.10), radius: 20, x: 0, y: 6)
|
.shadow(color: .black.opacity(cs == .dark ? 0.40 : 0.10), radius: 20, x: 0, y: 6)
|
||||||
.shadow(color: .black.opacity(0.04), radius: 4, x: 0, y: 1)
|
|
||||||
)
|
)
|
||||||
.animation(KisaniSpring.snappy, value: isCollapsed)
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
KisaniCal/KisaniCal.entitlements
Normal file
5
KisaniCal/KisaniCal.entitlements
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict/>
|
||||||
|
</plist>
|
||||||
@@ -12,6 +12,9 @@ struct TaskItem: Identifiable, Codable, Equatable {
|
|||||||
var category: TaskCategory = .reminder
|
var category: TaskCategory = .reminder
|
||||||
var taskColor: TaskColor = .orange
|
var taskColor: TaskColor = .orange
|
||||||
var isRecurring: Bool = false
|
var isRecurring: Bool = false
|
||||||
|
var isPinned: Bool = false
|
||||||
|
var endDate: Date? = nil
|
||||||
|
var isAllDay: Bool = false
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Quadrant: String, CaseIterable, Identifiable, Codable {
|
enum Quadrant: String, CaseIterable, Identifiable, Codable {
|
||||||
@@ -84,12 +87,26 @@ class TaskViewModel: ObservableObject {
|
|||||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||||
}
|
}
|
||||||
var upcomingTasks: [TaskItem] {
|
var upcomingTasks: [TaskItem] {
|
||||||
let tomorrow = Calendar.current.date(byAdding: .day, value: 1,
|
let cal = Calendar.current
|
||||||
to: Calendar.current.startOfDay(for: Date()))!
|
let fourDays = cal.date(byAdding: .day, value: 4, to: cal.startOfDay(for: Date()))!
|
||||||
return tasks
|
return tasks
|
||||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= tomorrow }
|
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= fourDays }
|
||||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||||
}
|
}
|
||||||
|
var next3DaysTasks: [(date: Date, tasks: [TaskItem])] {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let today = cal.startOfDay(for: Date())
|
||||||
|
return (1...3).compactMap { offset -> (Date, [TaskItem])? in
|
||||||
|
guard let day = cal.date(byAdding: .day, value: offset, to: today),
|
||||||
|
let nextDay = cal.date(byAdding: .day, value: 1, to: day) else { return nil }
|
||||||
|
let dayTasks = tasks.filter {
|
||||||
|
!$0.isComplete &&
|
||||||
|
($0.dueDate ?? .distantFuture) >= day &&
|
||||||
|
($0.dueDate ?? .distantFuture) < nextDay
|
||||||
|
}.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||||
|
return dayTasks.isEmpty ? nil : (day, dayTasks)
|
||||||
|
}
|
||||||
|
}
|
||||||
var completedTodayTasks: [TaskItem] {
|
var completedTodayTasks: [TaskItem] {
|
||||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
let startOfDay = Calendar.current.startOfDay(for: Date())
|
||||||
return tasks
|
return tasks
|
||||||
@@ -104,15 +121,42 @@ class TaskViewModel: ObservableObject {
|
|||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
func tasks(for quadrant: Quadrant) -> [TaskItem] {
|
func tasks(for quadrant: Quadrant) -> [TaskItem] {
|
||||||
tasks.filter { $0.quadrant == quadrant }
|
tasks
|
||||||
|
.filter { $0.quadrant == quadrant }
|
||||||
|
.sorted { $0.isPinned && !$1.isPinned }
|
||||||
|
}
|
||||||
|
|
||||||
|
func pin(_ task: TaskItem) {
|
||||||
|
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||||
|
tasks[idx].isPinned.toggle()
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDate(_ task: TaskItem, date: Date?) {
|
||||||
|
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||||
|
tasks[idx].dueDate = date
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCategory(_ task: TaskItem, category: TaskCategory) {
|
||||||
|
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||||
|
tasks[idx].category = category
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
func moveToQuadrant(taskID: UUID, quadrant: Quadrant) {
|
||||||
|
guard let idx = tasks.firstIndex(where: { $0.id == taskID }) else { return }
|
||||||
|
tasks[idx].quadrant = quadrant
|
||||||
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
|
func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
|
||||||
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
|
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
|
||||||
taskColor: TaskColor = .orange, isRecurring: Bool = false) {
|
taskColor: TaskColor = .orange, isRecurring: Bool = false,
|
||||||
|
endDate: Date? = nil, isAllDay: Bool = false) {
|
||||||
tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
|
tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
|
||||||
quadrant: quadrant, category: category,
|
quadrant: quadrant, category: category,
|
||||||
taskColor: taskColor, isRecurring: isRecurring))
|
taskColor: taskColor, isRecurring: isRecurring,
|
||||||
|
endDate: endDate, isAllDay: isAllDay))
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ final class CalendarStore: ObservableObject {
|
|||||||
object: ekStore,
|
object: ekStore,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self] _ in
|
) { [weak self] _ in
|
||||||
guard let self, let m = self.fetchedMonth else { return }
|
Task { @MainActor [weak self] in
|
||||||
self.fetchMonth(containing: m)
|
guard let self, let m = self.fetchedMonth else { return }
|
||||||
|
self.fetchMonth(containing: m)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,6 +976,7 @@ private struct DayTLEntry: Identifiable {
|
|||||||
var isComplete: Bool
|
var isComplete: Bool
|
||||||
var isAllDay: Bool
|
var isAllDay: Bool
|
||||||
var sortKey: Int // < 0 = all-day/special; else minute-of-day
|
var sortKey: Int // < 0 = all-day/special; else minute-of-day
|
||||||
|
var sortGroup: Int = 0 // 0 = active, 1 = completed, 2 = overdue
|
||||||
var taskRef: TaskItem?
|
var taskRef: TaskItem?
|
||||||
var workoutRef: WorkoutProgram?
|
var workoutRef: WorkoutProgram?
|
||||||
}
|
}
|
||||||
@@ -981,26 +984,31 @@ private struct DayTLEntry: Identifiable {
|
|||||||
struct DayTimelineView: View {
|
struct DayTimelineView: View {
|
||||||
@Environment(\.colorScheme) private var cs
|
@Environment(\.colorScheme) private var cs
|
||||||
|
|
||||||
let date: Date
|
let date: Date
|
||||||
let tasks: [TaskItem]
|
let tasks: [TaskItem]
|
||||||
let calEvents: [EKEvent]
|
var overdueTasks: [TaskItem] = []
|
||||||
let workout: WorkoutProgram?
|
let calEvents: [EKEvent]
|
||||||
var onWorkoutTap: (() -> Void)? = nil
|
let workout: WorkoutProgram?
|
||||||
var onToggle: ((TaskItem) -> Void)? = nil
|
var onWorkoutTap: (() -> Void)? = nil
|
||||||
|
var onToggle: ((TaskItem) -> Void)? = nil
|
||||||
|
|
||||||
private let cal = Calendar.current
|
private let cal = Calendar.current
|
||||||
|
private static let overdueFmt: DateFormatter = {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "MMM d"; return f
|
||||||
|
}()
|
||||||
|
|
||||||
private var entries: [DayTLEntry] {
|
private var entries: [DayTLEntry] {
|
||||||
var out: [DayTLEntry] = []
|
var out: [DayTLEntry] = []
|
||||||
|
|
||||||
// All-day tasks (no specific time)
|
// All-day tasks (no specific time)
|
||||||
for t in tasks where !t.hasTime {
|
for t in tasks where !t.hasTime {
|
||||||
|
let grp = t.isComplete ? 1 : 0
|
||||||
out.append(DayTLEntry(
|
out.append(DayTLEntry(
|
||||||
timeLabel: "All Day",
|
timeLabel: "All Day",
|
||||||
title: t.title,
|
title: t.title,
|
||||||
color: t.taskColor.color(),
|
color: t.isComplete ? AppColors.green : t.taskColor.color(),
|
||||||
isComplete: t.isComplete,
|
isComplete: t.isComplete,
|
||||||
isAllDay: true, sortKey: -1, taskRef: t
|
isAllDay: true, sortKey: -1, sortGroup: grp, taskRef: t
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1010,11 +1018,11 @@ struct DayTimelineView: View {
|
|||||||
timeLabel: "All Day",
|
timeLabel: "All Day",
|
||||||
title: ev.title ?? "Event",
|
title: ev.title ?? "Event",
|
||||||
color: Color(cgColor: ev.calendar.cgColor),
|
color: Color(cgColor: ev.calendar.cgColor),
|
||||||
isComplete: false, isAllDay: true, sortKey: -1
|
isComplete: false, isAllDay: true, sortKey: -1, sortGroup: 0
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workout
|
// Workout — always pinned first (sortGroup -1)
|
||||||
if let w = workout {
|
if let w = workout {
|
||||||
let done = w.doneSets == w.totalSets && w.totalSets > 0
|
let done = w.doneSets == w.totalSets && w.totalSets > 0
|
||||||
out.append(DayTLEntry(
|
out.append(DayTLEntry(
|
||||||
@@ -1022,7 +1030,7 @@ struct DayTimelineView: View {
|
|||||||
title: w.name,
|
title: w.name,
|
||||||
subtitle: "\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s")",
|
subtitle: "\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s")",
|
||||||
color: AppColors.blue,
|
color: AppColors.blue,
|
||||||
isComplete: done, isAllDay: true, sortKey: -2, workoutRef: w
|
isComplete: done, isAllDay: true, sortKey: -2, sortGroup: -1, workoutRef: w
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1032,11 +1040,12 @@ struct DayTimelineView: View {
|
|||||||
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d)
|
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d)
|
||||||
let hd = h == 0 ? 12 : (h > 12 ? h - 12 : h)
|
let hd = h == 0 ? 12 : (h > 12 ? h - 12 : h)
|
||||||
let ms = m == 0 ? "" : ":\(String(format: "%02d", m))"
|
let ms = m == 0 ? "" : ":\(String(format: "%02d", m))"
|
||||||
|
let grp = t.isComplete ? 1 : 0
|
||||||
out.append(DayTLEntry(
|
out.append(DayTLEntry(
|
||||||
timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM",
|
timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM",
|
||||||
title: t.title, color: t.taskColor.color(),
|
title: t.title, color: t.isComplete ? AppColors.green : t.taskColor.color(),
|
||||||
isComplete: t.isComplete, isAllDay: false,
|
isComplete: t.isComplete, isAllDay: false,
|
||||||
sortKey: h * 60 + m, taskRef: t
|
sortKey: h * 60 + m, sortGroup: grp, taskRef: t
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1057,11 +1066,26 @@ struct DayTimelineView: View {
|
|||||||
timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM",
|
timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM",
|
||||||
title: ev.title ?? "Event", subtitle: endLabel,
|
title: ev.title ?? "Event", subtitle: endLabel,
|
||||||
color: Color(cgColor: ev.calendar.cgColor),
|
color: Color(cgColor: ev.calendar.cgColor),
|
||||||
isComplete: false, isAllDay: false, sortKey: h * 60 + m
|
isComplete: false, isAllDay: false, sortKey: h * 60 + m, sortGroup: 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overdue tasks — shown last with accent color and their date as label
|
||||||
|
for t in overdueTasks {
|
||||||
|
let label = t.dueDate.map {
|
||||||
|
cal.isDateInYesterday($0) ? "Yest." : Self.overdueFmt.string(from: $0)
|
||||||
|
} ?? "Overdue"
|
||||||
|
out.append(DayTLEntry(
|
||||||
|
timeLabel: label,
|
||||||
|
title: t.title,
|
||||||
|
color: AppColors.accent,
|
||||||
|
isComplete: false, isAllDay: true,
|
||||||
|
sortKey: -1, sortGroup: 2, taskRef: t
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
return out.sorted {
|
return out.sorted {
|
||||||
|
if $0.sortGroup != $1.sortGroup { return $0.sortGroup < $1.sortGroup }
|
||||||
if $0.sortKey < 0 && $1.sortKey < 0 { return $0.sortKey > $1.sortKey }
|
if $0.sortKey < 0 && $1.sortKey < 0 { return $0.sortKey > $1.sortKey }
|
||||||
if $0.sortKey < 0 { return true }
|
if $0.sortKey < 0 { return true }
|
||||||
if $1.sortKey < 0 { return false }
|
if $1.sortKey < 0 { return false }
|
||||||
|
|||||||
@@ -1,147 +1,502 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import EventKit
|
||||||
|
|
||||||
struct OnboardingView: View {
|
struct OnboardingView: View {
|
||||||
@Environment(\.colorScheme) private var cs
|
@Environment(\.colorScheme) private var cs
|
||||||
|
@EnvironmentObject private var workoutVM: WorkoutViewModel
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
|
|
||||||
@AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 0
|
// Persisted calendar settings
|
||||||
@AppStorage("heightCm") private var heightCm: Double = 0
|
@AppStorage("appearanceMode") private var appearanceMode: Int = 0
|
||||||
@AppStorage("weightUnit") private var weightUnit: Int = 0 // 0 = kg, 1 = lbs
|
@AppStorage("dayStartHour") private var dayStartHour: Int = 7
|
||||||
|
@AppStorage("todayViewStyle") private var todayViewStyle: Int = 0
|
||||||
|
// Persisted workout settings
|
||||||
|
@AppStorage("showWorkoutTab") private var showWorkoutTab: Bool = false
|
||||||
|
@AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 0
|
||||||
|
@AppStorage("heightCm") private var heightCm: Double = 0
|
||||||
|
@AppStorage("weightUnit") private var weightUnit: Int = 0
|
||||||
|
@AppStorage("preferredWorkoutTime") private var preferredWorkoutTime: Int = 0
|
||||||
|
|
||||||
@State private var weightStr = ""
|
// Local (uncommitted) state
|
||||||
@State private var heightStr = ""
|
@State private var localAppearance: Int = 2 // dark by default
|
||||||
|
@State private var localDayStart: Int = 7
|
||||||
|
@State private var localViewStyle: Int = 0
|
||||||
|
|
||||||
|
@State private var workoutEnabled = false
|
||||||
|
@State private var weightStr = ""
|
||||||
|
@State private var heightStr = ""
|
||||||
|
@State private var selectedDays: Set<Int> = []
|
||||||
|
@State private var calAuthStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .event)
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
|
|
||||||
fileprivate enum Field { case weight, height }
|
private let ekStore = EKEventStore()
|
||||||
|
|
||||||
private var canContinue: Bool {
|
private let timeSlots: [(Int, String, String)] = [
|
||||||
(Double(weightStr) ?? 0) > 0 && (Double(heightStr) ?? 0) > 0
|
(0, "Morning", "sunrise.fill"),
|
||||||
|
(1, "Midday", "sun.max.fill"),
|
||||||
|
(2, "Afternoon", "sun.min.fill"),
|
||||||
|
(3, "Evening", "sunset.fill")
|
||||||
|
]
|
||||||
|
fileprivate enum Field { case weight, height }
|
||||||
|
private let weekdays: [(Int, String)] = [(2,"M"),(3,"T"),(4,"W"),(5,"T"),(6,"F"),(7,"S"),(1,"S")]
|
||||||
|
|
||||||
|
private var calIsGranted: Bool {
|
||||||
|
if #available(iOS 17.0, *) { return calAuthStatus == .fullAccess }
|
||||||
|
return calAuthStatus == .authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
private var liveScheme: ColorScheme? {
|
||||||
|
switch localAppearance {
|
||||||
|
case 1: return .light
|
||||||
|
case 2: return .dark
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
AppColors.background(cs).ignoresSafeArea()
|
AppColors.background(cs).ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
ScrollView(showsIndicators: false) {
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// ── Brand mark ──
|
|
||||||
VStack(spacing: 14) {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 22)
|
|
||||||
.fill(AppColors.accentSoft)
|
|
||||||
.frame(width: 72, height: 72)
|
|
||||||
Image(systemName: "dumbbell.fill")
|
|
||||||
.font(.system(size: 32, weight: .semibold))
|
|
||||||
.foregroundColor(AppColors.accent)
|
|
||||||
}
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
Text("Welcome to Kisani")
|
|
||||||
.font(AppFonts.sans(26, weight: .bold))
|
|
||||||
.foregroundColor(AppColors.text(cs))
|
|
||||||
Text("Personalizes your warm-up and body stats")
|
|
||||||
.font(AppFonts.sans(14))
|
|
||||||
.foregroundColor(AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.bottom, 40)
|
|
||||||
|
|
||||||
// ── Form card ──
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Unit toggle
|
Spacer(minLength: 32)
|
||||||
HStack(spacing: 0) {
|
|
||||||
ForEach([("kg", 0), ("lbs", 1)], id: \.1) { label, idx in
|
// ── Brand mark ──
|
||||||
Button {
|
VStack(spacing: 14) {
|
||||||
withAnimation(KisaniSpring.micro) {
|
ZStack {
|
||||||
// Convert existing weight string when toggling
|
RoundedRectangle(cornerRadius: 22)
|
||||||
if let val = Double(weightStr) {
|
.fill(AppColors.accentSoft)
|
||||||
let kg = weightUnit == 1 ? val / 2.20462 : val
|
.frame(width: 72, height: 72)
|
||||||
weightUnit = idx
|
Image(systemName: "checklist.unchecked")
|
||||||
let display = weightUnit == 1 ? kg * 2.20462 : kg
|
.font(.system(size: 30, weight: .semibold))
|
||||||
weightStr = display.truncatingRemainder(dividingBy: 1) == 0
|
.foregroundColor(AppColors.accent)
|
||||||
? "\(Int(display))"
|
}
|
||||||
: String(format: "%.1f", display)
|
VStack(spacing: 6) {
|
||||||
} else {
|
Text("Welcome to KisaniCal")
|
||||||
weightUnit = idx
|
.font(AppFonts.sans(26, weight: .bold))
|
||||||
|
.foregroundColor(AppColors.text(cs))
|
||||||
|
Text("Tasks, calendar, and workouts\n— all in one place.")
|
||||||
|
.font(AppFonts.sans(14))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 36)
|
||||||
|
|
||||||
|
// ══════════════════════════════
|
||||||
|
// MARK: Calendar
|
||||||
|
// ══════════════════════════════
|
||||||
|
OnboardingSectionHeader(title: "Calendar")
|
||||||
|
|
||||||
|
// Calendar access
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: calIsGranted ? "calendar.badge.checkmark" : "calendar")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundColor(calIsGranted ? AppColors.green : AppColors.accent)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(calIsGranted ? AppColors.greenSoft : AppColors.accentSoft)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.animation(KisaniSpring.micro, value: calIsGranted)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("iPhone Calendar")
|
||||||
|
.font(AppFonts.sans(13, weight: .semibold))
|
||||||
|
.foregroundColor(AppColors.text(cs))
|
||||||
|
Text(calIsGranted
|
||||||
|
? "Events will appear in your calendar view"
|
||||||
|
: "Show your events alongside tasks")
|
||||||
|
.font(AppFonts.sans(11))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if calIsGranted {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundColor(AppColors.green)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
} else if calAuthStatus == .denied {
|
||||||
|
Text("Denied")
|
||||||
|
.font(AppFonts.mono(10, weight: .bold))
|
||||||
|
.foregroundColor(AppColors.accent)
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||||
|
.background(AppColors.accentSoft)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
} else {
|
||||||
|
Button { requestCalendar() } label: {
|
||||||
|
Text("Enable")
|
||||||
|
.font(AppFonts.mono(10, weight: .bold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 7)
|
||||||
|
.background(AppColors.accent)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(PressButtonStyle(scale: 0.92))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(KisaniSpring.micro, value: calAuthStatus.rawValue)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 14)
|
||||||
|
.cardStyle()
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
|
// Appearance + Day Start + View Style card
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
|
||||||
|
// Appearance
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "paintbrush.fill")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundColor(AppColors.accent)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(AppColors.accentSoft)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
Text("Appearance")
|
||||||
|
.font(AppFonts.sans(13))
|
||||||
|
.foregroundColor(AppColors.text(cs))
|
||||||
|
}
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(
|
||||||
|
[(2, "Dark", "moon.fill"),
|
||||||
|
(1, "Light", "sun.max.fill"),
|
||||||
|
(0, "System", "iphone")] as [(Int, String, String)],
|
||||||
|
id: \.0
|
||||||
|
) { idx, label, icon in
|
||||||
|
Button {
|
||||||
|
withAnimation(KisaniSpring.micro) { localAppearance = idx }
|
||||||
|
} label: {
|
||||||
|
let on = localAppearance == idx
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
Text(label)
|
||||||
|
.font(AppFonts.mono(9.5, weight: .bold))
|
||||||
|
}
|
||||||
|
.foregroundColor(on ? AppColors.accent : AppColors.text3(cs))
|
||||||
|
.frame(maxWidth: .infinity).frame(height: 36)
|
||||||
|
.background(on ? AppColors.accentSoft : AppColors.surface2(cs))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(
|
||||||
|
on ? AppColors.accent : AppColors.border(cs),
|
||||||
|
lineWidth: on ? 1.5 : 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(PressButtonStyle(scale: 0.93))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||||
|
|
||||||
|
Divider().background(AppColors.border(cs)).padding(.leading, 56)
|
||||||
|
|
||||||
|
// Day Starts At
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "clock.fill")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundColor(AppColors.blue)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(AppColors.blueSoft)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
Text("Day Starts At")
|
||||||
|
.font(AppFonts.sans(13))
|
||||||
|
.foregroundColor(AppColors.text(cs))
|
||||||
|
Spacer()
|
||||||
|
Menu {
|
||||||
|
ForEach(4...11, id: \.self) { hour in
|
||||||
|
Button {
|
||||||
|
withAnimation(KisaniSpring.micro) { localDayStart = hour }
|
||||||
|
} label: {
|
||||||
|
let h12 = hour > 12 ? hour - 12 : hour
|
||||||
|
let ap = hour < 12 ? "AM" : "PM"
|
||||||
|
Label(
|
||||||
|
"\(h12):00 \(ap)",
|
||||||
|
systemImage: localDayStart == hour ? "checkmark" : ""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(label)
|
let h12 = localDayStart > 12 ? localDayStart - 12 : localDayStart
|
||||||
.font(AppFonts.mono(12, weight: .bold))
|
let ap = localDayStart < 12 ? "AM" : "PM"
|
||||||
.foregroundColor(weightUnit == idx ? AppColors.accent : AppColors.text3(cs))
|
HStack(spacing: 4) {
|
||||||
.frame(maxWidth: .infinity)
|
Text("\(h12):00 \(ap)")
|
||||||
.padding(.vertical, 8)
|
.font(AppFonts.mono(12, weight: .semibold))
|
||||||
.background(weightUnit == idx ? AppColors.accentSoft : Color.clear)
|
.foregroundColor(AppColors.accent)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||||
|
.background(AppColors.accentSoft)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||||
}
|
}
|
||||||
.buttonStyle(PressButtonStyle(scale: 0.96))
|
|
||||||
}
|
}
|
||||||
}
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||||
.padding(4)
|
|
||||||
.background(AppColors.surface2(cs))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(AppColors.border(cs), lineWidth: 1))
|
|
||||||
.padding(.horizontal, 28)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
|
|
||||||
// Fields
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
OnboardingField(
|
|
||||||
icon: "scalemass.fill",
|
|
||||||
label: "Body Weight",
|
|
||||||
unit: weightUnit == 0 ? "kg" : "lbs",
|
|
||||||
placeholder: weightUnit == 0 ? "e.g. 80" : "e.g. 176",
|
|
||||||
text: $weightStr,
|
|
||||||
keyboard: .decimalPad,
|
|
||||||
focused: $focusedField,
|
|
||||||
tag: .weight
|
|
||||||
)
|
|
||||||
Divider().background(AppColors.border(cs)).padding(.leading, 56)
|
Divider().background(AppColors.border(cs)).padding(.leading, 56)
|
||||||
OnboardingField(
|
|
||||||
icon: "ruler.fill",
|
// Today View Style
|
||||||
label: "Height",
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
unit: "cm",
|
HStack(spacing: 12) {
|
||||||
placeholder: "e.g. 175",
|
Image(systemName: "list.bullet.below.rectangle")
|
||||||
text: $heightStr,
|
.font(.system(size: 15))
|
||||||
keyboard: .numberPad,
|
.foregroundColor(AppColors.accent)
|
||||||
focused: $focusedField,
|
.frame(width: 32, height: 32)
|
||||||
tag: .height
|
.background(AppColors.accentSoft)
|
||||||
)
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
Text("Today View")
|
||||||
|
.font(AppFonts.sans(13))
|
||||||
|
.foregroundColor(AppColors.text(cs))
|
||||||
|
}
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(
|
||||||
|
[(0, "Timeline", "chart.bar.xaxis"),
|
||||||
|
(1, "Task List", "list.bullet")] as [(Int, String, String)],
|
||||||
|
id: \.0
|
||||||
|
) { idx, label, icon in
|
||||||
|
Button {
|
||||||
|
withAnimation(KisaniSpring.micro) { localViewStyle = idx }
|
||||||
|
} label: {
|
||||||
|
let on = localViewStyle == idx
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
Text(label)
|
||||||
|
.font(AppFonts.mono(9.5, weight: .bold))
|
||||||
|
}
|
||||||
|
.foregroundColor(on ? AppColors.accent : AppColors.text3(cs))
|
||||||
|
.frame(maxWidth: .infinity).frame(height: 36)
|
||||||
|
.background(on ? AppColors.accentSoft : AppColors.surface2(cs))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(
|
||||||
|
on ? AppColors.accent : AppColors.border(cs),
|
||||||
|
lineWidth: on ? 1.5 : 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(PressButtonStyle(scale: 0.93))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
.cardStyle()
|
.cardStyle()
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
}
|
.padding(.bottom, 28)
|
||||||
|
|
||||||
// ── Hint ──
|
// ══════════════════════════════
|
||||||
Text("Used for bodyweight exercises and warm-up loads.\nEdit anytime in Settings → Workout Settings.")
|
// MARK: Workout
|
||||||
.font(AppFonts.sans(11))
|
// ══════════════════════════════
|
||||||
.foregroundColor(AppColors.text3(cs))
|
OnboardingSectionHeader(title: "Workout")
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal, 32)
|
|
||||||
.padding(.top, 14)
|
|
||||||
|
|
||||||
Spacer()
|
VStack(spacing: 0) {
|
||||||
|
|
||||||
// ── CTA ──
|
// Activate toggle
|
||||||
Button { commit() } label: {
|
HStack(spacing: 12) {
|
||||||
HStack(spacing: 8) {
|
Image(systemName: "dumbbell.fill")
|
||||||
Text("Get Started")
|
.font(.system(size: 15))
|
||||||
.font(AppFonts.sans(15, weight: .bold))
|
.foregroundColor(AppColors.blue)
|
||||||
Image(systemName: "arrow.right")
|
.frame(width: 32, height: 32)
|
||||||
.font(.system(size: 13, weight: .bold))
|
.background(AppColors.blueSoft)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Workout Module")
|
||||||
|
.font(AppFonts.sans(13, weight: .semibold))
|
||||||
|
.foregroundColor(AppColors.text(cs))
|
||||||
|
Text("Track gym sessions and exercises")
|
||||||
|
.font(AppFonts.sans(11))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Toggle("", isOn: $workoutEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.tint(AppColors.accent)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 14)
|
||||||
|
|
||||||
|
// Expanded workout details
|
||||||
|
if workoutEnabled {
|
||||||
|
Divider().background(AppColors.border(cs))
|
||||||
|
|
||||||
|
// kg / lbs toggle
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach([("kg", 0), ("lbs", 1)], id: \.1) { label, idx in
|
||||||
|
Button {
|
||||||
|
withAnimation(KisaniSpring.micro) {
|
||||||
|
if let val = Double(weightStr) {
|
||||||
|
let kg = weightUnit == 1 ? val / 2.20462 : val
|
||||||
|
weightUnit = idx
|
||||||
|
let display = weightUnit == 1 ? kg * 2.20462 : kg
|
||||||
|
weightStr = display.truncatingRemainder(dividingBy: 1) == 0
|
||||||
|
? "\(Int(display))"
|
||||||
|
: String(format: "%.1f", display)
|
||||||
|
} else {
|
||||||
|
weightUnit = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(label)
|
||||||
|
.font(AppFonts.mono(12, weight: .bold))
|
||||||
|
.foregroundColor(weightUnit == idx ? AppColors.accent : AppColors.text3(cs))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(weightUnit == idx ? AppColors.accentSoft : Color.clear)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||||
|
}
|
||||||
|
.buttonStyle(PressButtonStyle(scale: 0.96))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
.background(AppColors.surface2(cs))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(AppColors.border(cs), lineWidth: 1))
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
OnboardingField(
|
||||||
|
icon: "scalemass.fill",
|
||||||
|
label: "Body Weight",
|
||||||
|
unit: weightUnit == 0 ? "kg" : "lbs",
|
||||||
|
placeholder: weightUnit == 0 ? "e.g. 80" : "e.g. 176",
|
||||||
|
text: $weightStr,
|
||||||
|
keyboard: .decimalPad,
|
||||||
|
focused: $focusedField,
|
||||||
|
tag: .weight
|
||||||
|
)
|
||||||
|
Divider().background(AppColors.border(cs)).padding(.leading, 56)
|
||||||
|
OnboardingField(
|
||||||
|
icon: "ruler.fill",
|
||||||
|
label: "Height",
|
||||||
|
unit: "cm",
|
||||||
|
placeholder: "e.g. 175",
|
||||||
|
text: $heightStr,
|
||||||
|
keyboard: .numberPad,
|
||||||
|
focused: $focusedField,
|
||||||
|
tag: .height
|
||||||
|
)
|
||||||
|
Divider().background(AppColors.border(cs)).padding(.leading, 56)
|
||||||
|
|
||||||
|
// Workout days
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "calendar.badge.checkmark")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundColor(AppColors.blue)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(AppColors.blueSoft)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
Text("Workout Days")
|
||||||
|
.font(AppFonts.sans(13))
|
||||||
|
.foregroundColor(AppColors.text(cs))
|
||||||
|
Spacer()
|
||||||
|
if !selectedDays.isEmpty {
|
||||||
|
Text("\(selectedDays.count)d/wk")
|
||||||
|
.font(AppFonts.mono(9.5, weight: .bold))
|
||||||
|
.foregroundColor(AppColors.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
ForEach(weekdays, id: \.0) { wd, label in
|
||||||
|
Button {
|
||||||
|
withAnimation(KisaniSpring.micro) {
|
||||||
|
if selectedDays.contains(wd) { selectedDays.remove(wd) }
|
||||||
|
else { selectedDays.insert(wd) }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
let on = selectedDays.contains(wd)
|
||||||
|
Text(label)
|
||||||
|
.font(AppFonts.mono(10, weight: .bold))
|
||||||
|
.foregroundColor(on ? AppColors.blue : AppColors.text3(cs))
|
||||||
|
.frame(maxWidth: .infinity).frame(height: 32)
|
||||||
|
.background(on ? AppColors.blueSoft : AppColors.surface2(cs))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 7).stroke(
|
||||||
|
on ? AppColors.blue : AppColors.border(cs),
|
||||||
|
lineWidth: on ? 1.5 : 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(PressButtonStyle(scale: 0.92))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||||
|
|
||||||
|
Divider().background(AppColors.border(cs)).padding(.leading, 56)
|
||||||
|
|
||||||
|
// Preferred workout time
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "clock.fill")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundColor(AppColors.accent)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(AppColors.accentSoft)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
Text("Preferred Time")
|
||||||
|
.font(AppFonts.sans(13))
|
||||||
|
.foregroundColor(AppColors.text(cs))
|
||||||
|
}
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
ForEach(timeSlots, id: \.0) { idx, label, icon in
|
||||||
|
Button {
|
||||||
|
withAnimation(KisaniSpring.micro) { preferredWorkoutTime = idx }
|
||||||
|
} label: {
|
||||||
|
let on = preferredWorkoutTime == idx
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundColor(on ? AppColors.accent : AppColors.text3(cs))
|
||||||
|
Text(label)
|
||||||
|
.font(AppFonts.mono(9, weight: .bold))
|
||||||
|
.foregroundColor(on ? AppColors.accent : AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity).frame(height: 46)
|
||||||
|
.background(on ? AppColors.accentSoft : AppColors.surface2(cs))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 9).stroke(
|
||||||
|
on ? AppColors.accent : AppColors.border(cs),
|
||||||
|
lineWidth: on ? 1.5 : 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(PressButtonStyle(scale: 0.92))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.foregroundColor(.white)
|
.cardStyle()
|
||||||
.frame(maxWidth: .infinity)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 16)
|
.animation(KisaniSpring.snappy, value: workoutEnabled)
|
||||||
.background(canContinue ? AppColors.accent : AppColors.borderHi(cs))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
Text("All settings can be changed later in Settings.")
|
||||||
|
.font(AppFonts.sans(11))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
.padding(.top, 16)
|
||||||
|
|
||||||
|
Spacer(minLength: 24)
|
||||||
|
|
||||||
|
// ── Get Started ──
|
||||||
|
Button { commit() } label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("Get Started")
|
||||||
|
.font(AppFonts.sans(15, weight: .bold))
|
||||||
|
Image(systemName: "arrow.right")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(AppColors.accent)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
||||||
|
}
|
||||||
|
.buttonStyle(PressButtonStyle())
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 36)
|
||||||
}
|
}
|
||||||
.buttonStyle(PressButtonStyle())
|
|
||||||
.disabled(!canContinue)
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.bottom, 36)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.preferredColorScheme(liveScheme)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .keyboard) {
|
ToolbarItemGroup(placement: .keyboard) {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -150,20 +505,73 @@ struct OnboardingView: View {
|
|||||||
.foregroundColor(AppColors.accent)
|
.foregroundColor(AppColors.accent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { focusedField = .weight } }
|
.onAppear {
|
||||||
|
calAuthStatus = EKEventStore.authorizationStatus(for: .event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func requestCalendar() {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
ekStore.requestFullAccessToEvents { granted, _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
withAnimation(KisaniSpring.snappy) {
|
||||||
|
self.calAuthStatus = granted ? .fullAccess : .denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ekStore.requestAccess(to: .event) { granted, _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
withAnimation(KisaniSpring.snappy) {
|
||||||
|
self.calAuthStatus = granted ? .authorized : .denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func commit() {
|
private func commit() {
|
||||||
guard canContinue else { return }
|
appearanceMode = localAppearance
|
||||||
let rawWeight = Double(weightStr) ?? 0
|
dayStartHour = localDayStart
|
||||||
// Always store in kg internally
|
todayViewStyle = localViewStyle
|
||||||
bodyWeightKg = weightUnit == 1 ? rawWeight / 2.20462 : rawWeight
|
showWorkoutTab = workoutEnabled
|
||||||
heightCm = Double(heightStr) ?? 0
|
if workoutEnabled {
|
||||||
|
let rawWeight = Double(weightStr) ?? 0
|
||||||
|
if rawWeight > 0 {
|
||||||
|
bodyWeightKg = weightUnit == 1 ? rawWeight / 2.20462 : rawWeight
|
||||||
|
}
|
||||||
|
if let h = Double(heightStr), h > 0 { heightCm = h }
|
||||||
|
if !selectedDays.isEmpty { workoutVM.setScheduleForDays(selectedDays) }
|
||||||
|
}
|
||||||
withAnimation(KisaniSpring.exit) { isPresented = false }
|
withAnimation(KisaniSpring.exit) { isPresented = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Section Header
|
||||||
|
|
||||||
|
private struct OnboardingSectionHeader: View {
|
||||||
|
@Environment(\.colorScheme) private var cs
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(title.uppercased())
|
||||||
|
.font(AppFonts.mono(9.5, weight: .bold))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppColors.border(cs))
|
||||||
|
.frame(height: 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Field Row
|
// MARK: - Field Row
|
||||||
|
|
||||||
private struct OnboardingField: View {
|
private struct OnboardingField: View {
|
||||||
@Environment(\.colorScheme) private var cs
|
@Environment(\.colorScheme) private var cs
|
||||||
let icon: String
|
let icon: String
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user