Files
KisaniCal/KisaniCal/ContentView.swift
kutesir bcbcda5ba5 analytics(deeplink): route report notification tap to its report (KC-51 ph4c)
Completes deep-link routing (goal #7): AnalyticsDeepLink is Identifiable; a
tapped weekly/monthly report notification is consumed in ContentView on
launch/active and presents AnalyticsView opened to that area. Core round-trip
re-verified standalone; SwiftUI wiring needs an Xcode build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00

348 lines
14 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 = {
#if DEBUG
if let t = ProcessInfo.processInfo.environment["KISANI_INITIAL_TAB"], let i = Int(t) { return i }
#endif
return 0
}()
@State private var showQuickAddTask = false
@State private var analyticsLink: AnalyticsDeepLink?
@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()
workoutVM.checkPendingMissed()
CloudSyncManager.shared.restoreSettings()
CloudSyncManager.shared.backupSettings()
HealthKitManager.shared.bootstrap()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
consumeWidgetAddTask()
// Backfill any missing analytics reports (idempotent), then notify.
let generated = AnalyticsService.shared.reconcile(using: workoutVM)
NotificationManager.shared.scheduleAnalyticsReports(generated)
if let link = NotificationManager.shared.consumePendingAnalyticsDeepLink() { analyticsLink = link }
// 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()
workoutVM.checkPendingMissed()
CloudSyncManager.shared.backupSettings()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
let generated = AnalyticsService.shared.reconcile(using: workoutVM)
NotificationManager.shared.scheduleAnalyticsReports(generated)
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()
}
.sheet(item: $workoutVM.askCompensate) { missed in
CompensationSheet(missed: missed)
.environmentObject(workoutVM)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
.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)
}
.sheet(item: $analyticsLink) { link in
AnalyticsView(initialLink: link)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
#if DEBUG
.onAppear {
// Test hook: auto-open Quick Add (used to verify the voicefield binding
// on the simulator, which has no microphone). Compiled out of Release.
if ProcessInfo.processInfo.environment["KISANI_AUTOADD"] == "1" {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { showQuickAddTask = true }
}
}
#endif
.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("Wenza")
.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)
}
}