feat: 添加拍药盒功能和语音直达入口 - 实现拍药盒扫描流程,支持本地OCR识别药品信息 - 在日记页面添加拍药盒和记症状的三选一入口 - 优化按钮点击区域,确保符合苹果HIG最小命中区标准 - 添加用药记录到时间线的独立分类显示 - 实现长按+号语音直达功能,支持语音意图分类跳转 - 更新项目配置文件,启用代码分析和死代码剥离选项 - 增加多项本地化字符串支持新功能 ```
312 lines
11 KiB
Swift
312 lines
11 KiB
Swift
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()
|
|
}
|