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)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
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 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()
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ final class CalendarStore: ObservableObject {
|
||||
object: ekStore,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self, let m = self.fetchedMonth else { return }
|
||||
self.fetchMonth(containing: m)
|
||||
Task { @MainActor [weak self] in
|
||||
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 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?
|
||||
}
|
||||
@@ -981,26 +984,31 @@ private struct DayTLEntry: Identifiable {
|
||||
struct DayTimelineView: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
|
||||
let date: Date
|
||||
let tasks: [TaskItem]
|
||||
let calEvents: [EKEvent]
|
||||
let workout: WorkoutProgram?
|
||||
var onWorkoutTap: (() -> Void)? = nil
|
||||
var onToggle: ((TaskItem) -> Void)? = nil
|
||||
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 }
|
||||
|
||||
@@ -1,147 +1,502 @@
|
||||
import SwiftUI
|
||||
import EventKit
|
||||
|
||||
struct OnboardingView: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
@EnvironmentObject private var workoutVM: WorkoutViewModel
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
@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
|
||||
// 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
|
||||
@AppStorage("preferredWorkoutTime") private var preferredWorkoutTime: Int = 0
|
||||
|
||||
@State private var weightStr = ""
|
||||
@State private var heightStr = ""
|
||||
// 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()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
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 ──
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
// Unit 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
|
||||
let display = weightUnit == 1 ? kg * 2.20462 : kg
|
||||
weightStr = display.truncatingRemainder(dividingBy: 1) == 0
|
||||
? "\(Int(display))"
|
||||
: String(format: "%.1f", display)
|
||||
} else {
|
||||
weightUnit = idx
|
||||
Spacer(minLength: 32)
|
||||
|
||||
// ── Brand mark ──
|
||||
VStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 22)
|
||||
.fill(AppColors.accentSoft)
|
||||
.frame(width: 72, height: 72)
|
||||
Image(systemName: "checklist.unchecked")
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
}
|
||||
VStack(spacing: 6) {
|
||||
Text("Welcome to KisaniCal")
|
||||
.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: {
|
||||
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))
|
||||
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))
|
||||
}
|
||||
.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(.bottom, 16)
|
||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||
|
||||
// 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)
|
||||
OnboardingField(
|
||||
icon: "ruler.fill",
|
||||
label: "Height",
|
||||
unit: "cm",
|
||||
placeholder: "e.g. 175",
|
||||
text: $heightStr,
|
||||
keyboard: .numberPad,
|
||||
focused: $focusedField,
|
||||
tag: .height
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
// ── Hint ──
|
||||
Text("Used for bodyweight exercises and warm-up loads.\nEdit anytime in Settings → Workout Settings.")
|
||||
.font(AppFonts.sans(11))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 14)
|
||||
// ══════════════════════════════
|
||||
// MARK: Workout
|
||||
// ══════════════════════════════
|
||||
OnboardingSectionHeader(title: "Workout")
|
||||
|
||||
Spacer()
|
||||
VStack(spacing: 0) {
|
||||
|
||||
// ── CTA ──
|
||||
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))
|
||||
// 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) {
|
||||
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)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(canContinue ? AppColors.accent : AppColors.borderHi(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
||||
.cardStyle()
|
||||
.padding(.horizontal, 20)
|
||||
.animation(KisaniSpring.snappy, value: workoutEnabled)
|
||||
|
||||
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 {
|
||||
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 }
|
||||
let rawWeight = Double(weightStr) ?? 0
|
||||
// Always store in kg internally
|
||||
bodyWeightKg = weightUnit == 1 ? rawWeight / 2.20462 : rawWeight
|
||||
heightCm = Double(heightStr) ?? 0
|
||||
appearanceMode = localAppearance
|
||||
dayStartHour = localDayStart
|
||||
todayViewStyle = localViewStyle
|
||||
showWorkoutTab = workoutEnabled
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
// 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
Reference in New Issue
Block a user