import SwiftUI import SwiftData import UIKit enum TjTab: String, Hashable, CaseIterable { case home, records, trend, me var label: String { switch self { case .home: return String(appLoc: "主页") case .records: return String(appLoc: "记录") case .trend: return String(appLoc: "趋势") case .me: return String(appLoc: "我的") } } var icon: String { switch self { case .home: return "house" case .records: return "list.bullet.rectangle" case .trend: return "chart.line.uptrend.xyaxis" case .me: return "person.circle" } } /// 屏上从左到右的位置,用于决定页面 push 过渡的方向。 var index: Int { switch self { case .home: return 0 case .records: return 1 case .trend: return 2 case .me: return 3 } } } enum ActiveFlow: Identifiable { case quick, archive var id: String { String(describing: self) } } struct RootView: View { @Environment(\.modelContext) private var ctx @Environment(\.scenePhase) private var scenePhase @State private var tab: TjTab = .home /// 页面 push 过渡的来向:切到右侧 tab 时从 trailing 推入,切到左侧时从 leading 推入。 @State private var pushEdge: Edge = .trailing @State private var showRecordSheet = false @State private var activeFlow: ActiveFlow? @State private var showSymptomStart = false @State private var showDiary = false @State private var showIndicator = false @State private var showReminders = false @State private var showHealthExport = false /// 长按 + :语音直达(说一句话 → LLM 意图分类 → 打开对应入口)。 @State private var showVoiceCommand = false /// 语音直达「拍药盒」:RootView 层直接弹 MedicationScanFlow,不绕日记 sheet。 @State private var showMedicationScan = false /// 语音意图 → 打开对应新建入口(与 RecordSheet onPick 的路由一一对应)。 private func route(_ intent: VoiceIntent) { switch intent { case .diary: showDiary = true case .medication: showMedicationScan = true case .symptom: showSymptomStart = true case .indicator: showIndicator = true case .archive: activeFlow = .archive case .export: showHealthExport = true case .reminder: showReminders = true } } /// 统一的 tab 切换入口:按方向设定 pushEdge,再带动画改 tab。 /// 所有改 tab 的地方都走这里,保证过渡方向正确。 private func select(_ newTab: TjTab) { guard newTab != tab else { return } pushEdge = newTab.index > tab.index ? .trailing : .leading withAnimation(.easeInOut(duration: 0.26)) { tab = newTab } } var body: some View { VStack(spacing: 0) { Group { switch tab { case .home: HomeView(onTapArchive: { select(.records) }) case .records: ArchiveListView() case .trend: TrendsView() case .me: MeView() } } .frame(maxWidth: .infinity, maxHeight: .infinity) .id(tab) .transition(.push(from: pushEdge)) TabBar(active: tab, onTap: { select($0) }, onTapRecord: { showRecordSheet = true }, onLongPressRecord: { showVoiceCommand = true }) } .background(Tj.Palette.sand.ignoresSafeArea()) // 桌面 Widget 快照:启动后写一次,进后台时再写一次(轻量读库,App Group 未配置则 no-op)。 .task { WidgetSnapshotRefresher.refresh(in: ctx) } .onChange(of: scenePhase) { _, phase in if phase == .background { WidgetSnapshotRefresher.refresh(in: ctx) } } .sheet(isPresented: $showRecordSheet) { RecordSheet { kind in showRecordSheet = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { switch kind { case .quick: activeFlow = .quick case .archive: activeFlow = .archive case .symptom: showSymptomStart = true case .diary: showDiary = true case .indicator: showIndicator = true case .reminder: showReminders = true case .healthExport: showHealthExport = true } } } } .sheet(isPresented: $showSymptomStart) { SymptomStartSheet() } .sheet(isPresented: $showDiary) { DiaryQuickSheet() } .sheet(isPresented: $showIndicator) { // 「拍照识别」入口:关闭手输表单 → 打开指标速记 VL 流程(并入「记录指标」)。 IndicatorQuickSheet(onRequestCamera: { showIndicator = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { activeFlow = .quick } }) } .sheet(isPresented: $showReminders) { // 列表页依赖外层 NavigationStack 提供标题栏;sheet 形态补「完成」按钮。 NavigationStack { RemindersListView(presentedAsSheet: true) } } .fullScreenCover(isPresented: $showHealthExport) { HealthExportSheet() } .sheet(isPresented: $showVoiceCommand) { VoiceCommandSheet( onResolve: { intent in showVoiceCommand = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { route(intent) } }, onOpenMenu: { showVoiceCommand = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { showRecordSheet = true } } ) } .fullScreenCover(isPresented: $showMedicationScan) { MedicationScanFlow( onSave: { entries in MedicationArchiver.archive(entries: entries, in: ctx) }, onClose: { showMedicationScan = false } ) } #if os(iOS) .fullScreenCover(item: $activeFlow) { flow in switch flow { case .quick: QuickRegionCaptureFlow(onClose: { activeFlow = nil }) case .archive: UnifiedCaptureFlow(onClose: { activeFlow = nil }) } } #else .sheet(item: $activeFlow) { flow in switch flow { case .quick: QuickRegionCaptureFlow(onClose: { activeFlow = nil }) case .archive: UnifiedCaptureFlow(onClose: { activeFlow = nil }) } } #endif } } private struct TabBar: View { let active: TjTab let onTap: (TjTab) -> Void let onTapRecord: () -> Void let onLongPressRecord: () -> Void @Namespace private var indicatorNS /// + 号按压态(长按手势驱动的缩放视觉,代替 ButtonStyle)。 @State private var recordPressing = false private let cornerRadius: CGFloat = 22 private let slotHeight: CGFloat = 34 var body: some View { HStack(alignment: .bottom, spacing: 0) { tabItem(.home) tabItem(.records) recordSlot tabItem(.trend) tabItem(.me) } .padding(.horizontal, 4) .padding(.top, 10) .padding(.bottom, 6) .background(barBackground) .animation(.spring(response: 0.35, dampingFraction: 0.75), value: active) } private var barBackground: some View { UnevenRoundedRectangle( topLeadingRadius: cornerRadius, bottomLeadingRadius: 0, bottomTrailingRadius: 0, topTrailingRadius: cornerRadius, style: .continuous ) .fill(Tj.Palette.paper) .overlay(alignment: .top) { Rectangle() .fill(Tj.Palette.lineSoft) .frame(height: 1) } .shadow(color: Tj.Palette.ink.opacity(0.05), radius: 10, x: 0, y: -2) } private func tabItem(_ t: TjTab) -> some View { let isActive = active == t return Button { onTap(t) } label: { VStack(spacing: 4) { ZStack { if isActive { Capsule() .fill(Tj.Palette.sand2) .frame(width: 44, height: slotHeight - 6) .matchedGeometryEffect(id: "tabIndicator", in: indicatorNS) } Image(systemName: t.icon) .font(.tjScaled( 18, weight: isActive ? .semibold : .regular)) } .frame(width: 50, height: slotHeight) Text(t.label) .font(.tjScaled( 11, weight: isActive ? .semibold : .regular)) } .foregroundStyle(isActive ? Tj.Palette.ink : Tj.Palette.text3) .frame(maxWidth: .infinity) .contentShape(Rectangle()) } .buttonStyle(TabPressStyle()) } /// + 号:点按 → 新建菜单;长按 → 语音直达。 /// 不用 Button + simultaneousGesture(长按成功后松手仍可能触发 tap 二次弹菜单), /// 改为 tap / longPress 双手势 + onPressingChanged 驱动按压缩放。 private var recordSlot: some View { VStack(spacing: 4) { ZStack { Circle() .fill(Tj.Palette.ink) .overlay( Circle() .strokeBorder(Tj.Palette.paper, lineWidth: 2) ) .shadow(color: Tj.Palette.ink.opacity(0.18), radius: 4, x: 0, y: 2) Image(systemName: "plus") .font(.tjScaled( 16, weight: .semibold)) .foregroundStyle(Tj.Palette.paper) } .frame(width: slotHeight, height: slotHeight) Text("新建") .font(.tjScaled( 11, weight: .semibold)) .foregroundStyle(Tj.Palette.ink) } .frame(maxWidth: .infinity) .contentShape(Rectangle()) .scaleEffect(recordPressing ? 0.92 : 1.0) .animation(.spring(response: 0.25, dampingFraction: 0.7), value: recordPressing) .onTapGesture { onTapRecord() } .onLongPressGesture(minimumDuration: 0.45) { UIImpactFeedbackGenerator(style: .medium).impactOccurred() onLongPressRecord() } onPressingChanged: { pressing in recordPressing = pressing } .accessibilityElement(children: .combine) .accessibilityLabel("新建") .accessibilityHint("轻点打开新建菜单,长按语音直达") } } // 你好 private struct TabPressStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.92 : 1.0) .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed) } } #Preview { RootView() }