Files
KisaniCal/KisaniCal/ContentView.swift
kutesir f67429de0b 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.
2026-05-27 02:09:44 +03:00

259 lines
9.6 KiB
Swift

import SwiftUI
struct ContentView: View {
@StateObject private var taskVM = TaskViewModel()
@StateObject private var workoutVM = WorkoutViewModel()
@Environment(\.horizontalSizeClass) var hSizeClass
@Environment(\.scenePhase) private var scenePhase
@AppStorage("appearanceMode") private var appearanceMode = 0
@AppStorage("showMatrixTab") private var showMatrixTab = true
@AppStorage("showWorkoutTab") private var showWorkoutTab = true
@AppStorage("hasOnboarded") private var hasOnboarded = false
@State private var showOnboarding = false
@State private var selectedTab = 0
private var calendarIcon: Image {
let day = Calendar.current.component(.day, from: Date())
let size = CGSize(width: 26, height: 26)
let renderer = UIGraphicsImageRenderer(size: size)
let img = renderer.image { _ in
let color = UIColor.black
let lw: CGFloat = 1.5
let cr: CGFloat = 4.5
// Outer rounded rectangle body
let body = CGRect(x: lw / 2, y: 3, width: size.width - lw, height: size.height - 3 - lw / 2)
let bodyPath = UIBezierPath(roundedRect: body, cornerRadius: cr)
color.setStroke()
bodyPath.lineWidth = lw
bodyPath.stroke()
// Header divider line
let divY = body.minY + 7.5
let divPath = UIBezierPath()
divPath.move(to: CGPoint(x: body.minX + lw / 2, y: divY))
divPath.addLine(to: CGPoint(x: body.maxX - lw / 2, y: divY))
color.withAlphaComponent(0.45).setStroke()
divPath.lineWidth = 1
divPath.stroke()
// Ring bumps at top
color.setFill()
for rx in [size.width * 0.3, size.width * 0.7] {
UIBezierPath(roundedRect: CGRect(x: rx - 1.5, y: 0.5, width: 3, height: 5.5), cornerRadius: 1.5).fill()
}
// Date number centred in body below header
let s = "\(day)"
let font = UIFont.systemFont(ofSize: floor(size.height * 0.38), weight: .bold)
let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color]
let ts = s.size(withAttributes: attrs)
let contentMidY = divY + (body.maxY - divY) / 2
s.draw(at: CGPoint(x: (size.width - ts.width) / 2, y: contentMidY - ts.height / 2), withAttributes: attrs)
}
return Image(uiImage: img.withRenderingMode(.alwaysTemplate))
}
private var preferredScheme: ColorScheme? {
switch appearanceMode {
case 1: return .light
case 2: return .dark
default: return nil
}
}
var body: some View {
Group {
if hSizeClass == .regular {
iPadLayout()
} else {
iPhoneLayout()
}
}
.preferredColorScheme(preferredScheme)
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView(isPresented: $showOnboarding)
.onDisappear { hasOnboarded = true }
}
.onAppear { if !hasOnboarded { showOnboarding = true } }
.onChange(of: scenePhase) { phase in
if phase == .active {
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
}
}
.onChange(of: workoutVM.doneSets) { _ in
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
}
.onChange(of: taskVM.tasks.filter { !$0.isComplete && $0.quadrant == .urgent }.count) { _ in
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
}
}
// MARK: - iPhone
@ViewBuilder
private func iPhoneLayout() -> some View {
ZStack(alignment: .bottom) {
TabView(selection: $selectedTab) {
TodayView()
.environmentObject(taskVM)
.environmentObject(workoutVM)
.tag(0)
.tabBarInset()
CalendarView()
.environmentObject(taskVM)
.environmentObject(workoutVM)
.tag(1)
.tabBarInset()
if showMatrixTab {
MatrixView()
.environmentObject(taskVM)
.tag(2)
.tabBarInset()
} else {
Color.clear.tag(2)
}
if showWorkoutTab {
WorkoutView()
.environmentObject(workoutVM)
.tag(3)
.tabBarInset()
} else {
Color.clear.tag(3)
}
SettingsView()
.environmentObject(workoutVM)
.tag(4)
.tabBarInset()
}
.tint(AppColors.accent)
KisaniTabBar(
selection: $selectedTab,
calIcon: calendarIcon,
showMatrix: showMatrixTab,
showWorkout: showWorkoutTab
)
.padding(.horizontal, 24)
.padding(.bottom, 8)
}
.onAppear { UITabBar.appearance().isHidden = true }
}
// MARK: - iPad
@ViewBuilder
private func iPadLayout() -> some View {
NavigationSplitView(columnVisibility: .constant(.all)) {
List {
iPadSidebarItem("Tasks", icon: "checkmark", tag: 0)
iPadSidebarItem("Calendar", icon: "calendar", tag: 1)
if showMatrixTab {
iPadSidebarItem("Matrix", icon: "square.grid.2x2", tag: 2)
}
if showWorkoutTab {
iPadSidebarItem("Workout", icon: "dumbbell", tag: 3)
}
iPadSidebarItem("Settings", icon: "gearshape", tag: 4)
}
.navigationTitle("Kisani Cal")
.listStyle(.sidebar)
} detail: {
TodayView().environmentObject(taskVM)
}
.tint(AppColors.accent)
}
private func iPadSidebarItem(_ label: String, icon: String, tag: Int) -> some View {
NavigationLink(destination: iPadDetail(tag: tag)) {
Label(label, systemImage: icon)
}
}
@ViewBuilder
private func iPadDetail(tag: Int) -> some View {
switch tag {
case 0: TodayView().environmentObject(taskVM).environmentObject(workoutVM)
case 1: CalendarView().environmentObject(taskVM).environmentObject(workoutVM)
case 2: MatrixView().environmentObject(taskVM)
case 3: WorkoutView().environmentObject(workoutVM)
default: SettingsView().environmentObject(workoutVM)
}
}
}
// MARK: - Custom Tab Bar
// Adds a transparent bottom safe-area inset equal to the floating tab bar height,
// so scroll content and FABs inside each tab clear the bar.
private extension View {
func tabBarInset() -> some View {
safeAreaInset(edge: .bottom, spacing: 0) {
Color.clear.frame(height: 62)
}
}
}
struct KisaniTabBar: View {
@Binding var selection: Int
let calIcon: Image
var showMatrix: Bool
var showWorkout: Bool
@Environment(\.colorScheme) private var cs
private struct Entry: Identifiable {
let id: Int
let sfIcon: String?
let custom: Image?
init(_ id: Int, _ icon: String) { self.id = id; sfIcon = icon; custom = nil }
init(_ id: Int, custom img: Image) { self.id = id; sfIcon = nil; custom = img }
}
private var entries: [Entry] {
var e: [Entry] = [Entry(0, "checkmark"), Entry(1, custom: calIcon)]
if showMatrix { e.append(Entry(2, "square.grid.2x2.fill")) }
if showWorkout { e.append(Entry(3, "dumbbell.fill")) }
e.append(Entry(4, "gearshape.fill"))
return e
}
var body: some View {
HStack(spacing: 0) {
ForEach(entries) { entry in
Button {
withAnimation(KisaniSpring.micro) { selection = entry.id }
} label: {
ZStack {
if selection == entry.id {
RoundedRectangle(cornerRadius: 14)
.fill(AppColors.surface2(cs))
.frame(width: 52, height: 44)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
if let img = entry.custom {
img
.font(.system(size: 20))
.foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text(cs))
} else if let icon = entry.sfIcon {
Image(systemName: icon)
.font(.system(size: 17, weight: selection == entry.id ? .semibold : .regular))
.foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text(cs))
}
}
.frame(maxWidth: .infinity).frame(height: 50)
}
.buttonStyle(PressButtonStyle(scale: 0.88))
}
}
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 28)
.fill(AppColors.surface(cs))
.shadow(color: .black.opacity(0.10), radius: 20, x: 0, y: 6)
.shadow(color: .black.opacity(0.04), radius: 4, x: 0, y: 1)
)
}
}