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>
348 lines
14 KiB
Swift
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 voice→field 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)
|
|
}
|
|
}
|