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)
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView(isPresented: $showOnboarding)
.environmentObject(workoutVM)
.onDisappear { hasOnboarded = true }
}
.onAppear {
@@ -101,6 +102,8 @@ struct ContentView: View {
showQuickAddTask = true
case .today:
withAnimation(KisaniSpring.micro) { selectedTab = 0 }
case .next3:
withAnimation(KisaniSpring.micro) { selectedTab = 0 }
case .calendar:
withAnimation(KisaniSpring.micro) { selectedTab = 1 }
case .workout:
@@ -165,11 +168,8 @@ struct ContentView: View {
selection: $selectedTab,
calIcon: calIconImage,
showMatrix: showMatrixTab,
showWorkout: showWorkoutTab,
isCollapsed: tabState.isCollapsed
showWorkout: showWorkoutTab
)
.padding(.horizontal, 24)
.padding(.bottom, 8)
}
.onAppear { UITabBar.appearance().isHidden = true }
}
@@ -202,12 +202,10 @@ struct ContentView: View {
// 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 {
func tabBarInset() -> some View {
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
var showMatrix: Bool
var showWorkout: Bool
var isCollapsed: Bool = false
@Environment(\.colorScheme) private var cs
private struct Entry: Identifiable {
@@ -244,33 +241,35 @@ struct KisaniTabBar: View {
} label: {
ZStack {
if selection == entry.id {
RoundedRectangle(cornerRadius: 14)
.fill(AppColors.surface2(cs))
.frame(width: 52, height: 44)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
RoundedRectangle(cornerRadius: 16)
.fill(Color(uiColor: cs == .dark ? .tertiarySystemFill : .systemGray5))
.frame(width: 62, height: 42)
.transition(.opacity.combined(with: .scale(scale: 0.88)))
}
if let img = entry.custom {
img
.font(.system(size: isCollapsed ? 17 : 20))
.foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text(cs))
.font(.system(size: 22))
.foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text3(cs))
} else if let icon = entry.sfIcon {
Image(systemName: icon)
.font(.system(size: isCollapsed ? 15 : 17, weight: selection == entry.id ? .semibold : .regular))
.foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text(cs))
.font(.system(size: 22, weight: selection == entry.id ? .semibold : .regular))
.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(
RoundedRectangle(cornerRadius: 28)
.fill(AppColors.surface(cs))
.shadow(color: .black.opacity(isCollapsed ? 0.06 : 0.10), radius: 20, x: 0, y: 6)
.shadow(color: .black.opacity(0.04), radius: 4, x: 0, y: 1)
.fill(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground))
.shadow(color: .black.opacity(cs == .dark ? 0.40 : 0.10), radius: 20, x: 0, y: 6)
)
.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 taskColor: TaskColor = .orange
var isRecurring: Bool = false
var isPinned: Bool = false
var endDate: Date? = nil
var isAllDay: Bool = false
}
enum Quadrant: String, CaseIterable, Identifiable, Codable {
@@ -84,12 +87,26 @@ class TaskViewModel: ObservableObject {
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
}
var upcomingTasks: [TaskItem] {
let tomorrow = Calendar.current.date(byAdding: .day, value: 1,
to: Calendar.current.startOfDay(for: Date()))!
let cal = Calendar.current
let fourDays = cal.date(byAdding: .day, value: 4, to: cal.startOfDay(for: Date()))!
return tasks
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= tomorrow }
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= fourDays }
.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] {
let startOfDay = Calendar.current.startOfDay(for: Date())
return tasks
@@ -104,15 +121,42 @@ class TaskViewModel: ObservableObject {
save()
}
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,
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,
quadrant: quadrant, category: category,
taskColor: taskColor, isRecurring: isRecurring))
taskColor: taskColor, isRecurring: isRecurring,
endDate: endDate, isAllDay: isAllDay))
save()
}

View File

@@ -18,10 +18,12 @@ final class CalendarStore: ObservableObject {
object: ekStore,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
guard let self, let m = self.fetchedMonth else { return }
self.fetchMonth(containing: m)
}
}
}
deinit {
if let t = changeToken { NotificationCenter.default.removeObserver(t) }
@@ -974,6 +976,7 @@ private struct DayTLEntry: Identifiable {
var isComplete: Bool
var isAllDay: Bool
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 workoutRef: WorkoutProgram?
}
@@ -983,24 +986,29 @@ struct DayTimelineView: View {
let date: Date
let tasks: [TaskItem]
var overdueTasks: [TaskItem] = []
let calEvents: [EKEvent]
let workout: WorkoutProgram?
var onWorkoutTap: (() -> Void)? = nil
var onToggle: ((TaskItem) -> Void)? = nil
private let cal = Calendar.current
private static let overdueFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMM d"; return f
}()
private var entries: [DayTLEntry] {
var out: [DayTLEntry] = []
// All-day tasks (no specific time)
for t in tasks where !t.hasTime {
let grp = t.isComplete ? 1 : 0
out.append(DayTLEntry(
timeLabel: "All Day",
title: t.title,
color: t.taskColor.color(),
color: t.isComplete ? AppColors.green : t.taskColor.color(),
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",
title: ev.title ?? "Event",
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 {
let done = w.doneSets == w.totalSets && w.totalSets > 0
out.append(DayTLEntry(
@@ -1022,7 +1030,7 @@ struct DayTimelineView: View {
title: w.name,
subtitle: "\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s")",
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 hd = h == 0 ? 12 : (h > 12 ? h - 12 : h)
let ms = m == 0 ? "" : ":\(String(format: "%02d", m))"
let grp = t.isComplete ? 1 : 0
out.append(DayTLEntry(
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,
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",
title: ev.title ?? "Event", subtitle: endLabel,
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 {
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 { return true }
if $1.sortKey < 0 { return false }

View File

@@ -1,29 +1,65 @@
import SwiftUI
import EventKit
struct OnboardingView: View {
@Environment(\.colorScheme) private var cs
@EnvironmentObject private var workoutVM: WorkoutViewModel
@Binding var isPresented: Bool
// Persisted calendar settings
@AppStorage("appearanceMode") private var appearanceMode: Int = 0
@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 // 0 = kg, 1 = lbs
@AppStorage("weightUnit") private var weightUnit: Int = 0
@AppStorage("preferredWorkoutTime") private var preferredWorkoutTime: Int = 0
// Local (uncommitted) state
@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?
fileprivate enum Field { case weight, height }
private let ekStore = EKEventStore()
private var canContinue: Bool {
(Double(weightStr) ?? 0) > 0 && (Double(heightStr) ?? 0) > 0
private let timeSlots: [(Int, String, String)] = [
(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 {
ZStack {
AppColors.background(cs).ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
Spacer()
Spacer(minLength: 32)
// Brand mark
VStack(spacing: 14) {
@@ -31,29 +67,262 @@ struct OnboardingView: View {
RoundedRectangle(cornerRadius: 22)
.fill(AppColors.accentSoft)
.frame(width: 72, height: 72)
Image(systemName: "dumbbell.fill")
.font(.system(size: 32, weight: .semibold))
Image(systemName: "checklist.unchecked")
.font(.system(size: 30, weight: .semibold))
.foregroundColor(AppColors.accent)
}
VStack(spacing: 6) {
Text("Welcome to Kisani")
Text("Welcome to KisaniCal")
.font(AppFonts.sans(26, weight: .bold))
.foregroundColor(AppColors.text(cs))
Text("Personalizes your warm-up and body stats")
Text("Tasks, calendar, and workouts\n— all in one place.")
.font(AppFonts.sans(14))
.foregroundColor(AppColors.text3(cs))
.multilineTextAlignment(.center)
}
}
.padding(.bottom, 40)
.padding(.bottom, 36)
// Form card
//
// 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) {
// Unit toggle
// 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: {
let h12 = localDayStart > 12 ? localDayStart - 12 : localDayStart
let ap = localDayStart < 12 ? "AM" : "PM"
HStack(spacing: 4) {
Text("\(h12):00 \(ap)")
.font(AppFonts.mono(12, weight: .semibold))
.foregroundColor(AppColors.accent)
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))
}
}
.padding(.horizontal, 14).padding(.vertical, 12)
Divider().background(AppColors.border(cs)).padding(.leading, 56)
// Today View Style
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
Image(systemName: "list.bullet.below.rectangle")
.font(.system(size: 15))
.foregroundColor(AppColors.accent)
.frame(width: 32, height: 32)
.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()
.padding(.horizontal, 20)
.padding(.bottom, 28)
//
// MARK: Workout
//
OnboardingSectionHeader(title: "Workout")
VStack(spacing: 0) {
// Activate toggle
HStack(spacing: 12) {
Image(systemName: "dumbbell.fill")
.font(.system(size: 15))
.foregroundColor(AppColors.blue)
.frame(width: 32, height: 32)
.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) {
// Convert existing weight string when toggling
if let val = Double(weightStr) {
let kg = weightUnit == 1 ? val / 2.20462 : val
weightUnit = idx
@@ -82,10 +351,8 @@ struct OnboardingView: View {
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(AppColors.border(cs), lineWidth: 1))
.padding(.horizontal, 28)
.padding(.bottom, 16)
.padding(.top, 12)
// Fields
VStack(spacing: 0) {
OnboardingField(
icon: "scalemass.fill",
label: "Body Weight",
@@ -107,22 +374,109 @@ struct OnboardingView: View {
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)
}
}
.cardStyle()
.padding(.horizontal, 20)
}
.animation(KisaniSpring.snappy, value: workoutEnabled)
// Hint
Text("Used for bodyweight exercises and warm-up loads.\nEdit anytime in Settings → Workout Settings.")
Text("All settings can be changed later in Settings.")
.font(AppFonts.sans(11))
.foregroundColor(AppColors.text3(cs))
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.top, 14)
.padding(.top, 16)
Spacer()
Spacer(minLength: 24)
// CTA
// Get Started
Button { commit() } label: {
HStack(spacing: 8) {
Text("Get Started")
@@ -133,15 +487,16 @@ struct OnboardingView: View {
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(canContinue ? AppColors.accent : AppColors.borderHi(cs))
.background(AppColors.accent)
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
}
.buttonStyle(PressButtonStyle())
.disabled(!canContinue)
.padding(.horizontal, 20)
.padding(.bottom, 36)
}
}
}
.preferredColorScheme(liveScheme)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
@@ -150,20 +505,73 @@ struct OnboardingView: View {
.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() {
guard canContinue else { return }
appearanceMode = localAppearance
dayStartHour = localDayStart
todayViewStyle = localViewStyle
showWorkoutTab = workoutEnabled
if workoutEnabled {
let rawWeight = Double(weightStr) ?? 0
// Always store in kg internally
if rawWeight > 0 {
bodyWeightKg = weightUnit == 1 ? rawWeight / 2.20462 : rawWeight
heightCm = Double(heightStr) ?? 0
}
if let h = Double(heightStr), h > 0 { heightCm = h }
if !selectedDays.isEmpty { workoutVM.setScheduleForDays(selectedDays) }
}
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
private struct OnboardingField: View {
@Environment(\.colorScheme) private var cs
let icon: String

File diff suppressed because it is too large Load Diff