Files
KisaniCal/KisaniCal/Views/SettingsView.swift
kutesir 875f562801 Add profile stats sheet with task and workout analytics
Tapping the profile card opens a full stats sheet showing:
Tasks — completed this week, overdue count, done rate, 7-day bar
chart, open count by Eisenhower quadrant.
Workouts — streak, sets today, today % progress, weekly schedule
grid (dumbbell = scheduled, checkmark = done), active program summary.
2026-05-29 12:06:34 +03:00

1015 lines
54 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
struct SettingsView: View {
@Environment(\.colorScheme) var cs
@EnvironmentObject var workoutVM: WorkoutViewModel
@EnvironmentObject var taskVM: TaskViewModel
// Persisted settings
@AppStorage("appearanceMode") private var appearanceMode = 0
@AppStorage("soundsOn") private var soundsOn = true
@AppStorage("healthOn") private var healthOn = false
@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
// 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 showBackup = false
@State private var showWorkoutSettings = 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.restTimerSeconds)s" }
private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" }
private var dateLabel: String { ["MMM d", "MM/DD", "DD/MM"][dateFormat] }
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("RK")
.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("Robin Kutesa")
.font(AppFonts.sans(14, weight: .semibold))
.foregroundColor(AppColors.text(cs))
HStack(spacing: 5) {
AppBadge(
text: workoutVM.streakDays > 0 ? "\(workoutVM.streakDays)d streak 🔥" : "No streak yet",
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)
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: "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 }
SttToggleRow(icon: "heart", bg: AppColors.greenSoft, fg: AppColors.green, label: "Health Integration", isOn: $healthOn, isLast: true)
}
// 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", value: "𝕏 👾 📸") { showFollow = true }
SttNavRow(icon: "info.circle", bg: AppColors.surface3(cs), label: "About", value: "v1.0.0", 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: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showWorkoutSettings) { WorkoutSettingsSheet(unit: $weightUnit, sets: $defaultSets, reps: $defaultReps).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds).presentationDetents([.height(340)]).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) }
}
}
// 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 Kisani Cal 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 Kisani 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 {
@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
@Environment(\.dismiss) var dismiss; @Environment(\.colorScheme) var cs
@State private var bwStr = ""
@State private var htStr = ""
var body: some View {
VStack(spacing: 0) {
SheetHeader(title: "Workout Settings") { dismiss() }
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 20) {
// 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))" : ""
}
}
}
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
@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() }
Text("\(seconds)s").font(AppFonts.mono(52, weight: .bold)).foregroundColor(AppColors.accent).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)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)
}
}
.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=Kisani%20Cal%20Feedback") { openURL(url) }
}
HelpRow(icon: "questionmark.circle", label: "FAQ", sub: "Frequently asked questions") {}
HelpRow(icon: "book", label: "User Guide", sub: "Learn how to use Kisani Cal") {}
}
.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
// 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 {
let start = Calendar.current.date(byAdding: .day, value: -6, to: Calendar.current.startOfDay(for: Date()))!
return taskVM.tasks.filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= start }.count
}
private var urgentOpen: Int {
taskVM.tasks.filter { !$0.isComplete && $0.quadrant == .urgent }.count
}
// 7-day task activity
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()))!
let next = cal.date(byAdding: .day, value: 1, to: day)!
let n = taskVM.tasks.filter { t in
guard t.isComplete, let at = t.completedAt else { return false }
return at >= day && at < next
}.count
return (day, n)
}
}
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("RK").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("Robin Kutesa")
.font(AppFonts.sans(18, weight: .bold)).foregroundColor(AppColors.text(cs))
Text("kisaniCAL.")
.font(AppFonts.mono(11)).foregroundColor(AppColors.text3(cs))
}
.frame(maxWidth: .infinity).padding(.vertical, 14)
// Tasks card
VStack(alignment: .leading, spacing: 12) {
Text("TASKS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
HStack(spacing: 8) {
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.quadrant == .urgent && !$0.isComplete }.count, color: AppColors.accent)
QuadStat(label: "Schedule", count: taskVM.tasks.filter { $0.quadrant == .schedule && !$0.isComplete }.count, color: AppColors.yellow)
QuadStat(label: "Delegate", count: taskVM.tasks.filter { $0.quadrant == .delegate_ && !$0.isComplete }.count, color: AppColors.blue)
QuadStat(label: "Eliminate",count: taskVM.tasks.filter { $0.quadrant == .eliminate && !$0.isComplete }.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: "day streak", color: workoutVM.streakDays > 0 ? AppColors.accent : AppColors.text3(cs), suffix: workoutVM.streakDays > 0 ? "🔥" : "")
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))
}
.padding(.horizontal, 14).padding(.bottom, 30)
}
}
.background(AppColors.background(cs).ignoresSafeArea())
}
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 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("Kisani Cal").font(AppFonts.sans(20, weight: .bold)).foregroundColor(AppColors.text(cs))
Text("Version 1.0.0").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())
}
}