Files
KisaniCal/KisaniCal/ContentView.swift
kutesir 800c7db76d fix: reset workout log each new day + new app logo
KC-1: Set completion was stored permanently on the program, so the same
program scheduled on back-to-back days (e.g. Shoulders Mon + Tue) kept
yesterday's checkmarks. Add resetSetsForNewDayIfNeeded() to clear every
set's isDone when the calendar day changes (cold launch, onAppear, and
scenePhase active). Streak history is untouched.

KC-2: Replace all 12 AppIcon sizes with the new KisaniCal mark, alpha
flattened onto RGB(26,29,34) so the 1024 App Store icon is fully opaque.

Add KisaniCal/ISSUES.md tracker (KC-1, KC-2: In Progress).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:29:25 +03:00

314 lines
12 KiB
Swift

import SwiftUI
import WidgetKit
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
@State private var showQuickAddTask = false
@StateObject private var tabState = FloatingTabState()
@ObservedObject private var shortcuts = ShortcutHandler.shared
@ObservedObject private var tutorial = TutorialManager.shared
@State private var tutorialFrames: [Int: CGRect] = [:]
@State private var iPadSelection: Int? = 0
@State private var calIconImage: Image = ContentView.buildCalIcon()
private static func buildCalIcon() -> 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
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()
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()
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()
}
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
}
}
private var isIPad: Bool { UIDevice.current.userInterfaceIdiom == .pad }
/// Picks up the "+" tapped on the My Tasks widget and opens the add-task sheet.
private func consumeWidgetAddTask() {
guard UserDefaults.kisani.bool(forKey: "kisani.widget.openAddTask") else { return }
UserDefaults.kisani.removeObject(forKey: "kisani.widget.openAddTask")
selectedTab = 0
showQuickAddTask = true
}
var body: some View {
Group {
if isIPad {
iPadLayout()
} else {
iPhoneLayout()
}
}
.environmentObject(tabState)
.preferredColorScheme(preferredScheme)
.fullScreenCover(isPresented: $showOnboarding) {
OnboardingView(isPresented: $showOnboarding)
.environmentObject(workoutVM)
.onDisappear { hasOnboarded = true }
}
.onAppear {
if !hasOnboarded { showOnboarding = true }
workoutVM.resetSetsForNewDayIfNeeded()
CloudSyncManager.shared.restoreSettings()
CloudSyncManager.shared.backupSettings()
HealthKitManager.shared.bootstrap()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
consumeWidgetAddTask()
// Pull workouts from Health, then reschedule so a detected workout updates reminders.
Task {
await workoutVM.syncFromHealthKit()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
}
}
.onChange(of: scenePhase) { phase in
if phase == .active {
taskVM.reload()
consumeWidgetAddTask()
workoutVM.resetSetsForNewDayIfNeeded()
workoutVM.checkPendingHealthConfirm()
CloudSyncManager.shared.backupSettings()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
Task {
await workoutVM.syncFromHealthKit()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
}
}
}
.onChange(of: taskVM.tasks) { _ in
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
WidgetCenter.shared.reloadAllTimelines()
}
.onChange(of: workoutVM.doneSets) { _ in
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
}
.onChange(of: shortcuts.pending) { action in
guard let action else { return }
switch action {
case .addTask:
showQuickAddTask = true
case .today:
withAnimation(KisaniSpring.micro) { selectedTab = 0 }
case .next3:
withAnimation(KisaniSpring.micro) { selectedTab = 0 }
case .calendar:
withAnimation(KisaniSpring.micro) { selectedTab = 1 }
case .workout:
withAnimation(KisaniSpring.micro) { selectedTab = showWorkoutTab ? 3 : 0 }
}
shortcuts.consume()
}
.sheet(isPresented: $showQuickAddTask) {
AddTaskSheet(prefilledDate: Date())
.environmentObject(taskVM)
.presentationDetents([.height(150)])
.presentationDragIndicator(.visible)
}
.onChange(of: showWorkoutTab) { _ in ShortcutHandler.registerShortcuts() }
}
// 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)
.environmentObject(taskVM)
.tag(4)
.tabBarInset()
}
.tint(AppColors.accent)
.onChange(of: selectedTab) { _ in tabState.resetOffset() }
KisaniTabBar(
selection: $selectedTab,
calIcon: calIconImage,
showMatrix: showMatrixTab,
showWorkout: showWorkoutTab
)
if tutorial.isActive {
TutorialOverlay(frames: tutorialFrames)
.transition(.opacity)
.zIndex(99)
}
}
.onPreferenceChange(TutorialFrameKey.self) { tutorialFrames = $0 }
.onAppear { UITabBar.appearance().isHidden = true }
}
// MARK: - iPad
@ViewBuilder
private func iPadLayout() -> some View {
NavigationSplitView {
List(selection: $iPadSelection) {
Label("Tasks", systemImage: "checkmark").tag(0)
Label("Calendar", systemImage: "calendar").tag(1)
if showMatrixTab { Label("Matrix", systemImage: "square.grid.2x2").tag(2) }
if showWorkoutTab { Label("Workout", systemImage: "dumbbell").tag(3) }
Label("Settings", systemImage: "gearshape").tag(4)
}
.navigationTitle("Kisani Cal")
.listStyle(.sidebar)
} detail: {
switch iPadSelection ?? 0 {
case 1: CalendarView().environmentObject(taskVM).environmentObject(workoutVM)
case 2: MatrixView().environmentObject(taskVM)
case 3: WorkoutView().environmentObject(workoutVM)
case 4: SettingsView().environmentObject(workoutVM).environmentObject(taskVM)
default: TodayView().environmentObject(taskVM).environmentObject(workoutVM)
}
}
.tint(AppColors.accent)
}
}
// MARK: - Custom Tab Bar
private extension View {
func tabBarInset() -> some View {
safeAreaInset(edge: .bottom, spacing: 0) {
Color.clear.frame(height: 64)
}
}
}
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: 16)
.fill(Color(uiColor: cs == .dark ? .tertiarySystemFill : .systemGray5))
.frame(width: 62, height: 42)
.transition(.opacity.combined(with: .scale(scale: 0.88)))
}
if let img = entry.custom {
img
.font(.system(size: 22))
.foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text3(cs))
} else if let icon = entry.sfIcon {
Image(systemName: icon)
.font(.system(size: 22, weight: selection == entry.id ? .semibold : .regular))
.foregroundColor(selection == entry.id ? AppColors.accent : AppColors.text3(cs))
}
}
.frame(maxWidth: .infinity)
.frame(height: 52)
.tutorialAnchor(entry.id)
}
.buttonStyle(PressButtonStyle(scale: 0.90))
}
}
.padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 28)
.fill(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground))
.shadow(color: .black.opacity(cs == .dark ? 0.40 : 0.10), radius: 20, x: 0, y: 6)
)
.padding(.horizontal, 20)
.padding(.bottom, 10)
}
}