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:
Robin Kutesa
2026-05-30 23:49:30 +03:00
parent a11258dea5
commit 68efa02803
6 changed files with 1601 additions and 300 deletions

View File

@@ -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)
} }
} }

View 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>

View File

@@ -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()
} }

View File

@@ -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 }

View File

@@ -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