Files
kangkang/康康/RootView.swift
link2026 30f75dc2cd ```
feat(DiaryQuickSheet): 添加AI追问问答功能和底部协作入口

- 新增currentAnswer状态管理追问输入,添加answerFocused状态独立处理键盘避让
- 移除键盘工具条的关心条,将AI协作入口固定到底部按钮
- 添加完整的问答式追问卡片组件,支持自由回答输入和加入日记功能
- 修改prompt阶段行为,不再在正文区显示邀请横幅
- 更新recordCurrent为answerCurrent,实现问题+答案一同加入日记的逻辑
- 调整底部操作栏布局,间距和内边距优化

refactor(InferenceSettingsView): 性能自检改为内联展开模式

- 将性能自检视图从导航链接改为当前页就地展开
- 添加showSelfTest状态控制展开收起动画
- 支持ModelSelfTestView内联嵌入模式,去除外层导航和背景

chore(Localizable): 同步更新本地化字符串资源

- 添加新的UI文本:加入日记、在这儿写下你的回答、康康帮你一起填等
- 修复部分字符串位置调整和翻译映射问题
- 同步更新多语言版本的翻译内容

style(RootView): 优化记一笔标签页视觉设计

- 为记一笔标签添加语音识别角标标识
- 使用麦克风图标配合加号突出长按语音直达功能
```
2026-06-17 10:05:32 +08:00

339 lines
13 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
/// chip `.report`, tab
@State private var pendingRecordsFilter: TimelineKind?
@State private var showRecordSheet = false
@State private var activeFlow: ActiveFlow?
@State private var showSymptomStart = false
@State private var showDiary = false
/// : sheet ,
@State private var diaryDirectWrite = 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
/// · :sheet + NavigationStack
@State private var showMedicationLibrary = false
/// ( RecordSheet onPick )
private func route(_ intent: VoiceIntent) {
switch intent {
case .diary: diaryDirectWrite = true; 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: { kind in
pendingRecordsFilter = kind
select(.records)
})
case .records: ArchiveListView(initialFilter: pendingRecordsFilter)
case .trend: TrendsView()
case .me: MeView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.id(tab)
.transition(.push(from: pushEdge))
TabBar(active: tab,
onTap: {
// tab , .report
if $0 == .records { pendingRecordsFilter = nil }
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: diaryDirectWrite = false; showDiary = true
case .indicator: showIndicator = true
case .reminder: showReminders = true
case .healthExport: showHealthExport = true
case .medicationLibrary: showMedicationLibrary = true
}
}
}
}
.sheet(isPresented: $showSymptomStart) {
SymptomStartSheet()
}
.sheet(isPresented: $showDiary) {
DiaryQuickSheet(directWrite: diaryDirectWrite)
}
.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) }
}
.sheet(isPresented: $showMedicationLibrary) {
NavigationStack { MedicationLibraryView(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: { meds, images in
MedicationArchiver.archive(medications: meds, images: images, 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.shadow.opacity(0.07), 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.shadow.opacity(0.20),
radius: 5, x: 0, y: 2)
Image(systemName: "plus")
.font(.tjScaled( 16, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
}
.frame(width: slotHeight, height: slotHeight)
// :, +
.overlay(alignment: .bottomTrailing) {
Image(systemName: "mic.fill")
.font(.tjScaled( 8, weight: .bold))
.foregroundStyle(Tj.Palette.paper)
.frame(width: 15, height: 15)
.background(Circle().fill(Tj.Palette.brick))
.overlay(Circle().strokeBorder(Tj.Palette.paper, lineWidth: 1.5))
.offset(x: 3, y: 2)
}
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()
}