Some checks failed
CI / build-and-test (push) Has been cancelled
Follow-up to KC-55's design question: habit reminders (Prayer/Bed/Coffee) stay out of Today/Matrix/Activity History (would add 3-9 daily checkboxes forever, against 'nothing shouts'), but now get a 'Done' action on the notification itself -- like the workout confirm flow's 'Yes, log it' -- that logs a private timestamp. New HABIT_LOG category/HABIT_DONE action; every habit notification carries userInfo['habitType'] (bed/prayer/coffee -- slot detail collapses to the type). logHabitDone/habitDoneCount store a deduped date array in UserDefaults.kisani, mirroring the lifetime-tally streak philosophy from KC-40 rather than a breakable streak. Settings shows a quiet 'Xd' badge per habit -- the only place the count appears; nothing touches the task list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1650 lines
92 KiB
Swift
1650 lines
92 KiB
Swift
import SwiftUI
|
||
|
||
extension Bundle {
|
||
/// Public marketing version, e.g. "2.0".
|
||
var marketingVersion: String { infoDictionary?["CFBundleShortVersionString"] as? String ?? "—" }
|
||
/// Build number, e.g. "9".
|
||
var buildNumber: String { infoDictionary?["CFBundleVersion"] as? String ?? "" }
|
||
/// Combined display, e.g. "2.0 (9)" — single source of truth for any UI showing the version.
|
||
var versionDisplay: String { buildNumber.isEmpty ? marketingVersion : "\(marketingVersion) (\(buildNumber))" }
|
||
}
|
||
|
||
struct SettingsView: View {
|
||
@Environment(\.colorScheme) var cs
|
||
@EnvironmentObject var workoutVM: WorkoutViewModel
|
||
@EnvironmentObject var taskVM: TaskViewModel
|
||
@ObservedObject private var auth = AuthManager.shared
|
||
@ObservedObject private var calStore = CalendarStore.shared
|
||
|
||
// Persisted settings
|
||
@AppStorage("appearanceMode") private var appearanceMode = 0
|
||
@AppStorage("soundsOn") private var soundsOn = true
|
||
@AppStorage("showMatrixTab") private var showMatrixTab = true
|
||
@AppStorage("showWorkoutTab") private var showWorkoutTab = true
|
||
@AppStorage("dateFormat") private var dateFormat = 0
|
||
@AppStorage("firstDayMonday") private var firstDayMonday = false
|
||
@AppStorage("showCompleted") private var showCompleted = false
|
||
@AppStorage("weightUnit") private var weightUnit = 0
|
||
@AppStorage("defaultSets") private var defaultSets = 3
|
||
@AppStorage("defaultReps") private var defaultReps = 10
|
||
@AppStorage("iCloudSync") private var iCloudSync = true
|
||
@AppStorage("googleCalSync") private var googleCalSync = false
|
||
@AppStorage("iCloudCalSync") private var iCloudCalSync = true
|
||
@AppStorage("streakGoal") private var streakGoal = 5
|
||
// App-Group store so NotificationManager (which reads UserDefaults.kisani) sees it.
|
||
@AppStorage("kisani.periodMilestones", store: UserDefaults.kisani) private var periodMilestones = true
|
||
@AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
|
||
@AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
|
||
@AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
|
||
|
||
// Sheet presentation
|
||
@State private var showProfile = false
|
||
@State private var showTabBar = false
|
||
@State private var showAppearance = false
|
||
@State private var showDateTime = false
|
||
@State private var showWidgets = false
|
||
@State private var showGeneral = false
|
||
@State private var showImport = false
|
||
@State private var showCalendar = false
|
||
@State private var showBackup = false
|
||
@State private var showWorkoutSettings = false
|
||
@State private var showHabitReminders = false
|
||
@State private var showRestTimer = false
|
||
@State private var showHelp = false
|
||
@State private var showFollow = false
|
||
@State private var showAbout = false
|
||
|
||
private var appearanceLabel: String { ["System", "Light", "Dark"][appearanceMode] }
|
||
private var restLabel: String { workoutVM.restTimerEnabled ? "\(workoutVM.restTimerSeconds)s" : "Off" }
|
||
private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" }
|
||
private var dateLabel: String { ["MMM d", "MM/DD", "DD/MM"][dateFormat] }
|
||
private var habitsLabel: String? {
|
||
let on = [bedReminderEnabled, prayerReminderEnabled, coffeeReminderEnabled].filter { $0 }.count
|
||
return on == 0 ? nil : "\(on) on"
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
AppColors.background(cs).ignoresSafeArea()
|
||
ScrollView(showsIndicators: false) {
|
||
VStack(spacing: 0) {
|
||
Color.clear.frame(height: 0).trackScrollForTabBar()
|
||
|
||
Text("Settings")
|
||
.font(AppFonts.sans(15, weight: .semibold))
|
||
.foregroundColor(AppColors.text(cs))
|
||
.padding(.top, 10).padding(.bottom, 4)
|
||
|
||
// ── Profile ──
|
||
Button { showProfile = true } label: {
|
||
HStack(spacing: 12) {
|
||
ZStack {
|
||
Circle().fill(AppColors.accentSoft)
|
||
Text(initials(for: auth.currentUser?.displayName))
|
||
.font(AppFonts.sans(15, weight: .bold))
|
||
.foregroundColor(AppColors.accent)
|
||
}
|
||
.frame(width: 44, height: 44)
|
||
.overlay(Circle().stroke(AppColors.accent.opacity(0.18), lineWidth: 1.5))
|
||
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
Text(auth.currentUser?.displayName ?? "My Account")
|
||
.font(AppFonts.sans(14, weight: .semibold))
|
||
.foregroundColor(AppColors.text(cs))
|
||
HStack(spacing: 5) {
|
||
AppBadge(
|
||
text: workoutVM.streakDays > 0 ? "\(workoutVM.streakDays)d \(streakBars(workoutVM.streakDays, goal: streakGoal))" : "no streak",
|
||
bg: AppColors.accentSoft, fg: AppColors.accent
|
||
)
|
||
AppBadge(
|
||
text: "\(taskVM.tasks.filter { $0.isComplete }.count) done",
|
||
bg: AppColors.greenSoft, fg: AppColors.green
|
||
)
|
||
AppBadge(
|
||
text: weightUnit == 0 ? "kg" : "lbs",
|
||
bg: AppColors.surface3(cs), fg: AppColors.text2(cs)
|
||
)
|
||
}
|
||
}
|
||
Spacer()
|
||
Image(systemName: "chevron.right")
|
||
.font(.system(size: 10))
|
||
.foregroundColor(AppColors.text3(cs))
|
||
}
|
||
.padding(14).cardStyle()
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.horizontal, 14).padding(.top, 10).padding(.bottom, 14)
|
||
|
||
// ── CUSTOMIZE ──
|
||
SttSection(label: "Customize") {
|
||
SttNavRow(icon: "menucard", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Tab Bar", value: "\(showMatrixTab && showWorkoutTab ? "5" : showMatrixTab || showWorkoutTab ? "4" : "3") tabs") { showTabBar = true }
|
||
SttNavRow(icon: "paintpalette", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Appearance", value: appearanceLabel) { showAppearance = true }
|
||
SttToggleRow(icon: "bell", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Sounds & Notifications", isOn: $soundsOn)
|
||
SttToggleRow(icon: "calendar.badge.clock", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Time Milestones",
|
||
isOn: Binding(
|
||
get: { periodMilestones },
|
||
set: { periodMilestones = $0
|
||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) }))
|
||
SttNavRow(icon: "clock", bg: AppColors.greenSoft, fg: AppColors.green, label: "Date & Time", value: dateLabel) { showDateTime = true }
|
||
SttNavRow(icon: "puzzlepiece", bg: AppColors.yellowSoft, fg: AppColors.yellow, label: "Widgets") { showWidgets = true }
|
||
SttNavRow(icon: "gearshape", bg: AppColors.surface3(cs), label: "General", isLast: true) { showGeneral = true }
|
||
}
|
||
|
||
// ── DATA ──
|
||
SttSection(label: "Data") {
|
||
SttNavRow(icon: "calendar", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Calendar", value: calStore.isAuthorized ? "● Connected" : (calStore.isDenied ? "Denied" : "Connect"), valueColor: calStore.isAuthorized ? AppColors.green : nil) { showCalendar = true }
|
||
SttNavRow(icon: "arrow.up.right", bg: AppColors.greenSoft, fg: AppColors.green, label: "Connected Sources", value: googleCalSync || iCloudCalSync ? "Connected" : nil) { showImport = true }
|
||
SttNavRow(icon: "cloud", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Library Sync", value: iCloudSync ? "● Synced" : "Off", valueColor: iCloudSync ? AppColors.green : nil, isLast: true) { showBackup = true }
|
||
}
|
||
|
||
// ── FITNESS ──
|
||
SttSection(label: "Fitness") {
|
||
SttNavRow(icon: "dumbbell", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Workout Settings", value: weightLabel) { showWorkoutSettings = true }
|
||
SttNavRow(icon: "figure.run", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Rest Timer", value: restLabel) { showRestTimer = true }
|
||
HealthToggleRow(isLast: true)
|
||
}
|
||
|
||
// ── HABITS ──
|
||
SttSection(label: "Habits") {
|
||
SttNavRow(icon: "hands.sparkles", bg: AppColors.yellowSoft, fg: AppColors.yellow,
|
||
label: "Habit Reminders", value: habitsLabel,
|
||
valueColor: habitsLabel != nil ? AppColors.green : nil, isLast: true) { showHabitReminders = true }
|
||
}
|
||
|
||
// ── ACCOUNT ──
|
||
SttSection(label: "Account", secondary: true) {
|
||
if let user = AuthManager.shared.currentUser {
|
||
SttNavRow(icon: "person.circle", bg: AppColors.surface3(cs),
|
||
label: user.displayName ?? "Apple Account",
|
||
value: user.email, isLast: false) { }
|
||
}
|
||
Button {
|
||
AuthManager.shared.signOut()
|
||
} label: {
|
||
HStack(spacing: 14) {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 8).fill(AppColors.redSoft).frame(width: 32, height: 32)
|
||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||
.font(.system(size: 14, weight: .medium))
|
||
.foregroundColor(AppColors.red)
|
||
}
|
||
Text("Sign Out")
|
||
.font(AppFonts.sans(15))
|
||
.foregroundColor(AppColors.red)
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 16).frame(height: 52)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
// ── SUPPORT ──
|
||
SttSection(label: "Support", secondary: true) {
|
||
SttNavRow(icon: "star", bg: AppColors.yellowSoft, fg: AppColors.yellow, label: "Help & Feedback") { showHelp = true }
|
||
SttNavRow(icon: "message", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Follow Us") { showFollow = true }
|
||
SttNavRow(icon: "info.circle", bg: AppColors.surface3(cs), label: "About", value: "v\(Bundle.main.marketingVersion)", isLast: true) { showAbout = true }
|
||
}
|
||
|
||
Spacer().frame(height: 40)
|
||
}
|
||
}
|
||
}
|
||
.sheet(isPresented: $showProfile) { ProfileStatsSheet().environmentObject(taskVM).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
|
||
.sheet(isPresented: $showTabBar) { TabBarSheet(showMatrix: $showMatrixTab, showWorkout: $showWorkoutTab).presentationDetents([.height(320)]).presentationDragIndicator(.visible) }
|
||
.sheet(isPresented: $showAppearance) { AppearanceSheet(mode: $appearanceMode).presentationDetents([.height(290)]).presentationDragIndicator(.visible) }
|
||
.sheet(isPresented: $showDateTime) { DateTimeSheet(format: $dateFormat, mondayFirst: $firstDayMonday).presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
|
||
.sheet(isPresented: $showWidgets) { WidgetsSheet().presentationDetents([.height(320)]).presentationDragIndicator(.visible) }
|
||
.sheet(isPresented: $showGeneral) { GeneralSheet(showCompleted: $showCompleted, mondayFirst: $firstDayMonday).presentationDetents([.height(260)]).presentationDragIndicator(.visible) }
|
||
.sheet(isPresented: $showImport) { ImportSheet(googleCal: $googleCalSync, iCloudCal: $iCloudCalSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
||
.sheet(isPresented: $showCalendar) { CalendarConnectSheet(calStore: calStore).presentationDetents([.large]).presentationDragIndicator(.visible) }
|
||
.sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
||
.sheet(isPresented: $showWorkoutSettings) { WorkoutSettingsSheet(unit: $weightUnit, sets: $defaultSets, reps: $defaultReps).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
|
||
.sheet(isPresented: $showHabitReminders) { HabitRemindersSheet().environmentObject(workoutVM).environmentObject(taskVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
|
||
.sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds, enabled: $workoutVM.restTimerEnabled).presentationDetents([.height(360)]).presentationDragIndicator(.visible) }
|
||
.sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
|
||
.sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
||
.sheet(isPresented: $showAbout) { AboutSheet().presentationDetents([.height(340)]).presentationDragIndicator(.visible) }
|
||
.onAppear { calStore.refreshStatus() }
|
||
}
|
||
}
|
||
|
||
// MARK: - Health Toggle Row (requests auth when turned on)
|
||
private struct HealthToggleRow: View {
|
||
@Environment(\.colorScheme) private var cs
|
||
var isLast: Bool = false
|
||
@ObservedObject private var hk = HealthKitManager.shared
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 11) {
|
||
Image(systemName: "heart.fill")
|
||
.font(.system(size: 14))
|
||
.foregroundColor(AppColors.green)
|
||
.frame(width: 28, height: 28)
|
||
.background(AppColors.greenSoft)
|
||
.cornerRadius(8)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text("Health Integration")
|
||
.font(AppFonts.sans(13))
|
||
.foregroundColor(AppColors.text(cs))
|
||
if hk.authorized {
|
||
Text("Connected")
|
||
.font(AppFonts.mono(9))
|
||
.foregroundColor(AppColors.green)
|
||
} else if !hk.isAvailable {
|
||
Text("Not available on this device")
|
||
.font(AppFonts.mono(9))
|
||
.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.green)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 11)
|
||
if !isLast { Divider().background(AppColors.border(cs)).padding(.leading, 53) }
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Section
|
||
private struct SttSection<Content: View>: View {
|
||
@Environment(\.colorScheme) private var cs
|
||
let label: String
|
||
var secondary: Bool = false
|
||
@ViewBuilder let content: Content
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text(label)
|
||
.font(AppFonts.sans(secondary ? 9.5 : 10, weight: .semibold))
|
||
.foregroundColor(secondary ? AppColors.text3(cs) : AppColors.text2(cs))
|
||
.padding(.horizontal, 18).padding(.bottom, 5)
|
||
VStack(spacing: 0) { content }
|
||
.cardStyle().padding(.horizontal, 14).padding(.bottom, secondary ? 6 : 8)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Nav Row
|
||
private struct SttNavRow: View {
|
||
@Environment(\.colorScheme) private var cs
|
||
let icon: String; let bg: Color
|
||
var fg: Color? = nil
|
||
let label: String
|
||
var value: String? = nil; var valueColor: Color? = nil; var isLast: Bool = false
|
||
let action: () -> Void
|
||
var body: some View {
|
||
Button(action: action) {
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 11) {
|
||
Image(systemName: icon).font(.system(size: 14))
|
||
.foregroundColor(fg ?? AppColors.text2(cs))
|
||
.frame(width: 28, height: 28).background(bg).cornerRadius(8)
|
||
Text(label).font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
if let v = value {
|
||
Text(v).font(AppFonts.mono(11))
|
||
.foregroundColor(valueColor ?? AppColors.text2(cs)).lineLimit(1)
|
||
}
|
||
Image(systemName: "chevron.right").font(.system(size: 10))
|
||
.foregroundColor(AppColors.text3(cs))
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 11)
|
||
if !isLast { Divider().background(AppColors.border(cs)).padding(.leading, 53) }
|
||
}.contentShape(Rectangle())
|
||
}.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
// MARK: - Toggle Row
|
||
private struct SttToggleRow: View {
|
||
@Environment(\.colorScheme) private var cs
|
||
let icon: String; let bg: Color
|
||
var fg: Color? = nil
|
||
let label: String
|
||
@Binding var isOn: Bool; var isLast: Bool = false
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 11) {
|
||
Image(systemName: icon).font(.system(size: 14))
|
||
.foregroundColor(fg ?? AppColors.text2(cs))
|
||
.frame(width: 28, height: 28).background(bg).cornerRadius(8)
|
||
Text(label).font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
Toggle("", isOn: $isOn).labelsHidden().tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 11)
|
||
if !isLast { Divider().background(AppColors.border(cs)).padding(.leading, 53) }
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Shared Header
|
||
private struct SheetHeader: View {
|
||
@Environment(\.colorScheme) private var cs
|
||
let title: String; let onDone: () -> Void
|
||
var body: some View {
|
||
HStack {
|
||
Text(title).font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
Button("Done", action: onDone).font(AppFonts.sans(13, weight: .semibold))
|
||
.foregroundColor(AppColors.accent).buttonStyle(.plain)
|
||
}
|
||
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 16)
|
||
}
|
||
}
|
||
|
||
// MARK: - Tab Bar Sheet
|
||
private struct TabBarSheet: View {
|
||
@Binding var showMatrix: Bool
|
||
@Binding var showWorkout: Bool
|
||
@Environment(\.dismiss) var dismiss
|
||
@Environment(\.colorScheme) var cs
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
SheetHeader(title: "Tab Bar") { dismiss() }
|
||
VStack(spacing: 0) {
|
||
TabToggleRow(icon: "checkmark", label: "Tasks", isOn: .constant(true), locked: true)
|
||
TabToggleRow(icon: "calendar", label: "Calendar", isOn: .constant(true), locked: true)
|
||
TabToggleRow(icon: "square.grid.2x2", label: "Matrix", isOn: $showMatrix, locked: false)
|
||
TabToggleRow(icon: "dumbbell", label: "Workout", isOn: $showWorkout, locked: false)
|
||
TabToggleRow(icon: "gearshape", label: "Settings", isOn: .constant(true), locked: true, isLast: true)
|
||
}
|
||
.cardStyle().padding(.horizontal, 20)
|
||
Text("Tasks, Calendar, and Settings are always visible.")
|
||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||
.multilineTextAlignment(.center).padding(.horizontal, 20).padding(.top, 12)
|
||
Spacer()
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
}
|
||
}
|
||
|
||
private struct TabToggleRow: View {
|
||
@Environment(\.colorScheme) private var cs
|
||
let icon: String; let label: String
|
||
@Binding var isOn: Bool; let locked: Bool; var isLast: Bool = false
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: icon).font(.system(size: 13)).foregroundColor(locked ? AppColors.text3(cs) : AppColors.text(cs)).frame(width: 22)
|
||
Text(label).font(AppFonts.sans(13)).foregroundColor(locked ? AppColors.text3(cs) : AppColors.text(cs))
|
||
Spacer()
|
||
if locked {
|
||
Text("Always on").font(AppFonts.mono(9.5)).foregroundColor(AppColors.text3(cs))
|
||
} else {
|
||
Toggle("", isOn: $isOn).labelsHidden().tint(AppColors.accent)
|
||
}
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||
if !isLast { Divider().background(AppColors.border(cs)).padding(.leading, 48) }
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Appearance Sheet
|
||
private struct AppearanceSheet: View {
|
||
@Binding var mode: Int
|
||
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
|
||
private let options = [("System", "circle.lefthalf.filled"), ("Light", "sun.max"), ("Dark", "moon.stars")]
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
SheetHeader(title: "Appearance") { dismiss() }
|
||
VStack(spacing: 8) {
|
||
ForEach(options.indices, id: \.self) { i in
|
||
Button { withAnimation(KisaniSpring.micro) { mode = i } } label: {
|
||
HStack(spacing: 14) {
|
||
Image(systemName: options[i].1).font(.system(size: 15, weight: .medium))
|
||
.foregroundColor(mode == i ? AppColors.accent : AppColors.text2(cs)).frame(width: 24)
|
||
Text(options[i].0).font(AppFonts.sans(14, weight: mode == i ? .semibold : .regular))
|
||
.foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
if mode == i { Image(systemName: "checkmark").font(.system(size: 12, weight: .bold)).foregroundColor(AppColors.accent) }
|
||
}
|
||
.padding(.horizontal, 16).padding(.vertical, 14)
|
||
.background(mode == i ? AppColors.accentSoft : AppColors.surface2(cs))
|
||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.medium))
|
||
.overlay(RoundedRectangle(cornerRadius: AppRadius.medium).stroke(mode == i ? AppColors.accent.opacity(0.35) : AppColors.border(cs), lineWidth: 1))
|
||
}.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
Spacer()
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
}
|
||
}
|
||
|
||
// MARK: - Date & Time Sheet
|
||
private struct DateTimeSheet: View {
|
||
@Binding var format: Int
|
||
@Binding var mondayFirst: Bool
|
||
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
|
||
private let formats = ["MMM d", "MM/DD/YYYY", "DD/MM/YYYY"]
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
SheetHeader(title: "Date & Time") { dismiss() }
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("DATE FORMAT").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||
HStack(spacing: 8) {
|
||
ForEach(formats.indices, id: \.self) { i in
|
||
Button { withAnimation { format = i } } label: {
|
||
Text(formats[i]).font(AppFonts.mono(11, weight: .bold))
|
||
.foregroundColor(format == i ? AppColors.accent : AppColors.text2(cs))
|
||
.frame(maxWidth: .infinity).padding(.vertical, 10)
|
||
.background(format == i ? AppColors.accentSoft : AppColors.surface2(cs))
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(format == i ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
|
||
}.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
VStack(spacing: 0) {
|
||
HStack {
|
||
Text("First day of week").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
Text(mondayFirst ? "Monday" : "Sunday").font(AppFonts.mono(11)).foregroundColor(AppColors.text3(cs))
|
||
Toggle("", isOn: $mondayFirst).labelsHidden().tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||
}
|
||
.cardStyle()
|
||
}
|
||
.padding(.horizontal, 20)
|
||
Spacer()
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
}
|
||
}
|
||
|
||
// MARK: - Widgets Sheet
|
||
private struct WidgetsSheet: View {
|
||
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
|
||
private let widgets = [("Small", "1×1 — Today's task count", "square.grid.2x2"), ("Medium", "2×1 — Upcoming tasks list", "rectangle.grid.1x2"), ("Large", "2×2 — Full task view", "square.split.2x2")]
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
SheetHeader(title: "Widgets") { dismiss() }
|
||
VStack(spacing: 10) {
|
||
ForEach(widgets, id: \.0) { w in
|
||
HStack(spacing: 14) {
|
||
Image(systemName: w.2).font(.system(size: 20)).foregroundColor(AppColors.accent)
|
||
.frame(width: 44, height: 44).background(AppColors.accentSoft)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(w.0).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
||
Text(w.1).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(12).cardStyle()
|
||
}
|
||
Text("Long-press your home screen → tap + → search Wenza to add widgets.")
|
||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||
.multilineTextAlignment(.center).padding(.top, 4)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
Spacer()
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
}
|
||
}
|
||
|
||
// MARK: - General Sheet
|
||
private struct GeneralSheet: View {
|
||
@Binding var showCompleted: Bool
|
||
@Binding var mondayFirst: Bool
|
||
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
SheetHeader(title: "General") { dismiss() }
|
||
VStack(spacing: 0) {
|
||
HStack {
|
||
Text("Show completed tasks").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
Toggle("", isOn: $showCompleted).labelsHidden().tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||
Divider().background(AppColors.border(cs)).padding(.leading, 14)
|
||
HStack {
|
||
Text("Week starts on Monday").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
Toggle("", isOn: $mondayFirst).labelsHidden().tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||
}
|
||
.cardStyle().padding(.horizontal, 20)
|
||
Spacer()
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
}
|
||
}
|
||
|
||
// MARK: - Import Sheet
|
||
private struct ImportSheet: View {
|
||
@Binding var googleCal: Bool
|
||
@Binding var iCloudCal: Bool
|
||
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
SheetHeader(title: "Import & Integration") { dismiss() }
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: "calendar.badge.plus").font(.system(size: 16))
|
||
.foregroundColor(AppColors.blue).frame(width: 28, height: 28)
|
||
.background(AppColors.blueSoft).cornerRadius(8)
|
||
Text("Google Calendar").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
Toggle("", isOn: $googleCal).labelsHidden().tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||
Divider().background(AppColors.border(cs)).padding(.leading, 53)
|
||
HStack(spacing: 12) {
|
||
Image(systemName: "cloud").font(.system(size: 16))
|
||
.foregroundColor(AppColors.blue).frame(width: 28, height: 28)
|
||
.background(AppColors.blueSoft).cornerRadius(8)
|
||
Text("iCloud Calendar").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
Toggle("", isOn: $iCloudCal).labelsHidden().tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||
}
|
||
.cardStyle().padding(.horizontal, 20)
|
||
Text("Enabling integrations will sync events to your Wenza calendar.")
|
||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||
.multilineTextAlignment(.center).padding(.horizontal, 20).padding(.top, 12)
|
||
Spacer()
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
}
|
||
}
|
||
|
||
// MARK: - Backup Sheet
|
||
private struct BackupSheet: View {
|
||
@Binding var iCloudSync: Bool
|
||
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
SheetHeader(title: "Backup & Sync") { dismiss() }
|
||
VStack(spacing: 14) {
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: "icloud").font(.system(size: 16))
|
||
.foregroundColor(AppColors.blue).frame(width: 28, height: 28)
|
||
.background(AppColors.blueSoft).cornerRadius(8)
|
||
Text("iCloud Sync").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
Toggle("", isOn: $iCloudSync).labelsHidden().tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||
if iCloudSync {
|
||
Divider().background(AppColors.border(cs)).padding(.leading, 53)
|
||
HStack {
|
||
Text("Last synced").font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
|
||
Spacer()
|
||
Text("Today · just now").font(AppFonts.mono(11)).foregroundColor(AppColors.green)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 10)
|
||
}
|
||
}
|
||
.cardStyle().padding(.horizontal, 20)
|
||
.animation(KisaniSpring.snappy, value: iCloudSync)
|
||
|
||
if iCloudSync {
|
||
Button {
|
||
// trigger sync
|
||
} label: {
|
||
Text("Sync Now")
|
||
.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent)
|
||
.frame(maxWidth: .infinity).padding(13)
|
||
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.accent, lineWidth: 1.5))
|
||
}
|
||
.buttonStyle(.plain).padding(.horizontal, 20)
|
||
}
|
||
}
|
||
Spacer()
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
}
|
||
}
|
||
|
||
// MARK: - Workout Settings Sheet
|
||
private struct WorkoutSettingsSheet: View {
|
||
@EnvironmentObject var workoutVM: WorkoutViewModel
|
||
@Binding var unit: Int
|
||
@Binding var sets: Int
|
||
@Binding var reps: Int
|
||
@AppStorage("bodyWeightKg") private var bodyWeightKg: Double = 80
|
||
@AppStorage("heightCm") private var heightCm: Double = 175
|
||
@AppStorage("workoutReminderEnabled") private var workoutReminderEnabled: Bool = true
|
||
@AppStorage("workoutHour") private var workoutHour: Int = 18
|
||
@AppStorage("workoutMinute") private var workoutMinute: Int = 0
|
||
@AppStorage("workoutCheckInEnabled") private var workoutCheckInEnabled: Bool = false
|
||
@AppStorage("workoutConfirmEnabled") private var workoutConfirmEnabled: Bool = false
|
||
@AppStorage("workoutConfirmHour") private var workoutConfirmHour: Int = 20
|
||
@AppStorage("workoutConfirmMinute") private var workoutConfirmMinute: Int = 0
|
||
@AppStorage("checkInHour") private var checkInHour: Int = 19
|
||
@AppStorage("checkInMinute") private var checkInMinute: Int = 30
|
||
@AppStorage("streakGoal") private var streakGoal: Int = 5
|
||
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
|
||
|
||
@State private var bwStr = ""
|
||
@State private var htStr = ""
|
||
@State private var workoutTime: Date = Date()
|
||
@State private var checkInTime: Date = Date()
|
||
@State private var confirmTime: Date = Date()
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
SheetHeader(title: "Workout Settings") { dismiss() }
|
||
ScrollView(showsIndicators: false) {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
|
||
// ── Reminders ──
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("REMINDERS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||
VStack(spacing: 0) {
|
||
// Pre-workout
|
||
HStack(spacing: 11) {
|
||
Image(systemName: "bell.fill")
|
||
.font(.system(size: 13)).foregroundColor(AppColors.accent)
|
||
.frame(width: 28, height: 28).background(AppColors.accentSoft).cornerRadius(8)
|
||
Text("Pre-workout reminder").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
Toggle("", isOn: $workoutReminderEnabled).labelsHidden().tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 11)
|
||
if workoutReminderEnabled {
|
||
Divider().background(AppColors.border(cs)).padding(.leading, 53)
|
||
HStack {
|
||
Text("Remind me at")
|
||
.font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
|
||
Spacer()
|
||
DatePicker("", selection: $workoutTime, displayedComponents: .hourAndMinute)
|
||
.labelsHidden().tint(AppColors.accent)
|
||
.onChange(of: workoutTime) { v in
|
||
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
|
||
workoutHour = c.hour ?? 18
|
||
workoutMinute = c.minute ?? 0
|
||
}
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 8)
|
||
}
|
||
Divider().background(AppColors.border(cs)).padding(.leading, 53)
|
||
// Post-workout check-in
|
||
HStack(spacing: 11) {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.font(.system(size: 13)).foregroundColor(AppColors.green)
|
||
.frame(width: 28, height: 28).background(AppColors.greenSoft).cornerRadius(8)
|
||
Text("Post-workout check-in").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
Toggle("", isOn: $workoutCheckInEnabled).labelsHidden().tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 11)
|
||
if workoutCheckInEnabled {
|
||
Divider().background(AppColors.border(cs)).padding(.leading, 53)
|
||
HStack {
|
||
Text("Check in at")
|
||
.font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
|
||
Spacer()
|
||
DatePicker("", selection: $checkInTime, displayedComponents: .hourAndMinute)
|
||
.labelsHidden().tint(AppColors.accent)
|
||
.onChange(of: checkInTime) { v in
|
||
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
|
||
checkInHour = c.hour ?? 19
|
||
checkInMinute = c.minute ?? 30
|
||
}
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 8)
|
||
}
|
||
}
|
||
.cardStyle()
|
||
.animation(KisaniSpring.snappy, value: workoutReminderEnabled)
|
||
.animation(KisaniSpring.snappy, value: workoutCheckInEnabled)
|
||
}
|
||
|
||
// ── Workout Confirmation ──
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("HEALTH SYNC").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 11) {
|
||
Image(systemName: "figure.strengthtraining.traditional")
|
||
.font(.system(size: 13)).foregroundColor(.white)
|
||
.frame(width: 28, height: 28).background(AppColors.blue).cornerRadius(8)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text("Sync streak from Apple Health").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Text("Import past workouts into your streak")
|
||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||
}
|
||
Spacer()
|
||
Button {
|
||
Task { await workoutVM.syncFromHealthKit() }
|
||
} label: {
|
||
Text("Sync now")
|
||
.font(AppFonts.mono(10, weight: .bold))
|
||
.foregroundColor(AppColors.blue)
|
||
.padding(.horizontal, 10).padding(.vertical, 5)
|
||
.background(AppColors.blueSoft)
|
||
.clipShape(Capsule())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||
|
||
Divider().background(AppColors.border(cs)).padding(.leading, 53)
|
||
|
||
HStack(spacing: 11) {
|
||
Image(systemName: "bell.badge.fill")
|
||
.font(.system(size: 13)).foregroundColor(.white)
|
||
.frame(width: 28, height: 28).background(AppColors.accent).cornerRadius(8)
|
||
Text("Workout confirmation reminder").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
Toggle("", isOn: $workoutConfirmEnabled).labelsHidden().tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 11)
|
||
|
||
if workoutConfirmEnabled {
|
||
Divider().background(AppColors.border(cs)).padding(.leading, 53)
|
||
HStack {
|
||
Text("Ask me at")
|
||
.font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
|
||
Spacer()
|
||
DatePicker("", selection: $confirmTime, displayedComponents: .hourAndMinute)
|
||
.labelsHidden().tint(AppColors.accent)
|
||
.onChange(of: confirmTime) { v in
|
||
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
|
||
workoutConfirmHour = c.hour ?? 20
|
||
workoutConfirmMinute = c.minute ?? 0
|
||
}
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 8)
|
||
}
|
||
}
|
||
.cardStyle()
|
||
.animation(KisaniSpring.snappy, value: workoutConfirmEnabled)
|
||
}
|
||
|
||
// ── Streak Goal ──
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("STREAK GOAL").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||
HStack(spacing: 7) {
|
||
ForEach([3, 4, 5, 6, 7], id: \.self) { days in
|
||
Button { withAnimation(KisaniSpring.micro) { streakGoal = days } } label: {
|
||
VStack(spacing: 4) {
|
||
Text("\(days)")
|
||
.font(AppFonts.mono(15, weight: .bold))
|
||
.foregroundColor(streakGoal == days ? AppColors.accent : AppColors.text2(cs))
|
||
Text("days")
|
||
.font(AppFonts.mono(8))
|
||
.foregroundColor(streakGoal == days ? AppColors.accent : AppColors.text3(cs))
|
||
}
|
||
.frame(maxWidth: .infinity).padding(.vertical, 10)
|
||
.background(streakGoal == days ? AppColors.accentSoft : AppColors.surface2(cs))
|
||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||
.overlay(RoundedRectangle(cornerRadius: 9).stroke(
|
||
streakGoal == days ? AppColors.accent : AppColors.border(cs),
|
||
lineWidth: streakGoal == days ? 1.5 : 1
|
||
))
|
||
}.buttonStyle(.plain)
|
||
}
|
||
}
|
||
Text("Your weekly workout target. Every workout you log adds to your total — a missed day never takes it away.")
|
||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||
}
|
||
|
||
// ── Body Profile ──
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("BODY PROFILE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||
VStack(spacing: 0) {
|
||
HStack {
|
||
Text("Body Weight").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
HStack(spacing: 4) {
|
||
TextField("80", text: $bwStr)
|
||
.font(AppFonts.mono(13, weight: .semibold))
|
||
.foregroundColor(AppColors.text(cs))
|
||
.multilineTextAlignment(.trailing)
|
||
.keyboardType(.decimalPad)
|
||
.frame(width: 56)
|
||
.onChange(of: bwStr) { val in
|
||
let clean = String(val.filter { $0.isNumber || $0 == "." }.prefix(5))
|
||
if clean != val { bwStr = clean }
|
||
if let d = Double(clean) { bodyWeightKg = d }
|
||
}
|
||
Text("kg")
|
||
.font(AppFonts.mono(10, weight: .bold))
|
||
.foregroundColor(AppColors.text3(cs))
|
||
}
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 11)
|
||
Divider().background(AppColors.border(cs)).padding(.leading, 14)
|
||
HStack {
|
||
Text("Height").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
HStack(spacing: 4) {
|
||
TextField("175", text: $htStr)
|
||
.font(AppFonts.mono(13, weight: .semibold))
|
||
.foregroundColor(AppColors.text(cs))
|
||
.multilineTextAlignment(.trailing)
|
||
.keyboardType(.numberPad)
|
||
.frame(width: 56)
|
||
.onChange(of: htStr) { val in
|
||
let clean = String(val.filter { $0.isNumber }.prefix(3))
|
||
if clean != val { htStr = clean }
|
||
if let d = Double(clean) { heightCm = d }
|
||
}
|
||
Text("cm")
|
||
.font(AppFonts.mono(10, weight: .bold))
|
||
.foregroundColor(AppColors.text3(cs))
|
||
}
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 11)
|
||
}
|
||
.cardStyle()
|
||
}
|
||
|
||
// ── Weight Unit ──
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("WEIGHT UNIT").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||
HStack(spacing: 10) {
|
||
ForEach(["kg", "lbs"].indices, id: \.self) { i in
|
||
Button { withAnimation(KisaniSpring.micro) { unit = i } } label: {
|
||
Text(["kg", "lbs"][i]).font(AppFonts.mono(13, weight: .bold))
|
||
.foregroundColor(unit == i ? AppColors.accent : AppColors.text2(cs))
|
||
.frame(maxWidth: .infinity).padding(.vertical, 12)
|
||
.background(unit == i ? AppColors.accentSoft : AppColors.surface2(cs))
|
||
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||
.overlay(RoundedRectangle(cornerRadius: 9).stroke(unit == i ? AppColors.accent : AppColors.border(cs), lineWidth: unit == i ? 1.5 : 1))
|
||
}.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Defaults ──
|
||
VStack(spacing: 0) {
|
||
StepperRow(label: "Default Sets", value: $sets, range: 1...10)
|
||
Divider().background(AppColors.border(cs)).padding(.leading, 14)
|
||
StepperRow(label: "Default Reps", value: $reps, range: 1...30, isLast: true)
|
||
}
|
||
.cardStyle()
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 20)
|
||
}
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
.onAppear {
|
||
bwStr = bodyWeightKg > 0 ? (bodyWeightKg.truncatingRemainder(dividingBy: 1) == 0 ? "\(Int(bodyWeightKg))" : String(format: "%.1f", bodyWeightKg)) : ""
|
||
htStr = heightCm > 0 ? "\(Int(heightCm))" : ""
|
||
let today = Calendar.current.dateComponents([.year, .month, .day], from: Date())
|
||
var wc = today; wc.hour = workoutHour; wc.minute = workoutMinute
|
||
workoutTime = Calendar.current.date(from: wc) ?? Date()
|
||
var cc = today; cc.hour = checkInHour; cc.minute = checkInMinute
|
||
checkInTime = Calendar.current.date(from: cc) ?? Date()
|
||
var cf = today; cf.hour = workoutConfirmHour; cf.minute = workoutConfirmMinute
|
||
confirmTime = Calendar.current.date(from: cf) ?? Date()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Habit Reminders Sheet
|
||
//
|
||
// Three independent daily reminders: prayer (up to 4 named times a day),
|
||
// bed-making (one nudge — research says it's the easiest first win of the
|
||
// day), and coffee/caffeine (up to 4 named times plus one custom reminder).
|
||
// All keys use the App Group store (`UserDefaults.kisani`) — the same store
|
||
// NotificationManager reads from — so toggling here actually schedules.
|
||
private struct HabitRemindersSheet: View {
|
||
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
|
||
@EnvironmentObject var workoutVM: WorkoutViewModel
|
||
@EnvironmentObject var taskVM: TaskViewModel
|
||
|
||
@AppStorage("bedReminderEnabled", store: UserDefaults.kisani) private var bedReminderEnabled = false
|
||
@AppStorage("bedReminderHour", store: UserDefaults.kisani) private var bedReminderHour = 8
|
||
@AppStorage("bedReminderMinute", store: UserDefaults.kisani) private var bedReminderMinute = 0
|
||
|
||
@AppStorage("prayerReminderEnabled", store: UserDefaults.kisani) private var prayerReminderEnabled = false
|
||
@AppStorage("coffeeReminderEnabled", store: UserDefaults.kisani) private var coffeeReminderEnabled = false
|
||
|
||
@AppStorage("coffeeCustomEnabled", store: UserDefaults.kisani) private var coffeeCustomEnabled = false
|
||
@AppStorage("coffeeCustomLabel", store: UserDefaults.kisani) private var coffeeCustomLabel = "Coffee"
|
||
@AppStorage("coffeeCustomHour", store: UserDefaults.kisani) private var coffeeCustomHour = 16
|
||
@AppStorage("coffeeCustomMinute", store: UserDefaults.kisani) private var coffeeCustomMinute = 30
|
||
|
||
private let prayerSlots = ["Morning", "Afternoon", "Evening", "Night"]
|
||
private let coffeeSlots = ["Morning", "Midday", "Afternoon", "Evening"]
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
SheetHeader(title: "Habit Reminders") {
|
||
dismiss()
|
||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||
}
|
||
ScrollView(showsIndicators: false) {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
|
||
// ── Prayer ──
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("PRAYER").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 11) {
|
||
Image(systemName: "hands.sparkles.fill")
|
||
.font(.system(size: 13)).foregroundColor(AppColors.yellow)
|
||
.frame(width: 28, height: 28).background(AppColors.yellowSoft).cornerRadius(8)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
HStack(spacing: 6) {
|
||
Text("Prayer reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
habitBadge("prayer")
|
||
}
|
||
Text("\"Have you had a chance to pray and give thanks?\"")
|
||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||
}
|
||
Spacer()
|
||
Toggle("", isOn: $prayerReminderEnabled).labelsHidden().tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||
if prayerReminderEnabled {
|
||
ForEach(prayerSlots, id: \.self) { slot in
|
||
HabitSlotRow(keyPrefix: "prayer\(slot)", label: slot,
|
||
defaultHour: Self.defaultHour(for: slot, prayer: true),
|
||
defaultMinute: 0, defaultEnabled: true)
|
||
}
|
||
}
|
||
}
|
||
.cardStyle()
|
||
.animation(KisaniSpring.snappy, value: prayerReminderEnabled)
|
||
}
|
||
|
||
// ── Bed-making ──
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("MORNING").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 11) {
|
||
Image(systemName: "bed.double.fill")
|
||
.font(.system(size: 13)).foregroundColor(AppColors.blue)
|
||
.frame(width: 28, height: 28).background(AppColors.blueSoft).cornerRadius(8)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
HStack(spacing: 6) {
|
||
Text("Make your bed").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
habitBadge("bed")
|
||
}
|
||
Text("The simplest task that starts the day with a win.")
|
||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||
}
|
||
Spacer()
|
||
Toggle("", isOn: $bedReminderEnabled).labelsHidden().tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||
if bedReminderEnabled {
|
||
Divider().background(AppColors.border(cs)).padding(.leading, 53)
|
||
HabitTimeRow(label: "Remind me at", hour: $bedReminderHour, minute: $bedReminderMinute)
|
||
}
|
||
}
|
||
.cardStyle()
|
||
.animation(KisaniSpring.snappy, value: bedReminderEnabled)
|
||
}
|
||
|
||
// ── Coffee / Caffeine ──
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("CAFFEINE").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||
VStack(spacing: 0) {
|
||
HStack(spacing: 11) {
|
||
Image(systemName: "cup.and.saucer.fill")
|
||
.font(.system(size: 13)).foregroundColor(.white)
|
||
.frame(width: 28, height: 28).background(Color(r: 122, g: 84, b: 52)).cornerRadius(8)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
HStack(spacing: 6) {
|
||
Text("Coffee reminders").font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
habitBadge("coffee")
|
||
}
|
||
Text("Track when you usually reach for a cup.")
|
||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||
}
|
||
Spacer()
|
||
Toggle("", isOn: $coffeeReminderEnabled).labelsHidden().tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||
if coffeeReminderEnabled {
|
||
ForEach(coffeeSlots, id: \.self) { slot in
|
||
HabitSlotRow(keyPrefix: "coffee\(slot)", label: slot,
|
||
defaultHour: Self.defaultHour(for: slot, prayer: false),
|
||
defaultMinute: 0, defaultEnabled: slot == "Morning")
|
||
}
|
||
Divider().background(AppColors.border(cs)).padding(.leading, 14)
|
||
HStack(spacing: 11) {
|
||
TextField("Custom label", text: $coffeeCustomLabel)
|
||
.font(AppFonts.sans(12.5)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
if coffeeCustomEnabled {
|
||
DatePicker("", selection: Binding(
|
||
get: { Calendar.current.date(bySettingHour: coffeeCustomHour, minute: coffeeCustomMinute, second: 0, of: Date()) ?? Date() },
|
||
set: { v in
|
||
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
|
||
coffeeCustomHour = c.hour ?? coffeeCustomHour
|
||
coffeeCustomMinute = c.minute ?? coffeeCustomMinute
|
||
}
|
||
), displayedComponents: .hourAndMinute)
|
||
.labelsHidden().tint(AppColors.accent)
|
||
}
|
||
Toggle("", isOn: $coffeeCustomEnabled).labelsHidden().tint(AppColors.accent).scaleEffect(0.85)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 9)
|
||
}
|
||
}
|
||
.cardStyle()
|
||
.animation(KisaniSpring.snappy, value: coffeeReminderEnabled)
|
||
}
|
||
|
||
Text("All reminders are local to this device and never share your responses — they're just gentle nudges.")
|
||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 20)
|
||
}
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
}
|
||
|
||
private static func defaultHour(for slot: String, prayer: Bool) -> Int {
|
||
let table: [String: Int] = prayer
|
||
? ["Morning": 7, "Afternoon": 13, "Evening": 18, "Night": 21]
|
||
: ["Morning": 8, "Midday": 12, "Afternoon": 15, "Evening": 18]
|
||
return table[slot] ?? 9
|
||
}
|
||
|
||
/// A quiet "X days" badge from tapping "Done" on a habit's notifications —
|
||
/// a private lifetime tally, never shown in Today/Matrix/Activity History.
|
||
@ViewBuilder
|
||
private func habitBadge(_ type: String) -> some View {
|
||
let count = NotificationManager.shared.habitDoneCount(type: type)
|
||
if count > 0 {
|
||
AppBadge(text: "\(count)d", bg: AppColors.greenSoft, fg: AppColors.green)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One named, independently-toggleable daily time slot (e.g. "Morning") — used
|
||
/// for both Prayer and Coffee's dense multi-slot lists. Reads/writes
|
||
/// `UserDefaults.kisani` directly under `<keyPrefix>Enabled/Hour/Minute` so it
|
||
/// stays in lockstep with what `NotificationManager` schedules from.
|
||
private struct HabitSlotRow: View {
|
||
@Environment(\.colorScheme) private var cs
|
||
let keyPrefix: String
|
||
let label: String
|
||
let defaultHour: Int
|
||
let defaultMinute: Int
|
||
let defaultEnabled: Bool
|
||
|
||
@AppStorage private var enabled: Bool
|
||
@AppStorage private var hour: Int
|
||
@AppStorage private var minute: Int
|
||
|
||
init(keyPrefix: String, label: String, defaultHour: Int, defaultMinute: Int, defaultEnabled: Bool) {
|
||
self.keyPrefix = keyPrefix; self.label = label
|
||
self.defaultHour = defaultHour; self.defaultMinute = defaultMinute; self.defaultEnabled = defaultEnabled
|
||
_enabled = AppStorage(wrappedValue: defaultEnabled, "\(keyPrefix)Enabled", store: UserDefaults.kisani)
|
||
_hour = AppStorage(wrappedValue: defaultHour, "\(keyPrefix)Hour", store: UserDefaults.kisani)
|
||
_minute = AppStorage(wrappedValue: defaultMinute, "\(keyPrefix)Minute", store: UserDefaults.kisani)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
Divider().background(AppColors.border(cs)).padding(.leading, 14)
|
||
HStack(spacing: 11) {
|
||
Text(label).font(AppFonts.sans(12.5)).foregroundColor(AppColors.text2(cs))
|
||
Spacer()
|
||
if enabled {
|
||
DatePicker("", selection: Binding(
|
||
get: { Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Date() },
|
||
set: { v in
|
||
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
|
||
hour = c.hour ?? hour; minute = c.minute ?? minute
|
||
}
|
||
), displayedComponents: .hourAndMinute)
|
||
.labelsHidden().tint(AppColors.accent)
|
||
}
|
||
Toggle("", isOn: $enabled).labelsHidden().tint(AppColors.accent).scaleEffect(0.85)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 9)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A plain "label + time picker" row (no toggle) — used where the toggle
|
||
/// already lives on the parent row (e.g. bed-making's single time).
|
||
private struct HabitTimeRow: View {
|
||
@Environment(\.colorScheme) private var cs
|
||
let label: String
|
||
@Binding var hour: Int
|
||
@Binding var minute: Int
|
||
|
||
var body: some View {
|
||
HStack {
|
||
Text(label).font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
|
||
Spacer()
|
||
DatePicker("", selection: Binding(
|
||
get: { Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Date() },
|
||
set: { v in
|
||
let c = Calendar.current.dateComponents([.hour, .minute], from: v)
|
||
hour = c.hour ?? hour; minute = c.minute ?? minute
|
||
}
|
||
), displayedComponents: .hourAndMinute)
|
||
.labelsHidden().tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 8)
|
||
}
|
||
}
|
||
|
||
private struct StepperRow: View {
|
||
@Environment(\.colorScheme) private var cs
|
||
let label: String; @Binding var value: Int; let range: ClosedRange<Int>; var isLast: Bool = false
|
||
var body: some View {
|
||
HStack {
|
||
Text(label).font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
HStack(spacing: 0) {
|
||
Button { if value > range.lowerBound { value -= 1 } } label: {
|
||
Image(systemName: "minus").font(.system(size: 13, weight: .semibold))
|
||
.foregroundColor(value > range.lowerBound ? AppColors.accent : AppColors.text3(cs))
|
||
.frame(width: 32, height: 32)
|
||
}
|
||
.buttonStyle(.plain)
|
||
Text("\(value)").font(AppFonts.mono(13, weight: .bold))
|
||
.foregroundColor(AppColors.text(cs)).frame(width: 32).multilineTextAlignment(.center)
|
||
Button { if value < range.upperBound { value += 1 } } label: {
|
||
Image(systemName: "plus").font(.system(size: 13, weight: .semibold))
|
||
.foregroundColor(value < range.upperBound ? AppColors.accent : AppColors.text3(cs))
|
||
.frame(width: 32, height: 32)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.background(AppColors.surface2(cs)).clipShape(RoundedRectangle(cornerRadius: 8))
|
||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(AppColors.border(cs), lineWidth: 1))
|
||
}
|
||
.padding(.horizontal, 14).padding(.vertical, 10)
|
||
}
|
||
}
|
||
|
||
// MARK: - Rest Timer Sheet
|
||
private struct RestTimerSheet: View {
|
||
@Binding var seconds: Int
|
||
@Binding var enabled: Bool
|
||
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
|
||
private let options = [30, 45, 60, 90, 120, 180]
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
SheetHeader(title: "Rest Timer") { dismiss() }
|
||
|
||
// Toggle
|
||
HStack {
|
||
Text("Auto-start after each set")
|
||
.font(AppFonts.sans(14))
|
||
.foregroundColor(AppColors.text(cs))
|
||
Spacer()
|
||
Toggle("", isOn: $enabled)
|
||
.labelsHidden()
|
||
.tint(AppColors.accent)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 20)
|
||
|
||
// Duration picker (dimmed when disabled)
|
||
Text(seconds >= 60 ? String(format: "%d:%02d", seconds / 60, seconds % 60) : "\(seconds)s")
|
||
.font(AppFonts.mono(52, weight: .bold))
|
||
.foregroundColor(AppColors.accent)
|
||
.opacity(enabled ? 1 : 0.35)
|
||
.padding(.bottom, 20)
|
||
|
||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 3), spacing: 10) {
|
||
ForEach(options, id: \.self) { opt in
|
||
Button { withAnimation(KisaniSpring.micro) { seconds = opt } } label: {
|
||
Text(opt >= 60 ? String(format: "%d:%02d", opt / 60, opt % 60) : "\(opt)s")
|
||
.font(AppFonts.mono(14, weight: .bold))
|
||
.foregroundColor(seconds == opt ? AppColors.accent : AppColors.text2(cs))
|
||
.frame(maxWidth: .infinity).padding(.vertical, 13)
|
||
.background(seconds == opt ? AppColors.accentSoft : AppColors.surface2(cs))
|
||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
||
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(seconds == opt ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.opacity(enabled ? 1 : 0.35)
|
||
.disabled(!enabled)
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
Spacer()
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
}
|
||
}
|
||
|
||
// MARK: - Help & Feedback Sheet
|
||
private struct HelpSheet: View {
|
||
@Environment(\.dismiss) var dismiss
|
||
@Environment(\.colorScheme) var cs
|
||
@Environment(\.openURL) var openURL
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
SheetHeader(title: "Help & Feedback") { dismiss() }
|
||
VStack(spacing: 10) {
|
||
HelpRow(icon: "envelope", label: "Send Feedback", sub: "Report a bug or suggest a feature") {
|
||
if let url = URL(string: "mailto:feedback@kisanicaI.app?subject=Wenza%20Feedback") { openURL(url) }
|
||
}
|
||
HelpRow(icon: "questionmark.circle", label: "FAQ", sub: "Frequently asked questions") {}
|
||
HelpRow(icon: "book", label: "User Guide", sub: "Learn how to use Wenza") {}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
Spacer()
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
}
|
||
}
|
||
|
||
private struct HelpRow: View {
|
||
@Environment(\.colorScheme) private var cs
|
||
let icon: String; let label: String; let sub: String; let action: () -> Void
|
||
var body: some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 14) {
|
||
Image(systemName: icon).font(.system(size: 16)).foregroundColor(AppColors.accent)
|
||
.frame(width: 40, height: 40).background(AppColors.accentSoft)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(label).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
||
Text(sub).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||
}
|
||
Spacer()
|
||
Image(systemName: "chevron.right").font(.system(size: 10)).foregroundColor(AppColors.text3(cs))
|
||
}
|
||
.padding(12).cardStyle()
|
||
}.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
// MARK: - Follow Us Sheet
|
||
private struct FollowSheet: View {
|
||
@Environment(\.dismiss) var dismiss
|
||
@Environment(\.colorScheme) var cs
|
||
@Environment(\.openURL) var openURL
|
||
private let socials: [(String, String, String, String)] = [
|
||
("𝕏 Twitter / X", "Share your streaks", "at.circle", "https://x.com"),
|
||
("Discord", "Join the community", "bubble.left.and.bubble.right", "https://discord.com"),
|
||
("Instagram", "Behind the scenes", "camera", "https://instagram.com"),
|
||
]
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
SheetHeader(title: "Follow Us") { dismiss() }
|
||
VStack(spacing: 8) {
|
||
ForEach(socials, id: \.0) { s in
|
||
Button { if let url = URL(string: s.3) { openURL(url) } } label: {
|
||
HStack(spacing: 14) {
|
||
Image(systemName: s.2).font(.system(size: 16)).foregroundColor(AppColors.accent)
|
||
.frame(width: 40, height: 40).background(AppColors.accentSoft)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(s.0).font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
||
Text(s.1).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||
}
|
||
Spacer()
|
||
Image(systemName: "arrow.up.right").font(.system(size: 10)).foregroundColor(AppColors.text3(cs))
|
||
}
|
||
.padding(12).cardStyle()
|
||
}.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
Spacer()
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
}
|
||
}
|
||
|
||
// MARK: - Profile Stats Sheet
|
||
private struct ProfileStatsSheet: View {
|
||
@Environment(\.dismiss) var dismiss
|
||
@Environment(\.colorScheme) var cs
|
||
@EnvironmentObject var taskVM: TaskViewModel
|
||
@EnvironmentObject var workoutVM: WorkoutViewModel
|
||
@AppStorage("streakGoal") private var streakGoal = 5
|
||
@ObservedObject private var hk = HealthKitManager.shared
|
||
@ObservedObject private var auth = AuthManager.shared
|
||
@State private var showActivityHistory = false
|
||
|
||
// ── Task metrics ──
|
||
private var totalTasks: Int { taskVM.tasks.count }
|
||
private var doneTasks: Int { taskVM.tasks.filter { $0.isComplete }.count }
|
||
private var overdueCount: Int { taskVM.overdueTasks.count }
|
||
private var completionRate: Int {
|
||
guard totalTasks > 0 else { return 0 }
|
||
return Int(Double(doneTasks) / Double(totalTasks) * 100)
|
||
}
|
||
private var completedThisWeek: Int {
|
||
// Sum of per-day counts — includes recurring occurrences.
|
||
last7.reduce(0) { $0 + $1.1 }
|
||
}
|
||
private var urgentOpen: Int {
|
||
taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .urgent }.count
|
||
}
|
||
|
||
// ── 7-day task activity (recurring occurrences included) ──
|
||
private var last7: [(Date, Int)] {
|
||
let cal = Calendar.current
|
||
return (0..<7).reversed().map { ago -> (Date, Int) in
|
||
let day = cal.date(byAdding: .day, value: -ago, to: cal.startOfDay(for: Date()))!
|
||
return (day, taskVM.completionCount(on: day))
|
||
}
|
||
}
|
||
private var maxDay: Int { max(1, last7.map { $0.1 }.max() ?? 1) }
|
||
|
||
// ── Workout metrics ──
|
||
private var scheduledDays: Int { workoutVM.schedule.count }
|
||
private let weekRow: [(Int, String)] = [(2,"M"),(3,"T"),(4,"W"),(5,"T"),(6,"F"),(7,"S"),(1,"S")]
|
||
private var todayWD: Int { Calendar.current.component(.weekday, from: Date()) }
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
HStack {
|
||
Spacer()
|
||
Button("Done") { dismiss() }
|
||
.font(AppFonts.sans(13, weight: .semibold))
|
||
.foregroundColor(AppColors.accent).buttonStyle(.plain)
|
||
}
|
||
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 4)
|
||
|
||
ScrollView(showsIndicators: false) {
|
||
VStack(spacing: 14) {
|
||
// ── Avatar ──
|
||
VStack(spacing: 8) {
|
||
ZStack {
|
||
Circle().fill(AppColors.accentSoft)
|
||
Text(initials(for: auth.currentUser?.displayName))
|
||
.font(AppFonts.sans(24, weight: .bold)).foregroundColor(AppColors.accent)
|
||
}
|
||
.frame(width: 68, height: 68)
|
||
.overlay(Circle().stroke(AppColors.accent.opacity(0.25), lineWidth: 2))
|
||
|
||
Text(auth.currentUser?.displayName ?? "My Account")
|
||
.font(AppFonts.sans(18, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||
Text(auth.currentUser?.email ?? "kisaniCAL.")
|
||
.font(AppFonts.mono(11)).foregroundColor(AppColors.text3(cs))
|
||
}
|
||
.frame(maxWidth: .infinity).padding(.vertical, 14)
|
||
|
||
// ── Tasks card ──
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Text("TASKS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||
Spacer()
|
||
Button { showActivityHistory = true } label: {
|
||
Text("History")
|
||
.font(AppFonts.mono(9, weight: .bold))
|
||
.foregroundColor(AppColors.text2(cs))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
HStack(spacing: 8) {
|
||
PStatCell(value: "\(taskVM.taskStreakDays)", label: "day streak", color: taskVM.taskStreakDays > 0 ? AppColors.green : AppColors.text3(cs))
|
||
PStatCell(value: "\(completedThisWeek)", label: "this week", color: AppColors.green)
|
||
PStatCell(value: "\(overdueCount)", label: "overdue", color: overdueCount > 0 ? AppColors.accent : AppColors.text3(cs))
|
||
PStatCell(value: "\(completionRate)%", label: "done rate", color: AppColors.blue)
|
||
}
|
||
|
||
// 7-day bar chart
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("COMPLETED LAST 7 DAYS")
|
||
.font(AppFonts.mono(7.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||
HStack(alignment: .bottom, spacing: 5) {
|
||
ForEach(last7, id: \.0) { day, count in
|
||
VStack(spacing: 3) {
|
||
if count > 0 {
|
||
Text("\(count)")
|
||
.font(AppFonts.mono(7.5, weight: .bold))
|
||
.foregroundColor(AppColors.green)
|
||
}
|
||
RoundedRectangle(cornerRadius: 3)
|
||
.fill(count > 0 ? AppColors.green : AppColors.borderHi(cs))
|
||
.frame(height: count > 0 ? max(8, CGFloat(count) / CGFloat(maxDay) * 44) : 8)
|
||
Text(dayLetter(day))
|
||
.font(AppFonts.mono(7.5, weight: .bold))
|
||
.foregroundColor(Calendar.current.isDateInToday(day) ? AppColors.accent : AppColors.text3(cs))
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
.frame(height: 62)
|
||
}
|
||
|
||
// Quadrant breakdown
|
||
HStack(spacing: 8) {
|
||
QuadStat(label: "Urgent", count: taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .urgent }.count, color: AppColors.accent)
|
||
QuadStat(label: "Schedule", count: taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .schedule }.count, color: AppColors.yellow)
|
||
QuadStat(label: "Delegate", count: taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .delegate_ }.count, color: AppColors.blue)
|
||
QuadStat(label: "Eliminate",count: taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .eliminate }.count, color: AppColors.green)
|
||
}
|
||
}
|
||
.padding(14).background(AppColors.surface(cs))
|
||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
||
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
||
|
||
// ── Workouts card ──
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("WORKOUTS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||
|
||
HStack(spacing: 8) {
|
||
PStatCell(value: "\(workoutVM.streakDays)", label: streakBars(workoutVM.streakDays, goal: streakGoal), color: workoutVM.streakDays >= streakGoal ? AppColors.green : workoutVM.streakDays > 0 ? AppColors.accent : AppColors.text3(cs))
|
||
PStatCell(value: "\(workoutVM.doneSets)/\(workoutVM.totalSets)", label: "sets today", color: AppColors.blue)
|
||
PStatCell(value: "\(Int(workoutVM.progress * 100))%", label: "today", color: workoutVM.progress >= 1 ? AppColors.green : AppColors.text2(cs))
|
||
}
|
||
|
||
// Weekly schedule grid
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("THIS WEEK'S SCHEDULE")
|
||
.font(AppFonts.mono(7.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||
HStack(spacing: 5) {
|
||
ForEach(weekRow, id: \.0) { wd, letter in
|
||
let scheduled = workoutVM.schedule[wd] != nil
|
||
let isToday = wd == todayWD
|
||
let done = isToday && workoutVM.progress >= 1
|
||
VStack(spacing: 4) {
|
||
ZStack {
|
||
Circle()
|
||
.fill(scheduled
|
||
? (done ? AppColors.greenSoft : (isToday ? AppColors.accentSoft : AppColors.surface2(cs)))
|
||
: AppColors.surface2(cs))
|
||
.frame(width: 30, height: 30)
|
||
if scheduled {
|
||
if done {
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: 9, weight: .bold))
|
||
.foregroundColor(AppColors.green)
|
||
} else {
|
||
Image(systemName: "dumbbell.fill")
|
||
.font(.system(size: 8))
|
||
.foregroundColor(isToday ? AppColors.accent : AppColors.text3(cs))
|
||
}
|
||
}
|
||
}
|
||
.overlay(Circle().stroke(isToday ? AppColors.accent : Color.clear, lineWidth: 1.5))
|
||
Text(letter)
|
||
.font(AppFonts.mono(7.5, weight: .bold))
|
||
.foregroundColor(isToday ? AppColors.accent : AppColors.text3(cs))
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Programs summary
|
||
HStack {
|
||
Image(systemName: "list.bullet.clipboard")
|
||
.font(.system(size: 13))
|
||
.foregroundColor(AppColors.blue)
|
||
.frame(width: 28, height: 28)
|
||
.background(AppColors.blueSoft)
|
||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(workoutVM.workoutTitle)
|
||
.font(AppFonts.sans(12, weight: .semibold))
|
||
.foregroundColor(AppColors.text(cs))
|
||
Text("\(workoutVM.activeSections.count) sections · \(workoutVM.totalSets) sets total")
|
||
.font(AppFonts.mono(9)).foregroundColor(AppColors.text3(cs))
|
||
}
|
||
Spacer()
|
||
Text("ACTIVE").font(AppFonts.mono(8, weight: .bold))
|
||
.foregroundColor(AppColors.accent)
|
||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||
.background(AppColors.accentSoft).cornerRadius(5)
|
||
}
|
||
.padding(10)
|
||
.background(AppColors.surface2(cs))
|
||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
||
}
|
||
.padding(14).background(AppColors.surface(cs))
|
||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
||
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
||
|
||
// ── Health card ──
|
||
if hk.authorized {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack(spacing: 6) {
|
||
Text("HEALTH")
|
||
.font(AppFonts.mono(9, weight: .bold))
|
||
.foregroundColor(AppColors.text3(cs))
|
||
Image(systemName: "heart.fill")
|
||
.font(.system(size: 8))
|
||
.foregroundColor(AppColors.green)
|
||
}
|
||
|
||
HStack(spacing: 8) {
|
||
PStatCell(value: "\(hk.stepsToday.formatted())", label: "steps today", color: AppColors.blue)
|
||
PStatCell(value: "\(hk.activeCaloriesToday)", label: "kcal active", color: AppColors.accent)
|
||
PStatCell(value: hk.restingHeartRate > 0 ? "\(hk.restingHeartRate)" : "—",
|
||
label: "bpm resting", color: AppColors.green)
|
||
}
|
||
|
||
HStack(spacing: 10) {
|
||
Image(systemName: "figure.strengthtraining.traditional")
|
||
.font(.system(size: 13))
|
||
.foregroundColor(AppColors.green)
|
||
.frame(width: 28, height: 28)
|
||
.background(AppColors.greenSoft)
|
||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text("Workouts this week")
|
||
.font(AppFonts.sans(12, weight: .semibold))
|
||
.foregroundColor(AppColors.text(cs))
|
||
Text("From Apple Health")
|
||
.font(AppFonts.mono(9))
|
||
.foregroundColor(AppColors.text3(cs))
|
||
}
|
||
Spacer()
|
||
Text("\(hk.workoutsThisWeek)")
|
||
.font(AppFonts.mono(17, weight: .bold))
|
||
.foregroundColor(hk.workoutsThisWeek > 0 ? AppColors.green : AppColors.text3(cs))
|
||
}
|
||
.padding(10)
|
||
.background(AppColors.surface2(cs))
|
||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
||
}
|
||
.padding(14).background(AppColors.surface(cs))
|
||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
||
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.green.opacity(0.25), lineWidth: 1))
|
||
.onAppear { Task { await hk.refresh() } }
|
||
}
|
||
}
|
||
.padding(.horizontal, 14).padding(.bottom, 30)
|
||
}
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
.sheet(isPresented: $showActivityHistory) {
|
||
ActivityHistoryView()
|
||
.environmentObject(taskVM)
|
||
.environmentObject(workoutVM)
|
||
}
|
||
}
|
||
|
||
private func dayLetter(_ date: Date) -> String {
|
||
let f = DateFormatter(); f.dateFormat = "E"
|
||
return String(f.string(from: date).prefix(1))
|
||
}
|
||
}
|
||
|
||
private struct PStatCell: View {
|
||
@Environment(\.colorScheme) private var cs
|
||
let value: String; let label: String; let color: Color; var suffix: String = ""
|
||
var body: some View {
|
||
VStack(spacing: 4) {
|
||
HStack(spacing: 3) {
|
||
Text(value).font(AppFonts.mono(17, weight: .bold)).foregroundColor(color)
|
||
if !suffix.isEmpty { Text(suffix).font(.system(size: 13)) }
|
||
}
|
||
Text(label).font(AppFonts.mono(8)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
|
||
}
|
||
.frame(maxWidth: .infinity).padding(.vertical, 12)
|
||
.background(AppColors.surface2(cs)).clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
||
}
|
||
}
|
||
|
||
private func initials(for name: String?) -> String {
|
||
guard let name, !name.isEmpty else { return "?" }
|
||
let parts = name.split(separator: " ")
|
||
if parts.count >= 2 {
|
||
return (String(parts[0].prefix(1)) + String(parts[1].prefix(1))).uppercased()
|
||
}
|
||
return String(name.prefix(2)).uppercased()
|
||
}
|
||
|
||
private func streakBars(_ streak: Int, goal: Int) -> String {
|
||
let filled = min(max(streak, 0), goal)
|
||
return String(repeating: "|", count: filled) + String(repeating: "·", count: goal - filled)
|
||
}
|
||
|
||
private struct QuadStat: View {
|
||
@Environment(\.colorScheme) private var cs
|
||
let label: String; let count: Int; let color: Color
|
||
var body: some View {
|
||
VStack(spacing: 3) {
|
||
Text("\(count)").font(AppFonts.mono(14, weight: .bold)).foregroundColor(count > 0 ? color : AppColors.text3(cs))
|
||
Text(label).font(AppFonts.mono(7, weight: .bold)).foregroundColor(AppColors.text3(cs)).lineLimit(1).minimumScaleFactor(0.7)
|
||
}
|
||
.frame(maxWidth: .infinity).padding(.vertical, 8)
|
||
.background(count > 0 ? color.opacity(0.08) : AppColors.surface2(cs))
|
||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
||
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(count > 0 ? color.opacity(0.25) : AppColors.border(cs), lineWidth: 1))
|
||
}
|
||
}
|
||
|
||
// MARK: - About Sheet
|
||
private struct AboutSheet: View {
|
||
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
SheetHeader(title: "About") { dismiss() }
|
||
VStack(spacing: 16) {
|
||
Image(systemName: "calendar.badge.clock")
|
||
.font(.system(size: 48, weight: .light)).foregroundColor(AppColors.accent)
|
||
VStack(spacing: 4) {
|
||
Text("Wenza").font(AppFonts.sans(20, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||
Text("Version \(Bundle.main.versionDisplay)").font(AppFonts.mono(11)).foregroundColor(AppColors.text3(cs))
|
||
}
|
||
Text("A calm, focused productivity app for tasks, calendar, and fitness.")
|
||
.font(AppFonts.sans(13)).foregroundColor(AppColors.text2(cs))
|
||
.multilineTextAlignment(.center).padding(.horizontal, 20)
|
||
}
|
||
.padding(.top, 10)
|
||
Spacer()
|
||
}
|
||
.background(AppColors.background(cs).ignoresSafeArea())
|
||
}
|
||
}
|