```
feat: 添加拍药盒功能和语音直达入口 - 实现拍药盒扫描流程,支持本地OCR识别药品信息 - 在日记页面添加拍药盒和记症状的三选一入口 - 优化按钮点击区域,确保符合苹果HIG最小命中区标准 - 添加用药记录到时间线的独立分类显示 - 实现长按+号语音直达功能,支持语音意图分类跳转 - 更新项目配置文件,启用代码分析和死代码剥离选项 - 增加多项本地化字符串支持新功能 ```
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
|
||||
enum TjTab: String, Hashable, CaseIterable {
|
||||
case home, records, trend, me
|
||||
@@ -35,6 +37,8 @@ enum ActiveFlow: Identifiable {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -45,6 +49,23 @@ struct RootView: View {
|
||||
@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 的地方都走这里,保证过渡方向正确。
|
||||
@@ -70,9 +91,15 @@ struct RootView: View {
|
||||
|
||||
TabBar(active: tab,
|
||||
onTap: { select($0) },
|
||||
onTapRecord: { showRecordSheet = true })
|
||||
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
|
||||
@@ -111,6 +138,30 @@ struct RootView: View {
|
||||
.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 {
|
||||
@@ -137,8 +188,11 @@ 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
|
||||
@@ -201,33 +255,45 @@ private struct TabBar: View {
|
||||
.buttonStyle(TabPressStyle())
|
||||
}
|
||||
|
||||
/// + 号:点按 → 新建菜单;长按 → 语音直达。
|
||||
/// 不用 Button + simultaneousGesture(长按成功后松手仍可能触发 tap 二次弹菜单),
|
||||
/// 改为 tap / longPress 双手势 + onPressingChanged 驱动按压缩放。
|
||||
private var recordSlot: some View {
|
||||
Button(action: onTapRecord) {
|
||||
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)
|
||||
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)
|
||||
Image(systemName: "plus")
|
||||
.font(.tjScaled( 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.paper)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.frame(width: slotHeight, height: slotHeight)
|
||||
|
||||
Text("新建")
|
||||
.font(.tjScaled( 11, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink)
|
||||
}
|
||||
.buttonStyle(TabPressStyle())
|
||||
.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("轻点打开新建菜单,长按语音直达")
|
||||
}
|
||||
}
|
||||
// 你好
|
||||
|
||||
Reference in New Issue
Block a user