feat(symptom): add Symptom @Model + start/end sheets + ongoing card

- Symptom @Model with severity 1-5 clamp, isOngoing, duration helpers
- SymptomStartSheet / SymptomEndSheet / OngoingSymptomsCard
- RecordSheet 加 .symptom kind 入口
- RootView 增加 'records' tab + ArchiveListView placeholder
- HomeView 顶部加 OngoingSymptomsCard
- ModelsSchemaTests: 2 个 Symptom 烟测(ongoing predicate + severity clamp)

Note: Symptom 是 CLAUDE.md §10 清单外的新功能,由产品负责人决定加入。
ArchiveListView 仍是 placeholder,真实 C1 实现按计划在 W4。
This commit is contained in:
link2026
2026-05-25 23:18:21 +08:00
parent e4a68a1bdd
commit 46b69cf8e1
10 changed files with 643 additions and 41 deletions

View File

@@ -1,19 +1,21 @@
import SwiftUI
enum TjTab: String, Hashable, CaseIterable {
case home, trend, me
case home, records, trend, me
var label: String {
switch self {
case .home: return ""
case .trend: return "趋势"
case .me: return "我的"
case .home: return ""
case .records: return "记录"
case .trend: return "趋势"
case .me: return "我的"
}
}
var icon: String {
switch self {
case .home: return "house"
case .trend: return "chart.line.uptrend.xyaxis"
case .me: return "person.circle"
case .home: return "house"
case .records: return "list.bullet.rectangle"
case .trend: return "chart.line.uptrend.xyaxis"
case .me: return "person.circle"
}
}
}
@@ -27,14 +29,16 @@ struct RootView: View {
@State private var tab: TjTab = .home
@State private var showRecordSheet = false
@State private var activeFlow: ActiveFlow?
@State private var showSymptomStart = false
var body: some View {
VStack(spacing: 0) {
Group {
switch tab {
case .home: HomeView(onTapArchive: {})
case .trend: TrendsView()
case .me: MeView()
case .home: HomeView(onTapArchive: { tab = .records })
case .records: ArchiveListView()
case .trend: TrendsView()
case .me: MeView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -51,11 +55,15 @@ struct RootView: View {
switch kind {
case .quick: activeFlow = .quick
case .archive: activeFlow = .archive
case .symptom: showSymptomStart = true
case .diary: break
}
}
}
}
.sheet(isPresented: $showSymptomStart) {
SymptomStartSheet()
}
#if os(iOS)
.fullScreenCover(item: $activeFlow) { flow in
switch flow {
@@ -83,66 +91,101 @@ private struct TabBar: View {
let onTap: (TjTab) -> Void
let onTapRecord: () -> Void
private let cornerRadius: CGFloat = 22
private let slotHeight: CGFloat = 34
var body: some View {
HStack(spacing: 0) {
HStack(alignment: .bottom, spacing: 0) {
tabItem(.home)
Color.clear.frame(width: 60, height: 1)
tabItem(.records)
recordSlot
tabItem(.trend)
tabItem(.me)
}
.padding(.horizontal, 12)
.padding(.horizontal, 4)
.padding(.top, 10)
.padding(.bottom, 4)
.background(
Tj.Palette.paper
.overlay(alignment: .top) {
Rectangle()
.fill(Tj.Palette.lineSoft)
.frame(height: 1)
}
.padding(.bottom, 6)
.background(barBackground)
}
private var barBackground: some View {
UnevenRoundedRectangle(
topLeadingRadius: cornerRadius,
bottomLeadingRadius: 0,
bottomTrailingRadius: 0,
topTrailingRadius: cornerRadius,
style: .continuous
)
.fill(Tj.Palette.paper)
.overlay(alignment: .top) {
recordButton.offset(y: -22)
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 {
Button { onTap(t) } label: {
let isActive = active == t
return Button { onTap(t) } label: {
VStack(spacing: 4) {
Image(systemName: t.icon)
.font(.system(size: 20, weight: .regular))
.frame(width: 26, height: 26)
ZStack {
if isActive {
Capsule()
.fill(Tj.Palette.sand2)
.frame(width: 44, height: slotHeight - 6)
}
Image(systemName: t.icon)
.font(.system(size: 18, weight: isActive ? .semibold : .regular))
}
.frame(width: 50, height: slotHeight)
Text(t.label)
.font(.system(size: 11))
.font(.system(size: 11, weight: isActive ? .semibold : .regular))
}
.foregroundStyle(active == t ? Tj.Palette.ink : Tj.Palette.text3)
.foregroundStyle(isActive ? Tj.Palette.ink : Tj.Palette.text3)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(TabPressStyle())
}
private var recordButton: some View {
private var recordSlot: some View {
Button(action: onTapRecord) {
VStack(spacing: 4) {
ZStack {
Circle()
.fill(Tj.Palette.ink)
.shadow(color: Color(red: 0.157, green: 0.216, blue: 0.176).opacity(0.25),
radius: 12, x: 0, y: 4)
.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(.system(size: 20, weight: .semibold))
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Tj.Palette.paper)
}
.frame(width: 52, height: 52)
.frame(width: slotHeight, height: slotHeight)
Text("记录")
.font(.system(size: 11))
Text("新建")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Tj.Palette.ink)
}
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(TabPressStyle())
}
}
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)
}
}