Initial commit: KisaniCal iOS app

Task management, Eisenhower matrix, workout logging with persistence,
and calendar with 6 view modes (Month, List, Year, Week, 3 Day, Day).
Custom floating tab bar, dynamic calendar icon, and workout data seeded.
This commit is contained in:
kutesir
2026-05-27 02:09:44 +03:00
commit f67429de0b
22 changed files with 6797 additions and 0 deletions

View File

@@ -0,0 +1,765 @@
import SwiftUI
struct SettingsView: View {
@Environment(\.colorScheme) var cs
@EnvironmentObject var workoutVM: WorkoutViewModel
// 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 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) {
Text("Settings")
.font(AppFonts.sans(15, weight: .semibold))
.foregroundColor(AppColors.text(cs))
.padding(.top, 10).padding(.bottom, 4)
// Profile
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: 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()
.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: $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: - 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())
}
}