Files
KisaniCal/KisaniCal/ContentView.swift
kutesir ad304913e4 Voice: show dictated transcript in the Quick Add field
The mic captured speech and the transcript reached rawText, but it never appeared
in the field. NLTaskField.updateUIView (UITextView representable) returned early
when the field wasn't focused (`guard coordinator.isEditing`), so externally-set
text (voice dictation, the normal not-focused case) was dropped and the
placeholder stayed.

Fix: render non-empty `text` even when not editing, placing the caret at the end;
keep the cursor-preserving fast path while actively editing. No new voice system —
this only fixes the existing mic button's transcript→field binding.

Verified on simulator via the real speech.transcript path: "Buy milk tomorrow" →
field shows it (chip: Tomorrow); "Call Romeo at 11 AM" → field shows it (chip:
Today 11:00). Adds DEBUG-only test hooks KISANI_AUTOADD / KISANI_VOICE_SIM
(compiled out of Release). Logged under KC-38.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:22:45 +03:00

336 lines
13 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
@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()
// 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)
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)
}
#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)
}
}