diff --git a/体己/__App.swift b/体己/App/TijiApp.swift similarity index 61% rename from 体己/__App.swift rename to 体己/App/TijiApp.swift index 2172e37..4efd359 100644 --- a/体己/__App.swift +++ b/体己/App/TijiApp.swift @@ -1,23 +1,17 @@ -// -// __App.swift -// 体己 -// -// Created by Tim on 2026/5/25. -// - import SwiftUI import SwiftData @main -struct __App: App { +struct TijiApp: App { var sharedModelContainer: ModelContainer = { let schema = Schema([ - Item.self, + Indicator.self, + Report.self, + DiaryEntry.self, ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) - + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) + return try ModelContainer(for: schema, configurations: [config]) } catch { fatalError("Could not create ModelContainer: \(error)") } @@ -25,7 +19,7 @@ struct __App: App { var body: some Scene { WindowGroup { - ContentView() + RootView() } .modelContainer(sharedModelContainer) } diff --git a/体己/ContentView.swift b/体己/ContentView.swift deleted file mode 100644 index 5e6fa60..0000000 --- a/体己/ContentView.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// ContentView.swift -// 体己 -// -// Created by Tim on 2026/5/25. -// - -import SwiftUI -import SwiftData - -struct ContentView: View { - @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] - - var body: some View { - NavigationSplitView { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") - } label: { - Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) - } - } - .onDelete(perform: deleteItems) - } -#if os(macOS) - .navigationSplitViewColumnWidth(min: 180, ideal: 200) -#endif - .toolbar { -#if os(iOS) - ToolbarItem(placement: .navigationBarTrailing) { - EditButton() - } -#endif - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") - } - } - } - } detail: { - Text("Select an item") - } - } - - private func addItem() { - withAnimation { - let newItem = Item(timestamp: Date()) - modelContext.insert(newItem) - } - } - - private func deleteItems(offsets: IndexSet) { - withAnimation { - for index in offsets { - modelContext.delete(items[index]) - } - } - } -} - -#Preview { - ContentView() - .modelContainer(for: Item.self, inMemory: true) -} diff --git a/体己/DesignSystem/Components.swift b/体己/DesignSystem/Components.swift new file mode 100644 index 0000000..fa1604b --- /dev/null +++ b/体己/DesignSystem/Components.swift @@ -0,0 +1,146 @@ +import SwiftUI + +struct TjLockChip: View { + var body: some View { + HStack(spacing: 4) { + Image(systemName: "lock.fill") + .font(.system(size: 9, weight: .semibold)) + Text("本地加密") + .font(.system(size: 10)) + .tracking(0.5) + } + .foregroundStyle(Tj.Palette.paper) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(Capsule().fill(Tj.Palette.ink)) + } +} + +enum TjBadgeStyle { + case brick, amber, leaf, ink, neutral + + var bg: Color { + switch self { + case .brick: return Tj.Palette.brickSoft + case .amber: return Color(red: 0.957, green: 0.890, blue: 0.749) + case .leaf: return Tj.Palette.leafSoft + case .ink: return Tj.Palette.ink + case .neutral: return Tj.Palette.sand2 + } + } + var fg: Color { + switch self { + case .brick: return Tj.Palette.brick + case .amber: return Tj.Palette.amber + case .leaf: return Tj.Palette.leaf + case .ink: return Tj.Palette.paper + case .neutral: return Tj.Palette.text2 + } + } +} + +struct TjBadge: View { + let text: String + var style: TjBadgeStyle = .neutral + var body: some View { + Text(text) + .font(.system(size: 10, weight: .semibold)) + .tracking(0.3) + .foregroundStyle(style.fg) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(Capsule().fill(style.bg)) + .lineLimit(1) + } +} + +struct TjPlaceholder: View { + let label: String + var dark: Bool = false + var radius: CGFloat = Tj.Radius.sm + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: radius, style: .continuous) + .fill(dark ? Color(red: 0.110, green: 0.122, blue: 0.110) : Tj.Palette.sand2) + DiagonalStripes(spacing: 7, color: dark ? Color.white.opacity(0.04) : Color.black.opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) + Text(label) + .font(.system(size: 11, design: .monospaced)) + .tracking(0.5) + .foregroundStyle(dark ? Color.white.opacity(0.5) : Tj.Palette.text3) + .multilineTextAlignment(.center) + .padding(8) + } + } +} + +private struct DiagonalStripes: View { + let spacing: CGFloat + let color: Color + + var body: some View { + Canvas { ctx, size in + let step = spacing + let count = Int((size.width + size.height) / step) + 4 + for i in -2.. some View { + configuration.label + .font(.system(size: fontSize, weight: .semibold)) + .tracking(1) + .foregroundStyle(Tj.Palette.paper) + .padding(.horizontal, horizontalPadding) + .frame(height: height) + .background(Capsule().fill(Tj.Palette.ink)) + .opacity(configuration.isPressed ? 0.85 : 1) + } +} + +struct TjGhostButton: ButtonStyle { + var height: CGFloat = 48 + var fontSize: CGFloat = 15 + var horizontalPadding: CGFloat = 22 + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: fontSize, weight: .semibold)) + .tracking(1) + .foregroundStyle(Tj.Palette.ink) + .padding(.horizontal, horizontalPadding) + .frame(height: height) + .background( + Capsule().strokeBorder(Tj.Palette.ink, lineWidth: 1) + ) + .opacity(configuration.isPressed ? 0.7 : 1) + } +} + +struct TjDashedDivider: View { + var body: some View { + Rectangle() + .fill(Tj.Palette.line) + .frame(height: 1) + .mask( + HStack(spacing: 4) { + ForEach(0..<200, id: \.self) { _ in + Rectangle().frame(width: 4, height: 1) + } + } + ) + } +} diff --git a/体己/DesignSystem/Tokens.swift b/体己/DesignSystem/Tokens.swift new file mode 100644 index 0000000..bd46837 --- /dev/null +++ b/体己/DesignSystem/Tokens.swift @@ -0,0 +1,62 @@ +import SwiftUI + +enum Tj { + enum Palette { + static let ink = Color(red: 0.165, green: 0.153, blue: 0.137) + static let ink2 = Color(red: 0.286, green: 0.275, blue: 0.251) + static let inkSoft = Color(red: 0.459, green: 0.447, blue: 0.424) + static let sand = Color(red: 0.976, green: 0.969, blue: 0.949) + static let sand2 = Color(red: 0.929, green: 0.918, blue: 0.886) + static let sand3 = Color(red: 0.878, green: 0.859, blue: 0.816) + static let paper = Color(red: 0.992, green: 0.988, blue: 0.973) + static let line = Color(red: 0.875, green: 0.863, blue: 0.831) + static let lineSoft = Color(red: 0.925, green: 0.918, blue: 0.890) + static let text = Color(red: 0.149, green: 0.137, blue: 0.118) + static let text2 = Color(red: 0.420, green: 0.408, blue: 0.384) + static let text3 = Color(red: 0.616, green: 0.604, blue: 0.580) + static let brick = Color(red: 0.886, green: 0.388, blue: 0.314) + static let brickSoft = Color(red: 0.976, green: 0.863, blue: 0.824) + static let amber = Color(red: 0.871, green: 0.627, blue: 0.314) + static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518) + static let leafSoft = Color(red: 0.867, green: 0.910, blue: 0.941) + static let darkBg = Color(red: 0.051, green: 0.063, blue: 0.059) + } + + enum Radius { + static let xs: CGFloat = 8 + static let sm: CGFloat = 14 + static let md: CGFloat = 20 + static let lg: CGFloat = 28 + static let xl: CGFloat = 36 + static let pill: CGFloat = 999 + } + + enum Shadow { + static func card() -> some View { + Color.clear + } + } +} + +extension Font { + static func tjTitle(_ size: CGFloat = 30) -> Font { .system(size: size, weight: .bold, design: .default) } + static func tjH2(_ size: CGFloat = 18) -> Font { .system(size: size, weight: .bold, design: .default) } + static func tjMono(_ size: CGFloat = 11) -> Font { .system(size: size, weight: .regular, design: .monospaced) } + static func tjSerifBody(_ size: CGFloat = 17) -> Font { .system(size: size, weight: .regular, design: .default) } +} + +extension View { + func tjCard(bordered: Bool = false, radius: CGFloat = Tj.Radius.md) -> some View { + self + .background( + RoundedRectangle(cornerRadius: radius, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: radius, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: bordered ? 1 : 0) + ) + .shadow(color: bordered ? .clear : Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.05), + radius: 2, x: 0, y: 1) + } +} diff --git a/体己/Features/Archive/ArchiveFlow.swift b/体己/Features/Archive/ArchiveFlow.swift new file mode 100644 index 0000000..0573a02 --- /dev/null +++ b/体己/Features/Archive/ArchiveFlow.swift @@ -0,0 +1,61 @@ +import SwiftUI + +private enum ArchiveStep: Hashable { + case guide + case scan + case meta + case progress + case result +} + +struct ArchiveFlow: View { + var onClose: () -> Void + + @State private var step: ArchiveStep = .guide + @State private var capturedPages: Int = 1 + @State private var totalPages: Int = 3 + + var body: some View { + ZStack { + switch step { + case .guide: + B1GuideView( + onSingle: { withAnimation { totalPages = 1; step = .scan } }, + onMulti: { withAnimation { totalPages = 3; step = .scan } }, + onSkip: onClose + ) + .transition(.opacity) + + case .scan: + B2ScanView( + onShoot: { capturedPages = min(capturedPages + 1, totalPages) }, + onDone: { withAnimation { step = .meta } }, + onClose: onClose, + page: capturedPages, + total: totalPages + ) + .transition(.opacity) + + case .meta: + B3MetaView( + onAnalyze: { withAnimation { step = .progress } }, + onBack: { withAnimation { step = .scan } } + ) + .transition(.opacity) + + case .progress: + B4ProgressView(onComplete: { + withAnimation { step = .result } + }) + .transition(.opacity) + + case .result: + B5ResultView( + onSave: onClose, + onBack: { withAnimation { step = .meta } } + ) + .transition(.opacity) + } + } + } +} diff --git a/体己/Features/Archive/B1GuideView.swift b/体己/Features/Archive/B1GuideView.swift new file mode 100644 index 0000000..f1a54cb --- /dev/null +++ b/体己/Features/Archive/B1GuideView.swift @@ -0,0 +1,131 @@ +import SwiftUI + +struct B1GuideView: View { + var onSingle: () -> Void + var onMulti: () -> Void + var onSkip: () -> Void + + var body: some View { + VStack(spacing: 0) { + HStack { + Button(action: onSkip) { + Image(systemName: "xmark") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .frame(width: 36, height: 36) + } + Spacer() + Button(action: onSkip) { + Text("跳过") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + .padding(8) + } + } + .padding(.horizontal, 12) + + VStack(alignment: .leading, spacing: 0) { + ZStack { + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(Tj.Palette.ink) + Image(systemName: "doc.text.fill") + .font(.system(size: 26, weight: .medium)) + .foregroundStyle(Tj.Palette.paper) + } + .frame(width: 60, height: 60) + .padding(.bottom, 18) + + Text("归档一份\n关键报告") + .font(.system(size: 30, weight: .bold)) + .lineSpacing(6) + .foregroundStyle(Tj.Palette.text) + .padding(.bottom, 12) + + Text("推荐拍清晰的\(Text("整张图").underline()),多页报告可一次完成扫描。原图与解读全部本地加密保存,永不上传。") + .font(.system(size: 13)) + .foregroundStyle(Tj.Palette.text2) + .lineSpacing(6) + .padding(.bottom, 26) + + VStack(spacing: 12) { + OptCard(title: "单张报告", sub: "一张图,几秒搞定", hint: "化验单 · 处方", badge: nil, action: onSingle) + OptCard(title: "多页报告", sub: "像扫描文档一样翻页拍摄", hint: "体检报告 · 影像报告", badge: "推荐", action: onMulti) + } + + Spacer(minLength: 18) + + HStack(alignment: .top, spacing: 10) { + Image(systemName: "lock.fill") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text2) + .padding(.top, 2) + Text("所有照片以 AES 加密存于本机沙盒。Tiji 服务端无法访问。") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text2) + .lineSpacing(4) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.sand2) + ) + } + .padding(.horizontal, 24) + .padding(.top, 20) + .padding(.bottom, 20) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + } +} + +private struct OptCard: View { + let title: String + let sub: String + let hint: String + let badge: String? + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.sand2) + Image(systemName: "doc.text") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(Tj.Palette.ink) + } + .frame(width: 44, height: 44) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 8) { + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + if let badge { + TjBadge(text: badge, style: .ink) + } + } + Text("\(sub) · \(hint)") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(16) + .tjCard(bordered: true) + } + .buttonStyle(.plain) + } +} + +#Preview { + B1GuideView( + onSingle: { print("单张报告") }, + onMulti: { print("多页报告") }, + onSkip: { print("跳过") } + ) +} diff --git a/体己/Features/Archive/B2ScanView.swift b/体己/Features/Archive/B2ScanView.swift new file mode 100644 index 0000000..6bc478c --- /dev/null +++ b/体己/Features/Archive/B2ScanView.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct B2ScanView: View { + var onShoot: () -> Void + var onDone: () -> Void + var onClose: () -> Void + var page: Int = 2 + var total: Int = 3 + + var body: some View { + ZStack { + Color(red: 0.04, green: 0.047, blue: 0.04).ignoresSafeArea() + + mockPaper + + DetectedEdge() + .stroke(Color(red: 0.95, green: 0.78, blue: 0.45), + style: StrokeStyle(lineWidth: 2, dash: [6, 4])) + .opacity(0.95) + .padding(.horizontal, 30) + .padding(.top, 140) + .padding(.bottom, 200) + .allowsHitTesting(false) + + VStack(spacing: 0) { + topBar + Spacer() + detectedBadge + Spacer() + thumbnails + bottomControls + } + } + .preferredColorScheme(.dark) + } + + private var mockPaper: some View { + VStack(spacing: 2) { + Text("体 检 报 告 (第 \(page) 页)") + .font(.system(size: 12, weight: .bold)) + .padding(.bottom, 4) + ForEach(reportRows, id: \.0) { row in + HStack { + Text(row.0).frame(maxWidth: .infinity, alignment: .leading) + Text(row.1) + Text(row.2).foregroundStyle(Tj.Palette.text3) + } + .font(.system(size: 9, design: .monospaced)) + } + } + .padding(16) + .foregroundStyle(Tj.Palette.text) + .frame(maxWidth: .infinity) + .background(Color(red: 0.97, green: 0.95, blue: 0.89).opacity(0.95)) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + .rotation3DEffect(.degrees(8), axis: (x: 1, y: 0, z: 0)) + .rotationEffect(.degrees(-1)) + .shadow(color: .black.opacity(0.6), radius: 20, x: 0, y: 12) + .padding(.horizontal, 40) + .padding(.top, 160) + .padding(.bottom, 220) + } + + private var reportRows: [(String, String, String)] { + [ + ("总胆固醇", "5.42", "3.10–5.18"), + ("甘油三酯", "1.78", "0.45–1.70"), + ("低密度脂蛋白", "3.84↑", "<3.40"), + ("高密度脂蛋白", "1.21", ">1.04"), + ("载脂蛋白 A1", "1.42", "1.00–1.60"), + ("载脂蛋白 B", "1.04", "0.55–1.05"), + ("谷丙转氨酶", "28", "9–50"), + ("谷草转氨酶", "24", "15–40"), + ("空腹血糖", "5.4", "3.9–6.1"), + ("糖化血红蛋白", "5.7", "4.0–6.0"), + ] + } + + private var topBar: some View { + HStack { + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Color.white) + .frame(width: 36, height: 36) + } + Spacer() + HStack(spacing: 4) { + Text("\(page)").font(.system(size: 12, design: .monospaced)) + Text(" / \(total) · 像扫描文档一样对准") + .font(.system(size: 12)) + } + .foregroundStyle(Color.white) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Capsule().fill(Color(red: 0.08, green: 0.11, blue: 0.094).opacity(0.7))) + Spacer() + Color.clear.frame(width: 36, height: 36) + } + .padding(.horizontal, 6) + .padding(.top, 50) + } + + private var detectedBadge: some View { + Text("已识别边框 · 将自动透视校正") + .font(.system(size: 10, weight: .semibold)) + .tracking(0.4) + .foregroundStyle(Color(red: 0.10, green: 0.115, blue: 0.094)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Capsule().fill(Color(red: 0.95, green: 0.78, blue: 0.45))) + .padding(.top, 140) + } + + private var thumbnails: some View { + HStack { + PageThumbStack(index: 1) + Spacer() + Text("已拍 1 页") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Color.white.opacity(0.7)) + } + .padding(.horizontal, 18) + .padding(.bottom, 24) + } + + private var bottomControls: some View { + HStack { + Color.clear.frame(width: 60, height: 60) + Spacer() + Button(action: onShoot) { + ZStack { + Circle().fill(Tj.Palette.paper) + Circle().strokeBorder(Color.white.opacity(0.4), lineWidth: 4) + } + .frame(width: 72, height: 72) + } + .buttonStyle(.plain) + Spacer() + Button(action: onDone) { + Text("完成") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Tj.Palette.paper) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Capsule().fill(Color.white.opacity(0.1))) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 32) + .padding(.bottom, 40) + } +} + +private struct DetectedEdge: Shape { + func path(in rect: CGRect) -> Path { + var p = Path() + let w = rect.width + let h = rect.height + p.move(to: CGPoint(x: w * 0.04, y: h * 0.05)) + p.addLine(to: CGPoint(x: w * 0.92, y: h * 0.02)) + p.addLine(to: CGPoint(x: w * 0.96, y: h * 0.96)) + p.addLine(to: CGPoint(x: 0, y: h * 1.0)) + p.closeSubpath() + return p + } +} + +struct PageThumbStack: View { + let index: Int + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.7)) + .frame(width: 56, height: 76) + .rotationEffect(.degrees(2)) + .offset(x: 4, y: 4) + .shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2) + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color(red: 0.97, green: 0.95, blue: 0.89).opacity(0.85)) + .frame(width: 56, height: 76) + .rotationEffect(.degrees(-1)) + .offset(x: 2, y: 2) + .shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2) + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Tj.Palette.paper) + .frame(width: 56, height: 76) + .overlay( + Text("p.\(index)") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + ) + .shadow(color: .black.opacity(0.4), radius: 4, x: 0, y: 2) + } + .frame(width: 64, height: 84, alignment: .topLeading) + } +} diff --git a/体己/Features/Archive/B3MetaView.swift b/体己/Features/Archive/B3MetaView.swift new file mode 100644 index 0000000..86de467 --- /dev/null +++ b/体己/Features/Archive/B3MetaView.swift @@ -0,0 +1,137 @@ +import SwiftUI + +struct B3MetaView: View { + var onAnalyze: () -> Void + var onBack: () -> Void + + @State private var selectedType = 0 + private let types = ["体检报告", "化验单", "影像报告", "处方", "其他"] + + var body: some View { + VStack(spacing: 0) { + header + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + Text("报告类型") + .font(.system(size: 11)) + .tracking(0.5) + .foregroundStyle(Tj.Palette.text3) + .padding(.bottom, 8) + + typeChips.padding(.bottom, 20) + + FormRow(label: "报告日期", value: "2026 / 05 / 25", subtle: false) + FormRow(label: "出具机构", value: "协和医院体检中心", subtle: true) + FormRow(label: "备注", value: "春季年度体检", subtle: true) + + Text("已拍页面(3 页)") + .font(.system(size: 11)) + .tracking(0.5) + .foregroundStyle(Tj.Palette.text3) + .padding(.top, 20) + .padding(.bottom, 10) + + HStack(spacing: 10) { + ForEach(1...3, id: \.self) { n in + PageCard(index: n) + } + } + } + .padding(.horizontal, 18) + .padding(.bottom, 18) + } + + VStack(spacing: 8) { + Button(action: onAnalyze) { + Text("开始 AI 解读").frame(maxWidth: .infinity) + } + .buttonStyle(TjPrimaryButton()) + + Text("预计耗时 5–8 秒 · 端侧 SME2 加速") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(.horizontal, 18) + .padding(.bottom, 14) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + } + + private var header: some View { + HStack(spacing: 6) { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .frame(width: 36, height: 36) + } + Text("归档信息") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Spacer() + } + .padding(.horizontal, 12) + .padding(.top, 4) + .padding(.bottom, 8) + } + + private var typeChips: some View { + let columns = [GridItem(.adaptive(minimum: 60, maximum: 200), spacing: 8)] + return LazyVGrid(columns: columns, alignment: .leading, spacing: 8) { + ForEach(Array(types.enumerated()), id: \.offset) { idx, t in + Button { selectedType = idx } label: { + Text(t) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(idx == selectedType ? Tj.Palette.paper : Tj.Palette.text2) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule().fill(idx == selectedType ? Tj.Palette.ink : Tj.Palette.sand2) + ) + } + .buttonStyle(.plain) + } + } + } +} + +private struct FormRow: View { + let label: String + let value: String + let subtle: Bool + + var body: some View { + HStack { + Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text2) + Spacer() + HStack(spacing: 6) { + Text(value) + .font(.system(size: 13)) + .foregroundStyle(subtle ? Tj.Palette.text3 : Tj.Palette.text) + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Tj.Palette.text3) + } + } + .padding(.vertical, 12) + .overlay(alignment: .top) { + Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) + } + } +} + +private struct PageCard: View { + let index: Int + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.paper) + .shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06), + radius: 2, x: 0, y: 1) + TjPlaceholder(label: "p.\(index)", radius: 4) + .padding(6) + } + .aspectRatio(0.72, contentMode: .fit) + } +} diff --git a/体己/Features/Archive/B4ProgressView.swift b/体己/Features/Archive/B4ProgressView.swift new file mode 100644 index 0000000..df4ab3f --- /dev/null +++ b/体己/Features/Archive/B4ProgressView.swift @@ -0,0 +1,293 @@ +import SwiftUI + +struct B4ProgressView: View { + var onComplete: () -> Void + + @State private var step: Int = 1 + @State private var pulse = false + @State private var glow = false + @State private var rotate: Double = 0 + @State private var elapsed: Double = 0.2 + + private let lineLabels = [ + "正在本地识别第 1 / 3 页…", + "正在本地识别第 2 / 3 页…", + "正在本地识别第 3 / 3 页…", + "提取指标 · 共 28 项", + "生成整体摘要…", + ] + + var body: some View { + ZStack { + backgroundGradient.ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + chip.padding(.bottom, 36) + + Text("本地 AI · 正在解读") + .font(.system(size: 22, weight: .semibold)) + .tracking(1) + .foregroundStyle(Color.white.opacity(0.95)) + .padding(.bottom, 6) + + Text("QWEN2.5-VL · ON-DEVICE · SME2") + .font(.system(size: 11, design: .monospaced)) + .tracking(0.5) + .foregroundStyle(Color.white.opacity(0.55)) + .padding(.bottom, 30) + + lineList + .padding(.horizontal, 28) + + speedBadge.padding(.top, 32) + Spacer() + + Text("本地处理中 · 不会上传任何内容") + .font(.system(size: 10, design: .monospaced)) + .tracking(0.5) + .foregroundStyle(Color.white.opacity(0.45)) + .padding(.bottom, 30) + } + .padding(.horizontal, 28) + } + .preferredColorScheme(.dark) + .onAppear { startAnimations() } + } + + private var backgroundGradient: some View { + RadialGradient( + colors: [ + Color(red: 0.22, green: 0.21, blue: 0.18), + Color(red: 0.13, green: 0.12, blue: 0.10), + Color(red: 0.08, green: 0.075, blue: 0.06), + ], + center: .init(x: 0.5, y: 0.3), + startRadius: 60, + endRadius: 700 + ) + } + + private var chip: some View { + ZStack { + Circle() + .fill(Color(red: 0.93, green: 0.75, blue: 0.40).opacity(glow ? 0.18 : 0.0)) + .frame(width: 176, height: 176) + .blur(radius: 30) + + Circle() + .strokeBorder(Color.white.opacity(0.18), + style: StrokeStyle(lineWidth: 1, dash: [4, 4])) + .frame(width: 140, height: 140) + .rotationEffect(.degrees(rotate)) + + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill( + LinearGradient( + colors: [Color(red: 0.36, green: 0.34, blue: 0.30), + Color(red: 0.22, green: 0.21, blue: 0.18)], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(Color.white.opacity(0.10), lineWidth: 1) + ) + .frame(width: 96, height: 96) + .shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 12) + .overlay(ChipGlyph()) + .overlay(alignment: .topTrailing) { + Circle() + .fill(Color(red: 0.95, green: 0.78, blue: 0.40)) + .frame(width: 6, height: 6) + .opacity(pulse ? 1 : 0.35) + .shadow(color: Color(red: 0.95, green: 0.78, blue: 0.40), radius: 6) + .padding(10) + } + .scaleEffect(pulse ? 1.06 : 1.0) + .opacity(pulse ? 0.92 : 1.0) + } + } + + private var lineList: some View { + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(lineLabels.enumerated()), id: \.offset) { idx, label in + LineRow( + text: label, + done: step > idx + 1, + active: step == idx + 1, + isLast: idx == lineLabels.count - 1 + ) + .opacity(step >= idx + 1 ? 1 : 0) + .offset(y: step >= idx + 1 ? 0 : 6) + .animation(.easeOut(duration: 0.4).delay(Double(idx) * 0.05), value: step) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var speedBadge: some View { + Text(String(format: "已处理 %.1fs · 比云端快 4.2×", elapsed)) + .font(.system(size: 10, design: .monospaced)) + .tracking(0.6) + .foregroundStyle(Color.white.opacity(0.75)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(Color.white.opacity(0.08))) + } + + private func startAnimations() { + withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) { + pulse.toggle() + } + withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) { + glow.toggle() + } + withAnimation(.linear(duration: 14).repeatForever(autoreverses: false)) { + rotate = 360 + } + + Task { + for _ in 0.. Void + var onBack: () -> Void + + @State private var expandedIndex: Int? = 0 + @State private var normalsExpanded = false + + let abnormal: [B5IndicatorData] = [ + .init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high, + note: "超过参考上限 0.44。建议关注饮食结构,3 个月内复查。"), + .init(name: "甘油三酯 TG", value: "1.78", unit: "mmol/L", range: "0.45–1.70", status: .high, note: nil), + .init(name: "尿酸 UA", value: "428", unit: "μmol/L", range: "150–420", status: .high, note: nil), + .init(name: "维生素 D", value: "18", unit: "ng/mL", range: "30–100", status: .low, note: nil), + ] + let normalCount = 24 + + var body: some View { + VStack(spacing: 0) { + header + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + reportMeta.padding(.bottom, 16) + summaryCard.padding(.bottom, 18) + SectionLabel("异常项", count: abnormal.count, accent: .brick) + .padding(.bottom, 10) + VStack(spacing: 8) { + ForEach(Array(abnormal.enumerated()), id: \.offset) { idx, it in + IndicatorRow(item: it, expanded: expandedIndex == idx) { + withAnimation { expandedIndex = (expandedIndex == idx) ? nil : idx } + } + } + } + .padding(.bottom, 18) + + SectionLabel("正常项", count: normalCount, accent: .leaf) + .padding(.bottom, 10) + normalCollapsed + } + .padding(.horizontal, 18) + .padding(.bottom, 16) + } + + HStack(spacing: 10) { + Button(action: onSave) { + Text("保存归档").frame(maxWidth: .infinity) + } + .buttonStyle(TjPrimaryButton()) + + Button { } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 16, weight: .semibold)) + } + .buttonStyle(TjGhostButton(horizontalPadding: 16)) + } + .padding(.horizontal, 18) + .padding(.bottom, 14) + .padding(.top, 10) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + } + + private var header: some View { + HStack(spacing: 6) { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .frame(width: 36, height: 36) + } + Spacer() + Button { } label: { + HStack(spacing: 4) { + Image(systemName: "photo") + Text("查看原图") + } + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + .padding(8) + } + } + .padding(.horizontal, 12) + .padding(.top, 4) + } + + private var reportMeta: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + TjBadge(text: "体检报告", style: .ink) + Text("3 页") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + Spacer() + TjLockChip() + } + Text("2026 春季年度体检") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(Tj.Palette.text) + Text("2026 / 05 / 25 · 协和医院体检中心") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + } + } + + private var summaryCard: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + Text("整体摘记") + .font(.system(size: 12, weight: .semibold)) + .tracking(0.3) + .foregroundStyle(Tj.Palette.brick) + .fixedSize() + Rectangle().fill(Tj.Palette.line).frame(height: 1) + Text("本机摘要") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + .fixedSize() + } + .padding(.bottom, 12) + + HStack(spacing: 14) { + Stat(n: "28", label: "总项") + Stat(n: "3", label: "偏高", tone: .brick) + Stat(n: "1", label: "偏低", tone: .amber) + Stat(n: "24", label: "正常", tone: .leaf) + } + .padding(.bottom, 14) + + Text("本次共检测 28 项,\(Text("3 项偏高").fontWeight(.semibold).underline(color: Tj.Palette.brick))(血脂相关 2 项 + 尿酸)、\(Text("1 项偏低").fontWeight(.semibold).underline(color: Tj.Palette.amber))(维生素 D)。整体趋势提示代谢风险有所抬升,建议优化饮食并复查血脂。") + .font(.system(size: 14)) + .foregroundStyle(Tj.Palette.text) + .lineSpacing(6) + .padding(.bottom, 12) + + TjDashedDivider().padding(.bottom, 10) + + Text("仅供参考,不构成医疗建议") + .font(.system(size: 11)) + .italic() + .foregroundStyle(Tj.Palette.text3) + } + .padding(.leading, 20) + .padding(.trailing, 20) + .padding(.vertical, 20) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + Tj.Palette.paper + .overlay(alignment: .leading) { + Tj.Palette.brick.frame(width: 3) + } + ) + .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) + .shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06), radius: 0, x: 0, y: 1) + } + + private var normalCollapsed: some View { + Button { withAnimation { normalsExpanded.toggle() } } label: { + HStack(spacing: 10) { + TjBadge(text: "\(normalCount)", style: .leaf) + Text("谷丙转氨酶、空腹血糖、糖化血红蛋白…") + .font(.system(size: 13)) + .foregroundStyle(Tj.Palette.text2) + .lineLimit(1) + Spacer() + Image(systemName: normalsExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .tjCard(bordered: true) + } + .buttonStyle(.plain) + } +} + +private struct Stat: View { + let n: String + let label: String + var tone: Tone = .ink + + enum Tone { case ink, brick, amber, leaf } + + var color: Color { + switch tone { + case .ink: return Tj.Palette.text + case .brick: return Tj.Palette.brick + case .amber: return Color(red: 0.59, green: 0.45, blue: 0.27) + case .leaf: return Tj.Palette.leaf + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(n) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(color) + Text(label) + .font(.system(size: 10)) + .tracking(0.5) + .foregroundStyle(Tj.Palette.text3) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct SectionLabel: View { + let title: String + let count: Int + let accent: AccentKind + + enum AccentKind { case brick, leaf } + + init(_ title: String, count: Int, accent: AccentKind) { + self.title = title + self.count = count + self.accent = accent + } + + var body: some View { + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(accent == .brick ? Tj.Palette.brick : Tj.Palette.leaf) + .frame(width: 4, height: 14) + Text(title).font(.system(size: 13, weight: .semibold)).foregroundStyle(Tj.Palette.text) + Text("· \(count)").font(.system(size: 11)).foregroundStyle(Tj.Palette.text3) + } + } +} + +private struct IndicatorRow: View { + let item: B5IndicatorData + let expanded: Bool + let onTap: () -> Void + + var statusBadge: TjBadgeStyle { + switch item.status { + case .high: return .brick + case .low: return .amber + case .normal: return .leaf + } + } + var statusWord: String { + switch item.status { + case .high: return "偏高" + case .low: return "偏低" + case .normal: return "正常" + } + } + var valueColor: Color { + switch item.status { + case .high: return Tj.Palette.brick + case .low: return Color(red: 0.55, green: 0.45, blue: 0.32) + case .normal: return Tj.Palette.text + } + } + + var body: some View { + Button(action: onTap) { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(item.name) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .lineLimit(1) + TjBadge(text: statusWord, style: statusBadge) + } + Text("范围 \(item.range) \(item.unit)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 2) { + Text(item.value) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(valueColor) + Text(item.unit) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + } + } + + if expanded, let note = item.note { + TjDashedDivider() + Text(note) + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text2) + .lineSpacing(5) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder( + item.status != .normal + ? Color(red: 0.78, green: 0.68, blue: 0.48).opacity(0.5) + : Tj.Palette.lineSoft, + lineWidth: 1 + ) + ) + } + .buttonStyle(.plain) + } +} diff --git a/体己/Features/Home/HomeView.swift b/体己/Features/Home/HomeView.swift new file mode 100644 index 0000000..6860007 --- /dev/null +++ b/体己/Features/Home/HomeView.swift @@ -0,0 +1,155 @@ +import SwiftUI + +struct HomeView: View { + var onTapArchive: () -> Void = {} + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + greeting + .padding(.top, 4) + .padding(.bottom, 18) + + todaySummaryCard + .padding(.bottom, 18) + + recentSection + .padding(.bottom, 22) + + archiveSection + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + } + + private var greeting: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text("5 月 25 日 · 周一") + .font(.system(size: 12)) + .tracking(1) + .foregroundStyle(Tj.Palette.text3) + Text("早安,林意") + .font(.tjTitle()) + .foregroundStyle(Tj.Palette.text) + } + Spacer() + TjLockChip() + .padding(.top, 4) + } + } + + private var todaySummaryCard: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + Text("今日 · 摘记") + .font(.system(size: 12, weight: .semibold)) + .tracking(0.3) + .foregroundStyle(Tj.Palette.brick) + .fixedSize() + Rectangle() + .fill(Tj.Palette.line) + .frame(height: 1) + Text("本机摘要") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + .fixedSize() + } + .padding(.bottom, 10) + + Text("上次体检后,\(Text("低密度脂蛋白").underline(color: Tj.Palette.brick).foregroundColor(Tj.Palette.text))持续偏高已 3 个月。建议本周记录一次空腹血脂。") + .font(.tjSerifBody()) + .foregroundStyle(Tj.Palette.text) + .lineSpacing(6) + .padding(.bottom, 14) + + HStack(spacing: 14) { + Button("记录今日") {} + .buttonStyle(TjPrimaryButton(height: 34, fontSize: 13, horizontalPadding: 14)) + Button("查看趋势") {} + .buttonStyle(TjGhostButton(height: 34, fontSize: 13, horizontalPadding: 14)) + } + } + .padding(.leading, 20) + .padding(.trailing, 18) + .padding(.vertical, 18) + .background( + Tj.Palette.paper + .overlay(alignment: .leading) { + Tj.Palette.brick.frame(width: 3) + } + ) + .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) + .shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06), radius: 0, x: 0, y: 1) + } + + private var recentSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .lastTextBaseline) { + Text("最近记录").font(.tjH2()).foregroundStyle(Tj.Palette.text) + Spacer() + Text("全部 ›") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + } + + VStack(spacing: 10) { + RecentItemRow( + date: "昨天 18:20", + type: "异常项快拍", + name: "低密度脂蛋白 LDL-C", + value: "3.84 mmol/L", + status: .high + ) + RecentItemRow( + date: "5 月 23 日", + type: "关键报告归档", + name: "春季年度体检 · 共 3 页", + value: "3 项偏高", + status: .archive + ) + RecentItemRow( + date: "5 月 22 日", + type: "文字日记", + name: "头痛 · 上午 10 点起,午后缓解", + value: nil, + status: .diary + ) + } + } + } + + private var archiveSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text) + + Button(action: onTapArchive) { + HStack(spacing: 14) { + TjPlaceholder(label: "档案 · 12") + .frame(width: 56, height: 56) + VStack(alignment: .leading, spacing: 2) { + Text("我的报告档案") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text("12 份 · 218 项指标 · 端侧加密") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(14) + .tjCard(bordered: true) + } + .buttonStyle(.plain) + } + } +} + +#Preview { + HomeView() +} diff --git a/体己/Features/Home/RecentItemRow.swift b/体己/Features/Home/RecentItemRow.swift new file mode 100644 index 0000000..2a4e0e2 --- /dev/null +++ b/体己/Features/Home/RecentItemRow.swift @@ -0,0 +1,59 @@ +import SwiftUI + +enum RecentItemStatus { + case high, archive, diary + + var dotColor: Color { + switch self { + case .high: return Tj.Palette.brick + case .archive: return Tj.Palette.ink2 + case .diary: return Tj.Palette.leaf + } + } + + var valueColor: Color { + switch self { + case .high: return Tj.Palette.brick + default: return Tj.Palette.text2 + } + } +} + +struct RecentItemRow: View { + let date: String + let type: String + let name: String + let value: String? + let status: RecentItemStatus + + var body: some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(status.dotColor) + .frame(width: 6, height: 40) + + VStack(alignment: .leading, spacing: 2) { + Text("\(date) · \(type)") + .font(.system(size: 11)) + .tracking(0.3) + .foregroundStyle(Tj.Palette.text3) + .lineLimit(1) + Text(name) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Tj.Palette.text) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 8) + if let value { + Text(value) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundStyle(status.valueColor) + .lineLimit(1) + .fixedSize() + } + } + .padding(12) + .tjCard(bordered: true) + } +} diff --git a/体己/Features/Me/MeView.swift b/体己/Features/Me/MeView.swift new file mode 100644 index 0000000..8622ccd --- /dev/null +++ b/体己/Features/Me/MeView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct MeView: View { + var body: some View { + VStack(spacing: 12) { + Spacer() + TjPlaceholder(label: "me · 设置 / 模型 / 档案管理\n(尚未实现)") + .frame(width: 280, height: 180) + Text("我的") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text2) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Tj.Palette.sand.ignoresSafeArea()) + } +} + +#Preview { MeView() } diff --git a/体己/Features/Quick/A1ViewfinderView.swift b/体己/Features/Quick/A1ViewfinderView.swift new file mode 100644 index 0000000..df4083f --- /dev/null +++ b/体己/Features/Quick/A1ViewfinderView.swift @@ -0,0 +1,159 @@ +import SwiftUI + +#if canImport(UIKit) +import UIKit +#endif + +struct A1ViewfinderView: View { + var onShoot: () -> Void + var onClose: () -> Void + + @State private var dotPulse = false + + var body: some View { + GeometryReader { geometry in + ZStack { + Color(red: 0.04, green: 0.047, blue: 0.04).ignoresSafeArea() + + mockCameraPreview(screenHeight: geometry.size.height) + + VStack { + HStack { + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Color.white) + .frame(width: 36, height: 36) + } + Spacer() + } + .padding(.horizontal, 6) + .padding(.top, 50) + + topHint + + Spacer() + } + + SmartFramer() + .allowsHitTesting(false) + .ignoresSafeArea() + + identifiedPill + .padding(.top, geometry.size.height * 0.62 - 20) + + VStack { + Spacer() + bottomControls + } + } + } +#if os(iOS) + .statusBarHidden(false) +#endif + .preferredColorScheme(.dark) + } + + private func mockCameraPreview(screenHeight: CGFloat) -> some View { + RadialGradient( + colors: [Color.white.opacity(0.05), Color.clear], + center: .init(x: 0.5, y: 0.3), + startRadius: 20, + endRadius: 400 + ) + .overlay(alignment: .center) { + VStack(alignment: .leading, spacing: 6) { + Text("总胆固醇 TC 5.42 mmol/L").opacity(0.65) + Text("甘油三酯 TG 1.78 mmol/L").opacity(0.65) + Text("低密度脂蛋白 3.84 mmol/L ↑").fontWeight(.semibold).opacity(1) + Text("高密度脂蛋白 1.21 mmol/L").opacity(0.65) + Text("载脂蛋白 A1 1.42 g/L").opacity(0.45) + Text("载脂蛋白 B 1.04 g/L").opacity(0.45) + } + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Tj.Palette.text) + .padding(.vertical, 20) + .padding(.horizontal, 18) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.92)) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + .rotationEffect(.degrees(-1.2)) + .shadow(color: .black.opacity(0.45), radius: 15, x: 0, y: 12) + .padding(.horizontal, 24) + .padding(.vertical, screenHeight * 0.20) + } + } + + private var topHint: some View { + Text("对准异常的那一行就好 · 不用拍整张") + .font(.system(size: 12)) + .tracking(0.5) + .foregroundStyle(Color.white.opacity(0.92)) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(Capsule().fill(Color(red: 0.08, green: 0.11, blue: 0.094).opacity(0.7))) + .padding(.top, 6) + } + + private var identifiedPill: some View { + HStack(spacing: 6) { + Circle() + .fill(Tj.Palette.paper) + .frame(width: 6, height: 6) + .opacity(dotPulse ? 1 : 0.35) + Text("AI 已识别到 1 项指标") + .font(.system(size: 11)) + .tracking(0.5) + } + .foregroundStyle(Tj.Palette.paper) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Capsule().fill(Color(red: 0.37, green: 0.47, blue: 0.31).opacity(0.85))) + .onAppear { + withAnimation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true)) { + dotPulse.toggle() + } + } + } + + private var bottomControls: some View { + HStack { + CircleIconButton(icon: "bolt.fill", size: 44) { } + Spacer() + Button(action: onShoot) { + ZStack { + Circle().fill(Tj.Palette.ink) + Circle().strokeBorder(Tj.Palette.paper, lineWidth: 4) + } + .frame(width: 72, height: 72) + .overlay( + Circle().strokeBorder(Color.white.opacity(0.2), lineWidth: 1) + .frame(width: 76, height: 76) + ) + } + .buttonStyle(.plain) + Spacer() + CircleIconButton(icon: "photo.on.rectangle", size: 44) { } + } + .padding(.horizontal, 32) + .padding(.bottom, 40) + } +} + +private struct CircleIconButton: View { + let icon: String + let size: CGFloat + let action: () -> Void + var body: some View { + Button(action: action) { + ZStack { + Circle().fill(Color.white.opacity(0.12)) + Image(systemName: icon) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(Tj.Palette.paper) + } + .frame(width: size, height: size) + } + .buttonStyle(.plain) + } +} diff --git a/体己/Features/Quick/A2ConfirmView.swift b/体己/Features/Quick/A2ConfirmView.swift new file mode 100644 index 0000000..0f45648 --- /dev/null +++ b/体己/Features/Quick/A2ConfirmView.swift @@ -0,0 +1,180 @@ +import SwiftUI + +struct A2ConfirmView: View { + var onSave: () -> Void + var onNext: () -> Void + var onBack: () -> Void + + @State private var expanded = false + + var body: some View { + VStack(spacing: 0) { + header + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + croppedPhoto.padding(.bottom, 14) + resultCard.padding(.bottom, 16) + actions + } + .padding(.horizontal, 18) + .padding(.bottom, 18) + } + } + .background(Tj.Palette.sand.ignoresSafeArea()) + } + + private var header: some View { + HStack(spacing: 6) { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .frame(width: 36, height: 36) + } + Text("核对识别结果") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Spacer() + Text("识别用时 0.4s · 本地") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(Tj.Palette.sand2)) + } + .padding(.horizontal, 12) + .padding(.top, 4) + .padding(.bottom, 8) + } + + private var croppedPhoto: some View { + ZStack(alignment: .topTrailing) { + Text("低密度脂蛋白 3.84 mmol/L ↑") + .font(.system(size: 13, design: .monospaced)) + .fontWeight(.semibold) + .tracking(0.3) + .foregroundStyle(Tj.Palette.text) + .padding(.vertical, 14) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(red: 0.96, green: 0.93, blue: 0.87).opacity(0.92)) + .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)) + .shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.06), + radius: 2, x: 0, y: 1) + Text("已裁剪") + .font(.system(size: 9)) + .tracking(0.5) + .foregroundStyle(Tj.Palette.text3) + .padding(.top, 8) + .padding(.trailing, 10) + } + } + + private var resultCard: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text("指标名 · 可编辑") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + Text("低密度脂蛋白胆固醇") + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text("LDL-C") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer() + TjBadge(text: "偏高", style: .brick) + } + + HStack(spacing: 12) { + FieldBox(label: "数值") { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("3.84") + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(Tj.Palette.brick) + Text("mmol/L") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + } + } + FieldBox(label: "参考范围") { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("< 3.40") + .font(.system(size: 14, design: .monospaced)) + .foregroundStyle(Tj.Palette.text2) + Text("mmol/L") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + } + } + } + + Button { withAnimation { expanded.toggle() } } label: { + HStack(alignment: .top, spacing: 10) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(Tj.Palette.brick) + .frame(width: 4) + Text(expanded + ? "超过参考上限 0.44,属轻度偏高。建议关注饮食结构(减少动物脂肪摄入),3 个月内复查。若家族有心血管病史,可与医生沟通是否需要药物干预。" + : "超过参考上限 0.44,属轻度偏高。点击展开详细解读 ›") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text2) + .lineSpacing(5) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.sand) + ) + } + .buttonStyle(.plain) + } + .padding(18) + .tjCard() + } + + private var actions: some View { + VStack(spacing: 10) { + Button(action: onSave) { + Text("保存到记录") + .frame(maxWidth: .infinity) + } + .buttonStyle(TjPrimaryButton()) + + Button(action: onNext) { + HStack(spacing: 8) { + Image(systemName: "camera.fill").font(.system(size: 14)) + Text("继续拍下一项") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(TjGhostButton()) + } + } +} + +private struct FieldBox: View { + let label: String + @ViewBuilder var content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.system(size: 10)) + .tracking(0.5) + .foregroundStyle(Tj.Palette.text3) + content + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 10) + .padding(.horizontal, 12) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + ) + } +} diff --git a/体己/Features/Quick/A3BatchView.swift b/体己/Features/Quick/A3BatchView.swift new file mode 100644 index 0000000..96503f8 --- /dev/null +++ b/体己/Features/Quick/A3BatchView.swift @@ -0,0 +1,124 @@ +import SwiftUI + +struct A3BatchItem { + let name: String + let value: String + let unit: String + let range: String + let status: IndicatorStatus +} + +struct A3BatchView: View { + var onAddMore: () -> Void + var onFinish: () -> Void + var onBack: () -> Void + + let items: [A3BatchItem] = [ + .init(name: "低密度脂蛋白胆固醇", value: "3.84", unit: "mmol/L", range: "< 3.40", status: .high), + .init(name: "甘油三酯 TG", value: "1.78", unit: "mmol/L", range: "< 1.70", status: .high), + .init(name: "空腹血糖 GLU", value: "5.4", unit: "mmol/L", range: "3.9–6.1", status: .normal), + ] + + var body: some View { + VStack(spacing: 0) { + header + ScrollView(showsIndicators: false) { + VStack(spacing: 10) { + ForEach(Array(items.enumerated()), id: \.offset) { idx, it in + BatchRow(index: idx + 1, item: it) + } + addRow + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + HStack(spacing: 10) { + Button { + onFinish() + } label: { + Text("全部保存(\(items.count))").frame(maxWidth: .infinity) + } + .buttonStyle(TjPrimaryButton()) + } + .padding(.horizontal, 16) + .padding(.bottom, 14) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + } + + private var header: some View { + HStack(spacing: 6) { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .frame(width: 36, height: 36) + } + VStack(alignment: .leading, spacing: 2) { + Text("本次已记录 \(items.count) 项") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text("核对后一次保存") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer() + Text("· · ·") + .font(.system(size: 14, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + .padding(.trailing, 12) + } + .padding(.horizontal, 12) + .padding(.top, 4) + .padding(.bottom, 12) + } + + private var addRow: some View { + Button(action: onAddMore) { + HStack(spacing: 8) { + Image(systemName: "camera").font(.system(size: 14)) + Text("再拍一项") + .font(.system(size: 13)) + } + .foregroundStyle(Tj.Palette.text3) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(Tj.Palette.line, style: StrokeStyle(lineWidth: 1.5, dash: [4, 4])) + ) + } + .buttonStyle(.plain) + } +} + +private struct BatchRow: View { + let index: Int + let item: A3BatchItem + + var body: some View { + HStack(spacing: 12) { + TjPlaceholder(label: "#\(index)") + .frame(width: 60, height: 44) + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .lineLimit(1) + Text("范围 \(item.range) \(item.unit)") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 2) { + Text(item.value) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(item.status == .high ? Tj.Palette.brick : Tj.Palette.text) + TjBadge(text: item.status == .high ? "偏高" : "正常", + style: item.status == .high ? .brick : .leaf) + } + } + .padding(12) + .tjCard() + } +} diff --git a/体己/Features/Quick/QuickCaptureFlow.swift b/体己/Features/Quick/QuickCaptureFlow.swift new file mode 100644 index 0000000..12ab0ba --- /dev/null +++ b/体己/Features/Quick/QuickCaptureFlow.swift @@ -0,0 +1,60 @@ +import SwiftUI + +private enum QuickStep: Hashable { + case viewfinder + case confirm + case batch +} + +struct QuickCaptureFlow: View { + var onClose: () -> Void + + @State private var step: QuickStep = .viewfinder + @State private var snapCount = 0 + + var body: some View { + ZStack { + switch step { + case .viewfinder: + A1ViewfinderView( + onShoot: { + snapCount += 1 + withAnimation(.easeInOut(duration: 0.25)) { step = .confirm } + }, + onClose: onClose + ) + .transition(.opacity) + + case .confirm: + A2ConfirmView( + onSave: { + if snapCount >= 2 { + withAnimation { step = .batch } + } else { + onClose() + } + }, + onNext: { + withAnimation { step = .viewfinder } + }, + onBack: { + withAnimation { step = .viewfinder } + } + ) + .transition(.opacity) + + case .batch: + A3BatchView( + onAddMore: { + withAnimation { step = .viewfinder } + }, + onFinish: onClose, + onBack: { + withAnimation { step = .confirm } + } + ) + .transition(.opacity) + } + } + } +} diff --git a/体己/Features/Quick/SmartFramer.swift b/体己/Features/Quick/SmartFramer.swift new file mode 100644 index 0000000..dd7c169 --- /dev/null +++ b/体己/Features/Quick/SmartFramer.swift @@ -0,0 +1,100 @@ +import SwiftUI + +struct SmartFramer: View { + var radius: CGFloat = 10 + var height: CGFloat = 56 + @State private var breathing = false + + var body: some View { + GeometryReader { geo in + ZStack { + Color.black.opacity(0.32) + .mask( + Rectangle() + .overlay( + RoundedRectangle(cornerRadius: radius, style: .continuous) + .frame(height: height) + .padding(.horizontal, geo.size.width * 0.08) + .blendMode(.destinationOut) + ) + .compositingGroup() + ) + + RoundedRectangle(cornerRadius: radius + 4, style: .continuous) + .stroke(Color(red: 0.95, green: 0.78, blue: 0.45), lineWidth: 1.5) + .shadow(color: Color(red: 0.95, green: 0.78, blue: 0.45).opacity(0.5), radius: 8) + .frame(height: height + 8) + .padding(.horizontal, geo.size.width * 0.08 - 4) + .opacity(breathing ? 1 : 0.35) + + cornerMarks(in: geo.size) + } + .frame(width: geo.size.width, height: geo.size.height) + .onAppear { + withAnimation(.easeInOut(duration: 2.2).repeatForever(autoreverses: true)) { + breathing.toggle() + } + } + } + } + + private func cornerMarks(in size: CGSize) -> some View { + let inset = size.width * 0.08 + return ZStack { + ForEach(Corner.allCases, id: \.self) { corner in + CornerMark(corner: corner, radius: radius) + .frame(width: 18, height: 18) + .position(corner.position(in: size, inset: inset, frameHeight: height)) + } + } + } +} + +private enum Corner: CaseIterable { + case tl, tr, bl, br + func position(in size: CGSize, inset: CGFloat, frameHeight: CGFloat) -> CGPoint { + let centerY = size.height / 2 + let top = centerY - frameHeight / 2 + let bottom = centerY + frameHeight / 2 + switch self { + case .tl: return CGPoint(x: inset, y: top) + case .tr: return CGPoint(x: size.width - inset, y: top) + case .bl: return CGPoint(x: inset, y: bottom) + case .br: return CGPoint(x: size.width - inset, y: bottom) + } + } +} + +private struct CornerMark: View { + let corner: Corner + let radius: CGFloat + + var body: some View { + Path { p in + let r = min(radius, 8) + switch corner { + case .tl: + p.move(to: CGPoint(x: 0, y: 18)) + p.addLine(to: CGPoint(x: 0, y: r)) + p.addQuadCurve(to: CGPoint(x: r, y: 0), control: CGPoint(x: 0, y: 0)) + p.addLine(to: CGPoint(x: 18, y: 0)) + case .tr: + p.move(to: CGPoint(x: 0, y: 0)) + p.addLine(to: CGPoint(x: 18 - r, y: 0)) + p.addQuadCurve(to: CGPoint(x: 18, y: r), control: CGPoint(x: 18, y: 0)) + p.addLine(to: CGPoint(x: 18, y: 18)) + case .bl: + p.move(to: CGPoint(x: 0, y: 0)) + p.addLine(to: CGPoint(x: 0, y: 18 - r)) + p.addQuadCurve(to: CGPoint(x: r, y: 18), control: CGPoint(x: 0, y: 18)) + p.addLine(to: CGPoint(x: 18, y: 18)) + case .br: + p.move(to: CGPoint(x: 0, y: 18)) + p.addLine(to: CGPoint(x: 18 - r, y: 18)) + p.addQuadCurve(to: CGPoint(x: 18, y: 18 - r), control: CGPoint(x: 18, y: 18)) + p.addLine(to: CGPoint(x: 18, y: 0)) + } + } + .stroke(Tj.Palette.paper, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + } +} diff --git a/体己/Features/Record/RecordSheet.swift b/体己/Features/Record/RecordSheet.swift new file mode 100644 index 0000000..ce7a0a6 --- /dev/null +++ b/体己/Features/Record/RecordSheet.swift @@ -0,0 +1,106 @@ +import SwiftUI + +enum RecordKind: String, Identifiable, CaseIterable { + case quick, archive, diary + var id: String { rawValue } + + var title: String { + switch self { + case .quick: return "异常项快拍" + case .archive: return "关键报告归档" + case .diary: return "文字日记" + } + } + var subtitle: String { + switch self { + case .quick: return "只记录单个或几项异常指标" + case .archive: return "完整保存整份报告(可多页)" + case .diary: return "记录症状、心情、用药" + } + } + var icon: String { + switch self { + case .quick: return "camera.fill" + case .archive: return "doc.fill" + case .diary: return "pencil" + } + } + var accent: Color { + switch self { + case .quick: return Tj.Palette.brick + case .archive: return Tj.Palette.ink + case .diary: return Tj.Palette.leaf + } + } +} + +struct RecordSheet: View { + var onPick: (RecordKind) -> Void + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Tj.Palette.line) + .frame(width: 40, height: 4) + .padding(.top, 10) + .padding(.bottom, 16) + + HStack { + Text("记录什么?") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + Spacer() + Text("本地处理 · 永不上传") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(.bottom, 14) + + VStack(spacing: 10) { + ForEach(RecordKind.allCases) { kind in + Button { + onPick(kind) + } label: { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(kind.accent) + Image(systemName: kind.icon) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(Tj.Palette.paper) + } + .frame(width: 44, height: 44) + + VStack(alignment: .leading, spacing: 2) { + Text(kind.title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text(kind.subtitle) + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(16) + .tjCard() + } + .buttonStyle(.plain) + } + } + .padding(.bottom, 22) + } + .padding(.horizontal, 18) + .background( + Tj.Palette.sand + .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous)) + .ignoresSafeArea(edges: .bottom) + ) + .presentationDetents([.fraction(0.55)]) + .presentationDragIndicator(.hidden) + .presentationBackground(Tj.Palette.sand) + .presentationCornerRadius(Tj.Radius.xl) + } +} diff --git a/体己/Features/Trends/TrendsView.swift b/体己/Features/Trends/TrendsView.swift new file mode 100644 index 0000000..3819ee7 --- /dev/null +++ b/体己/Features/Trends/TrendsView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct TrendsView: View { + var body: some View { + VStack(spacing: 12) { + Spacer() + TjPlaceholder(label: "trends · 折线图 + 影像档案入口\n(尚未实现)") + .frame(width: 280, height: 180) + Text("趋势") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text2) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Tj.Palette.sand.ignoresSafeArea()) + } +} + +#Preview { TrendsView() } diff --git a/体己/Item.swift b/体己/Item.swift deleted file mode 100644 index 2570f82..0000000 --- a/体己/Item.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Item.swift -// 体己 -// -// Created by Tim on 2026/5/25. -// - -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/体己/Models/Models.swift b/体己/Models/Models.swift new file mode 100644 index 0000000..1ca19d6 --- /dev/null +++ b/体己/Models/Models.swift @@ -0,0 +1,96 @@ +import Foundation +import SwiftData + +enum IndicatorStatus: String, Codable, CaseIterable { + case high, low, normal +} + +enum ReportType: String, Codable, CaseIterable { + case checkup, lab, imaging, prescription, other + + var label: String { + switch self { + case .checkup: return "体检报告" + case .lab: return "化验单" + case .imaging: return "影像报告" + case .prescription: return "处方" + case .other: return "其他" + } + } +} + +@Model +final class Indicator { + var name: String + var value: String + var unit: String + var range: String + var statusRaw: String + var note: String? + var capturedAt: Date + + init(name: String, + value: String, + unit: String, + range: String, + status: IndicatorStatus, + note: String? = nil, + capturedAt: Date = .now) { + self.name = name + self.value = value + self.unit = unit + self.range = range + self.statusRaw = status.rawValue + self.note = note + self.capturedAt = capturedAt + } + + var status: IndicatorStatus { + IndicatorStatus(rawValue: statusRaw) ?? .normal + } +} + +@Model +final class Report { + var title: String + var typeRaw: String + var reportDate: Date + var institution: String? + var note: String? + var summary: String? + var pageCount: Int + var createdAt: Date + + init(title: String, + type: ReportType, + reportDate: Date, + institution: String? = nil, + note: String? = nil, + summary: String? = nil, + pageCount: Int = 1, + createdAt: Date = .now) { + self.title = title + self.typeRaw = type.rawValue + self.reportDate = reportDate + self.institution = institution + self.note = note + self.summary = summary + self.pageCount = pageCount + self.createdAt = createdAt + } + + var type: ReportType { + ReportType(rawValue: typeRaw) ?? .other + } +} + +@Model +final class DiaryEntry { + var content: String + var createdAt: Date + + init(content: String, createdAt: Date = .now) { + self.content = content + self.createdAt = createdAt + } +} diff --git a/体己/RootView.swift b/体己/RootView.swift new file mode 100644 index 0000000..14d935e --- /dev/null +++ b/体己/RootView.swift @@ -0,0 +1,151 @@ +import SwiftUI + +enum TjTab: String, Hashable, CaseIterable { + case home, trend, me + var label: String { + switch self { + case .home: 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" + } + } +} + +enum ActiveFlow: Identifiable { + case quick, archive + var id: String { String(describing: self) } +} + +struct RootView: View { + @State private var tab: TjTab = .home + @State private var showRecordSheet = false + @State private var activeFlow: ActiveFlow? + + var body: some View { + VStack(spacing: 0) { + Group { + switch tab { + case .home: HomeView(onTapArchive: {}) + case .trend: TrendsView() + case .me: MeView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + TabBar(active: tab, + onTap: { tab = $0 }, + onTapRecord: { showRecordSheet = true }) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + .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 .diary: break + } + } + } + } +#if os(iOS) + .fullScreenCover(item: $activeFlow) { flow in + switch flow { + case .quick: + QuickCaptureFlow(onClose: { activeFlow = nil }) + case .archive: + ArchiveFlow(onClose: { activeFlow = nil }) + } + } +#else + .sheet(item: $activeFlow) { flow in + switch flow { + case .quick: + QuickCaptureFlow(onClose: { activeFlow = nil }) + case .archive: + ArchiveFlow(onClose: { activeFlow = nil }) + } + } +#endif + } +} + +private struct TabBar: View { + let active: TjTab + let onTap: (TjTab) -> Void + let onTapRecord: () -> Void + + var body: some View { + HStack(spacing: 0) { + tabItem(.home) + Color.clear.frame(width: 60, height: 1) + tabItem(.trend) + tabItem(.me) + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 4) + .background( + Tj.Palette.paper + .overlay(alignment: .top) { + Rectangle() + .fill(Tj.Palette.lineSoft) + .frame(height: 1) + } + ) + .overlay(alignment: .top) { + recordButton.offset(y: -22) + } + } + + private func tabItem(_ t: TjTab) -> some View { + Button { onTap(t) } label: { + VStack(spacing: 4) { + Image(systemName: t.icon) + .font(.system(size: 20, weight: .regular)) + .frame(width: 26, height: 26) + Text(t.label) + .font(.system(size: 11)) + } + .foregroundStyle(active == t ? Tj.Palette.ink : Tj.Palette.text3) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var recordButton: 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) + Image(systemName: "plus") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Tj.Palette.paper) + } + .frame(width: 52, height: 52) + + Text("记录") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.ink) + } + } + .buttonStyle(.plain) + } +} + +#Preview { + RootView() +}