feat: 添加拍药盒功能和语音直达入口

- 实现拍药盒扫描流程,支持本地OCR识别药品信息
- 在日记页面添加拍药盒和记症状的三选一入口
- 优化按钮点击区域,确保符合苹果HIG最小命中区标准
- 添加用药记录到时间线的独立分类显示
- 实现长按+号语音直达功能,支持语音意图分类跳转
- 更新项目配置文件,启用代码分析和死代码剥离选项
- 增加多项本地化字符串支持新功能
```
This commit is contained in:
link2026
2026-06-13 09:16:25 +08:00
parent f58d6064ba
commit 6c6a950140
30 changed files with 1856 additions and 64 deletions

View File

@@ -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("轻点打开新建菜单,长按语音直达")
}
}
//