Files
KisaniCal/KisaniCal/Views/OnboardingView.swift
kutesir 81813530c8 Rebrand app to "Wenza" (display name + user-facing copy)
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>
2026-06-15 18:31:51 +03:00

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