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.
259 lines
9.6 KiB
Swift
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)
|
|
)
|
|
}
|
|
}
|