Renames the user-facing app to Wenza without changing identity: - CFBundleDisplayName → "Wenza" (app) and "Wenza Widgets" (widget extension). - Calendar/Health permission prompts now reference Wenza. - In-app copy updated: sidebar title, Auth + Onboarding screens, Settings about/help rows, feedback email subject, tutorial text, widget description. Bundle identifier, App Group, UserDefaults storage keys, team ID, and the internal Xcode target/project names are intentionally unchanged, so this stays the same App Store app with no user data loss. App Store listing name must be changed separately in App Store Connect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
660 lines
34 KiB
Swift
660 lines
34 KiB
Swift
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
|
|
@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> = []
|
|
@ObservedObject private var calStore = CalendarStore.shared
|
|
@ObservedObject private var hk = HealthKitManager.shared
|
|
@FocusState private var focusedField: Field?
|
|
|
|
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 { calStore.isAuthorized }
|
|
|
|
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(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 Wenza")
|
|
.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 calStore.isDenied {
|
|
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: calStore.authStatus.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: {
|
|
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: Health
|
|
// ══════════════════════════════
|
|
if hk.isAvailable {
|
|
OnboardingSectionHeader(title: "Health")
|
|
|
|
HStack(spacing: 12) {
|
|
Image(systemName: hk.authorized ? "heart.fill" : "heart")
|
|
.font(.system(size: 15))
|
|
.foregroundColor(hk.authorized ? AppColors.green : AppColors.accent)
|
|
.frame(width: 32, height: 32)
|
|
.background(hk.authorized ? AppColors.greenSoft : AppColors.accentSoft)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
.animation(KisaniSpring.micro, value: hk.authorized)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Apple Health")
|
|
.font(AppFonts.sans(13, weight: .semibold))
|
|
.foregroundColor(AppColors.text(cs))
|
|
Text(hk.authorized
|
|
? "Steps, calories & workouts on your dashboard"
|
|
: "Show your activity stats above today's tasks")
|
|
.font(AppFonts.sans(11))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Toggle("", isOn: Binding(
|
|
get: { hk.authorized },
|
|
set: { on in
|
|
if on { Task { _ = await hk.requestAuthorization() } }
|
|
else { hk.disconnect() }
|
|
}
|
|
))
|
|
.labelsHidden()
|
|
.tint(AppColors.accent)
|
|
}
|
|
.padding(.horizontal, 14).padding(.vertical, 14)
|
|
.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) {
|
|
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)
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
}
|
|
.preferredColorScheme(liveScheme)
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .keyboard) {
|
|
Spacer()
|
|
Button("Done") { focusedField = nil }
|
|
.font(AppFonts.sans(13, weight: .semibold))
|
|
.foregroundColor(AppColors.accent)
|
|
}
|
|
}
|
|
.onAppear {
|
|
calStore.refreshStatus()
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func requestCalendar() {
|
|
if calStore.isDenied, let url = URL(string: UIApplication.openSettingsURLString) {
|
|
UIApplication.shared.open(url)
|
|
} else {
|
|
calStore.requestAccess()
|
|
}
|
|
}
|
|
|
|
private func commit() {
|
|
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
|
|
let label: String
|
|
let unit: String
|
|
let placeholder: String
|
|
@Binding var text: String
|
|
let keyboard: UIKeyboardType
|
|
var focused: FocusState<OnboardingView.Field?>.Binding
|
|
let tag: OnboardingView.Field
|
|
|
|
var isFocused: Bool { focused.wrappedValue == tag }
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 15))
|
|
.foregroundColor(isFocused ? AppColors.accent : AppColors.text3(cs))
|
|
.frame(width: 32, height: 32)
|
|
.background(isFocused ? AppColors.accentSoft : AppColors.surface3(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
.animation(KisaniSpring.micro, value: isFocused)
|
|
|
|
Text(label)
|
|
.font(AppFonts.sans(13))
|
|
.foregroundColor(AppColors.text(cs))
|
|
|
|
Spacer()
|
|
|
|
HStack(spacing: 5) {
|
|
TextField(placeholder, text: $text)
|
|
.font(AppFonts.mono(14, weight: .semibold))
|
|
.foregroundColor(AppColors.text(cs))
|
|
.multilineTextAlignment(.trailing)
|
|
.keyboardType(keyboard)
|
|
.focused(focused, equals: tag)
|
|
.frame(width: 70)
|
|
.onChange(of: text) { val in
|
|
let clean: String
|
|
if keyboard == .decimalPad {
|
|
clean = String(val.filter { $0.isNumber || $0 == "." }.prefix(6))
|
|
} else {
|
|
clean = String(val.filter { $0.isNumber }.prefix(3))
|
|
}
|
|
if clean != val { text = clean }
|
|
}
|
|
|
|
Text(unit)
|
|
.font(AppFonts.mono(10, weight: .bold))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
.frame(width: 26, alignment: .leading)
|
|
}
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 14)
|
|
}
|
|
}
|