- §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。
192 lines
6.8 KiB
Swift
192 lines
6.8 KiB
Swift
import SwiftUI
|
|
|
|
struct CalendarMonthGrid: View {
|
|
let monthAnchor: Date
|
|
let data: CalendarData
|
|
let onTapDay: (Date) -> Void
|
|
|
|
private let calendar: Calendar = {
|
|
var c = Calendar(identifier: .gregorian)
|
|
c.firstWeekday = 2 // 周一开始
|
|
c.locale = Locale(identifier: "zh_CN")
|
|
return c
|
|
}()
|
|
|
|
private let weekdayLabels = ["一", "二", "三", "四", "五", "六", "日"]
|
|
private let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)
|
|
|
|
private var days: [DayCell] {
|
|
guard let monthInterval = calendar.dateInterval(of: .month, for: monthAnchor) else {
|
|
return []
|
|
}
|
|
let firstOfMonth = monthInterval.start
|
|
let weekdayIndex = (calendar.component(.weekday, from: firstOfMonth) - calendar.firstWeekday + 7) % 7
|
|
let daysInMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)?.count ?? 30
|
|
|
|
var cells: [DayCell] = []
|
|
// leading padding (上月尾)
|
|
for offset in (0..<weekdayIndex).reversed() {
|
|
if let d = calendar.date(byAdding: .day, value: -(offset + 1), to: firstOfMonth) {
|
|
cells.append(DayCell(date: d, inCurrentMonth: false))
|
|
}
|
|
}
|
|
// current month days
|
|
for i in 0..<daysInMonth {
|
|
if let d = calendar.date(byAdding: .day, value: i, to: firstOfMonth) {
|
|
cells.append(DayCell(date: d, inCurrentMonth: true))
|
|
}
|
|
}
|
|
// trailing padding (下月头) — 填到 6 周整 = 42
|
|
while cells.count < 42 {
|
|
if let last = cells.last,
|
|
let next = calendar.date(byAdding: .day, value: 1, to: last.date) {
|
|
cells.append(DayCell(date: next, inCurrentMonth: false))
|
|
} else { break }
|
|
}
|
|
return cells
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
HStack(spacing: 4) {
|
|
ForEach(weekdayLabels, id: \.self) { w in
|
|
Text(w)
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
LazyVGrid(columns: columns, spacing: 4) {
|
|
ForEach(days) { cell in
|
|
DayCellView(
|
|
cell: cell,
|
|
ranges: data.ranges(touching: cell.date, calendar: calendar),
|
|
marks: data.marks(for: cell.date, calendar: calendar),
|
|
isToday: calendar.isDateInToday(cell.date),
|
|
calendar: calendar
|
|
)
|
|
.onTapGesture { onTapDay(cell.date) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DayCell: Identifiable, Hashable {
|
|
let date: Date
|
|
let inCurrentMonth: Bool
|
|
var id: String { "\(date.timeIntervalSince1970)" }
|
|
}
|
|
|
|
private struct DayCellView: View {
|
|
let cell: DayCell
|
|
let ranges: [SymptomRange]
|
|
let marks: DayMarks
|
|
let isToday: Bool
|
|
let calendar: Calendar
|
|
|
|
private var dayNumber: Int {
|
|
calendar.component(.day, from: cell.date)
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .top) {
|
|
// 背景:今天高亮
|
|
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
|
.fill(isToday ? Tj.Palette.sand2 : Color.clear)
|
|
|
|
VStack(spacing: 2) {
|
|
Text("\(dayNumber)")
|
|
.font(.system(size: 13,
|
|
weight: isToday ? .bold : .regular,
|
|
design: .default))
|
|
.foregroundStyle(textColor)
|
|
.padding(.top, 4)
|
|
|
|
// 症状连续条
|
|
if !ranges.isEmpty {
|
|
VStack(spacing: 1) {
|
|
ForEach(Array(ranges.prefix(2).enumerated()), id: \.element.id) { _, range in
|
|
symptomBar(range)
|
|
}
|
|
if ranges.count > 2 {
|
|
Text("+\(ranges.count - 2)")
|
|
.font(.system(size: 7, design: .monospaced))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 异常 / 普通点
|
|
if marks.hasAnyEvent {
|
|
HStack(spacing: 2) {
|
|
if marks.abnormalCount > 0 {
|
|
Circle().fill(Tj.Palette.brick).frame(width: 4, height: 4)
|
|
}
|
|
if marks.reportCount > 0 {
|
|
Circle().fill(Tj.Palette.ink2).frame(width: 4, height: 4)
|
|
}
|
|
if marks.normalCount > 0 && marks.abnormalCount == 0 {
|
|
Circle().fill(Tj.Palette.leaf).frame(width: 4, height: 4)
|
|
}
|
|
if marks.diaryCount > 0 {
|
|
Circle().fill(Tj.Palette.text3.opacity(0.7)).frame(width: 4, height: 4)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
.frame(height: 56)
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
private var textColor: Color {
|
|
if !cell.inCurrentMonth { return Tj.Palette.text3.opacity(0.5) }
|
|
if isToday { return Tj.Palette.ink }
|
|
return Tj.Palette.text
|
|
}
|
|
|
|
private func symptomBar(_ range: SymptomRange) -> some View {
|
|
let pos = range.position(cell.date, calendar: calendar)
|
|
let leadingRadius: CGFloat = (pos == .start || pos == .single) ? 3 : 0
|
|
let trailingRadius: CGFloat = (pos == .end || pos == .single) ? 3 : 0
|
|
return GeometryReader { geo in
|
|
UnevenRoundedRectangle(
|
|
topLeadingRadius: leadingRadius,
|
|
bottomLeadingRadius: leadingRadius,
|
|
bottomTrailingRadius: trailingRadius,
|
|
topTrailingRadius: trailingRadius,
|
|
style: .continuous
|
|
)
|
|
.fill(range.color)
|
|
.frame(
|
|
width: barWidth(for: pos, in: geo.size.width),
|
|
height: 4
|
|
)
|
|
.frame(maxWidth: .infinity,
|
|
alignment: barAlignment(for: pos))
|
|
}
|
|
.frame(height: 4)
|
|
}
|
|
|
|
private func barWidth(for pos: SymptomRange.Position, in cellWidth: CGFloat) -> CGFloat {
|
|
switch pos {
|
|
case .single: return cellWidth - 8
|
|
case .start, .end: return cellWidth - 2
|
|
case .middle: return cellWidth + 4 // 越界让相邻天视觉连接
|
|
}
|
|
}
|
|
|
|
private func barAlignment(for pos: SymptomRange.Position) -> Alignment {
|
|
switch pos {
|
|
case .start: return .leading
|
|
case .end: return .trailing
|
|
case .single: return .center
|
|
case .middle: return .center
|
|
}
|
|
}
|
|
}
|