- §5 schema 重写为 7 @Model 完整列表(含 UserProfile + Indicator.seriesKey) - §7 IA 改成 5 槽 TabBar(2 内容 + 中间 + + 2 设置),记录入口 5 个 kind - §10.6 红线例外清单加 Monitor + Profile(Symptom 也补上) - SeriesBucket.swift 缺 import SwiftData(persistentModelID 报错) 全套测试 50 case pass / 0 fail / 0 warning。
115 lines
3.8 KiB
Swift
115 lines
3.8 KiB
Swift
import SwiftUI
|
|
|
|
struct CalendarYearGrid: View {
|
|
let year: Int
|
|
let data: CalendarData
|
|
let onTapMonth: (Date) -> Void
|
|
|
|
private let calendar: Calendar = {
|
|
var c = Calendar(identifier: .gregorian)
|
|
c.firstWeekday = 2
|
|
c.locale = Locale(identifier: "zh_CN")
|
|
return c
|
|
}()
|
|
|
|
private var monthAnchors: [Date] {
|
|
(1...12).compactMap { m in
|
|
var comps = DateComponents()
|
|
comps.year = year; comps.month = m; comps.day = 1
|
|
return calendar.date(from: comps)
|
|
}
|
|
}
|
|
|
|
private let columns = Array(repeating: GridItem(.flexible(), spacing: 14), count: 3)
|
|
|
|
var body: some View {
|
|
LazyVGrid(columns: columns, spacing: 18) {
|
|
ForEach(monthAnchors, id: \.self) { anchor in
|
|
Button {
|
|
onTapMonth(anchor)
|
|
} label: {
|
|
MiniMonth(anchor: anchor, data: data, calendar: calendar)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MiniMonth: View {
|
|
let anchor: Date
|
|
let data: CalendarData
|
|
let calendar: Calendar
|
|
|
|
private var monthLabel: String {
|
|
let f = DateFormatter()
|
|
f.locale = Locale(identifier: "zh_CN")
|
|
f.dateFormat = "M 月"
|
|
return f.string(from: anchor)
|
|
}
|
|
|
|
private var days: [Date] {
|
|
guard let interval = calendar.dateInterval(of: .month, for: anchor) else { return [] }
|
|
let count = calendar.dateComponents([.day], from: interval.start, to: interval.end).day ?? 30
|
|
return (0..<count).compactMap { calendar.date(byAdding: .day, value: $0, to: interval.start) }
|
|
}
|
|
|
|
private var leadingPadding: Int {
|
|
guard let first = days.first else { return 0 }
|
|
return (calendar.component(.weekday, from: first) - calendar.firstWeekday + 7) % 7
|
|
}
|
|
|
|
private let microColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7)
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(monthLabel)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(Tj.Palette.text)
|
|
|
|
LazyVGrid(columns: microColumns, spacing: 2) {
|
|
ForEach(0..<leadingPadding, id: \.self) { _ in
|
|
Color.clear.frame(height: 8)
|
|
}
|
|
ForEach(days, id: \.self) { d in
|
|
dot(for: d)
|
|
}
|
|
}
|
|
}
|
|
.padding(8)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.fill(Tj.Palette.paper)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
|
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
private func dot(for date: Date) -> some View {
|
|
let marks = data.marks(for: date, calendar: calendar)
|
|
let ranges = data.ranges(touching: date, calendar: calendar)
|
|
let color: Color = {
|
|
if marks.abnormalCount > 0 { return Tj.Palette.brick }
|
|
if let topSeverity = ranges.map(\.severity).max() {
|
|
switch topSeverity {
|
|
case 1, 2: return Tj.Palette.leaf
|
|
case 3: return Tj.Palette.amber
|
|
default: return Tj.Palette.brick
|
|
}
|
|
}
|
|
if marks.hasAnyEvent { return Tj.Palette.text3.opacity(0.6) }
|
|
return Tj.Palette.lineSoft
|
|
}()
|
|
let isToday = calendar.isDateInToday(date)
|
|
return RoundedRectangle(cornerRadius: 2, style: .continuous)
|
|
.fill(color)
|
|
.frame(height: 8)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
|
.strokeBorder(Tj.Palette.ink, lineWidth: isToday ? 1 : 0)
|
|
)
|
|
}
|
|
}
|