docs(claude): sync §5/§7/§10 with Monitor+Profile; fix SeriesBucket SwiftData import
- §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。
This commit is contained in:
191
康康/Features/Trends/CalendarMonthGrid.swift
Normal file
191
康康/Features/Trends/CalendarMonthGrid.swift
Normal file
@@ -0,0 +1,191 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user