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:
108
康康/Features/Trends/CalendarMarkers.swift
Normal file
108
康康/Features/Trends/CalendarMarkers.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
struct SymptomRange: Identifiable, Hashable {
|
||||
let id: String
|
||||
let name: String
|
||||
let startDay: Date
|
||||
let endDay: Date
|
||||
let severity: Int
|
||||
let isOngoing: Bool
|
||||
|
||||
var color: Color {
|
||||
switch severity {
|
||||
case 1, 2: return Tj.Palette.leaf
|
||||
case 3: return Tj.Palette.amber
|
||||
default: return Tj.Palette.brick
|
||||
}
|
||||
}
|
||||
|
||||
func contains(_ day: Date, calendar: Calendar = .current) -> Bool {
|
||||
let d = calendar.startOfDay(for: day)
|
||||
return d >= startDay && d <= endDay
|
||||
}
|
||||
|
||||
func position(_ day: Date, calendar: Calendar = .current) -> Position {
|
||||
let d = calendar.startOfDay(for: day)
|
||||
let isStart = d == startDay
|
||||
let isEnd = d == endDay
|
||||
if isStart && isEnd { return .single }
|
||||
if isStart { return .start }
|
||||
if isEnd { return .end }
|
||||
return .middle
|
||||
}
|
||||
|
||||
enum Position { case single, start, middle, end }
|
||||
}
|
||||
|
||||
struct DayMarks: Hashable {
|
||||
var abnormalCount: Int = 0
|
||||
var normalCount: Int = 0
|
||||
var reportCount: Int = 0
|
||||
var diaryCount: Int = 0
|
||||
|
||||
var hasAnyEvent: Bool {
|
||||
abnormalCount + normalCount + reportCount + diaryCount > 0
|
||||
}
|
||||
}
|
||||
|
||||
struct CalendarData {
|
||||
let dayMarks: [Date: DayMarks]
|
||||
let symptomRanges: [SymptomRange]
|
||||
|
||||
func marks(for day: Date, calendar: Calendar = .current) -> DayMarks {
|
||||
dayMarks[calendar.startOfDay(for: day)] ?? DayMarks()
|
||||
}
|
||||
|
||||
func ranges(touching day: Date, calendar: Calendar = .current) -> [SymptomRange] {
|
||||
symptomRanges.filter { $0.contains(day, calendar: calendar) }
|
||||
}
|
||||
|
||||
static func build(indicators: [Indicator],
|
||||
reports: [Report],
|
||||
diaries: [DiaryEntry],
|
||||
symptoms: [Symptom],
|
||||
now: Date = .now,
|
||||
calendar: Calendar = .current) -> CalendarData {
|
||||
var buckets: [Date: DayMarks] = [:]
|
||||
|
||||
for i in indicators {
|
||||
let day = calendar.startOfDay(for: i.capturedAt)
|
||||
var m = buckets[day] ?? DayMarks()
|
||||
if i.status == .normal {
|
||||
m.normalCount += 1
|
||||
} else {
|
||||
m.abnormalCount += 1
|
||||
}
|
||||
buckets[day] = m
|
||||
}
|
||||
|
||||
for r in reports {
|
||||
let day = calendar.startOfDay(for: r.reportDate)
|
||||
var m = buckets[day] ?? DayMarks()
|
||||
m.reportCount += 1
|
||||
buckets[day] = m
|
||||
}
|
||||
|
||||
for d in diaries {
|
||||
let day = calendar.startOfDay(for: d.createdAt)
|
||||
var m = buckets[day] ?? DayMarks()
|
||||
m.diaryCount += 1
|
||||
buckets[day] = m
|
||||
}
|
||||
|
||||
let ranges: [SymptomRange] = symptoms.map { s in
|
||||
SymptomRange(
|
||||
id: "\(s.persistentModelID)",
|
||||
name: s.name,
|
||||
startDay: calendar.startOfDay(for: s.startedAt),
|
||||
endDay: calendar.startOfDay(for: s.endedAt ?? now),
|
||||
severity: s.severity,
|
||||
isOngoing: s.isOngoing
|
||||
)
|
||||
}
|
||||
|
||||
return CalendarData(dayMarks: buckets, symptomRanges: ranges)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
114
康康/Features/Trends/CalendarYearGrid.swift
Normal file
114
康康/Features/Trends/CalendarYearGrid.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
406
康康/Features/Trends/DayDetailSheet.swift
Normal file
406
康康/Features/Trends/DayDetailSheet.swift
Normal file
@@ -0,0 +1,406 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct SelectedDay: Identifiable, Hashable {
|
||||
let date: Date
|
||||
var id: TimeInterval { date.timeIntervalSince1970 }
|
||||
}
|
||||
|
||||
struct DayDetailSheet: View {
|
||||
@Environment(\.modelContext) private var ctx
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let date: Date
|
||||
let indicators: [Indicator]
|
||||
let reports: [Report]
|
||||
let diaries: [DiaryEntry]
|
||||
let symptoms: [Symptom]
|
||||
|
||||
@State private var endingSymptom: Symptom?
|
||||
|
||||
private let calendar: Calendar = {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.locale = Locale(identifier: "zh_CN")
|
||||
return c
|
||||
}()
|
||||
|
||||
// MARK: - 当日数据筛选
|
||||
|
||||
private var dayIndicators: [Indicator] {
|
||||
indicators.filter { calendar.isDate($0.capturedAt, inSameDayAs: date) }
|
||||
}
|
||||
|
||||
private var dayReports: [Report] {
|
||||
reports.filter { calendar.isDate($0.reportDate, inSameDayAs: date) }
|
||||
}
|
||||
|
||||
private var dayDiaries: [DiaryEntry] {
|
||||
diaries.filter { calendar.isDate($0.createdAt, inSameDayAs: date) }
|
||||
}
|
||||
|
||||
private var daySymptoms: [(symptom: Symptom, state: SymptomDayState)] {
|
||||
symptoms.compactMap { s in
|
||||
let start = calendar.startOfDay(for: s.startedAt)
|
||||
let end = calendar.startOfDay(for: s.endedAt ?? .now)
|
||||
let target = calendar.startOfDay(for: date)
|
||||
guard target >= start && target <= end else { return nil }
|
||||
let state: SymptomDayState
|
||||
if start == end && s.isOngoing { state = .startedToday }
|
||||
else if target == start { state = .startedToday }
|
||||
else if !s.isOngoing && target == end { state = .endedToday }
|
||||
else { state = .ongoing }
|
||||
return (s, state)
|
||||
}
|
||||
}
|
||||
|
||||
private var totalCount: Int {
|
||||
dayIndicators.count + dayReports.count + dayDiaries.count + daySymptoms.count
|
||||
}
|
||||
|
||||
// MARK: - body
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Capsule()
|
||||
.fill(Tj.Palette.line)
|
||||
.frame(width: 40, height: 4)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 14)
|
||||
|
||||
header
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
if totalCount == 0 {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !daySymptoms.isEmpty {
|
||||
section("症状", count: daySymptoms.count) {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(daySymptoms, id: \.symptom.id) { item in
|
||||
symptomRow(item.symptom, state: item.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !dayIndicators.isEmpty {
|
||||
section("指标", count: dayIndicators.count) {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(dayIndicators) { i in
|
||||
indicatorRow(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !dayReports.isEmpty {
|
||||
section("报告", count: dayReports.count) {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(dayReports) { r in
|
||||
reportRow(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !dayDiaries.isEmpty {
|
||||
section("日记", count: dayDiaries.count) {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(dayDiaries) { d in
|
||||
diaryRow(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(
|
||||
Tj.Palette.sand
|
||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.hidden)
|
||||
.presentationBackground(Tj.Palette.sand)
|
||||
.presentationCornerRadius(Tj.Radius.xl)
|
||||
.sheet(item: $endingSymptom) { sym in
|
||||
SymptomEndSheet(symptom: sym)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - header
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(dateLine)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Text(dayLabel)
|
||||
.font(.tjTitle(28))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
Spacer()
|
||||
if totalCount > 0 {
|
||||
Text("\(totalCount) 条")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var dateLine: String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "zh_CN")
|
||||
f.dateFormat = "yyyy 年"
|
||||
return f.string(from: date) + " · " + weekdayLabel
|
||||
}
|
||||
|
||||
private var dayLabel: String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "zh_CN")
|
||||
f.dateFormat = "M 月 d 日"
|
||||
return f.string(from: date)
|
||||
}
|
||||
|
||||
private var weekdayLabel: String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "zh_CN")
|
||||
f.dateFormat = "EEEE"
|
||||
return f.string(from: date)
|
||||
}
|
||||
|
||||
// MARK: - section
|
||||
|
||||
private func section<Content: View>(_ title: String,
|
||||
count: Int,
|
||||
@ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.tracking(0.3)
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
Text("\(count)")
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - rows
|
||||
|
||||
private func symptomRow(_ s: Symptom, state: SymptomDayState) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Capsule()
|
||||
.fill(severityColor(s.severity))
|
||||
.frame(width: 4, height: 36)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 6) {
|
||||
Text(s.name)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
stateBadge(state, isOngoing: s.isOngoing)
|
||||
}
|
||||
Text("\(state.subtitle) · 持续 \(formatDuration(s.duration))")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
|
||||
if s.isOngoing {
|
||||
Button {
|
||||
endingSymptom = s
|
||||
} label: {
|
||||
Text("结束")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Tj.Palette.sand2))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.tjCard(bordered: true)
|
||||
}
|
||||
|
||||
private func stateBadge(_ state: SymptomDayState, isOngoing: Bool) -> some View {
|
||||
Text(state.badge)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(state.badgeFg)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(state.badgeBg))
|
||||
}
|
||||
|
||||
private func indicatorRow(_ i: Indicator) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(indicatorAccent(i).opacity(0.12))
|
||||
Image(systemName: "drop.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(indicatorAccent(i))
|
||||
}
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(i.name)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
if !i.range.isEmpty {
|
||||
Text("参考 \(i.range)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
|
||||
Text("\(i.value) \(i.unit)\(arrow(i))")
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(i.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
.padding(12)
|
||||
.tjCard(bordered: true)
|
||||
}
|
||||
|
||||
private func reportRow(_ r: Report) -> some View {
|
||||
let abnormal = r.indicators.filter { $0.status != .normal }.count
|
||||
return HStack(spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Tj.Palette.ink2.opacity(0.12))
|
||||
Image(systemName: "doc.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.ink2)
|
||||
}
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(r.title)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Text("\(r.type.label) · 共 \(r.pageCount) 页")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
if abnormal > 0 {
|
||||
Text("\(abnormal) 项偏高")
|
||||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.brick)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.tjCard(bordered: true)
|
||||
}
|
||||
|
||||
private func diaryRow(_ d: DiaryEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(d.createdAt.formatted(date: .omitted, time: .shortened))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
Text(d.content)
|
||||
.font(.tjSerifBody(14))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineSpacing(4)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.tjCard(bordered: true)
|
||||
}
|
||||
|
||||
// MARK: - empty
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Spacer(minLength: 16)
|
||||
TjPlaceholder(label: "这一天还没有记录")
|
||||
.frame(width: 220, height: 120)
|
||||
Text("点底部 + 号可以补一条")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - utils
|
||||
|
||||
private func severityColor(_ value: Int) -> Color {
|
||||
switch value {
|
||||
case 1, 2: return Tj.Palette.leaf
|
||||
case 3: return Tj.Palette.amber
|
||||
default: return Tj.Palette.brick
|
||||
}
|
||||
}
|
||||
|
||||
private func indicatorAccent(_ i: Indicator) -> Color {
|
||||
i.status == .normal ? Tj.Palette.leaf : Tj.Palette.brick
|
||||
}
|
||||
|
||||
private func arrow(_ i: Indicator) -> String {
|
||||
switch i.status {
|
||||
case .high: return " ↑"
|
||||
case .low: return " ↓"
|
||||
case .normal: return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SymptomDayState {
|
||||
case startedToday, ongoing, endedToday
|
||||
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .startedToday: return "今天开始"
|
||||
case .ongoing: return "进行中"
|
||||
case .endedToday: return "今天结束"
|
||||
}
|
||||
}
|
||||
|
||||
var badge: String {
|
||||
switch self {
|
||||
case .startedToday: return "开始"
|
||||
case .ongoing: return "持续"
|
||||
case .endedToday: return "结束"
|
||||
}
|
||||
}
|
||||
|
||||
var badgeBg: Color {
|
||||
switch self {
|
||||
case .startedToday: return Tj.Palette.brickSoft
|
||||
case .ongoing: return Tj.Palette.sand2
|
||||
case .endedToday: return Tj.Palette.leafSoft
|
||||
}
|
||||
}
|
||||
|
||||
var badgeFg: Color {
|
||||
switch self {
|
||||
case .startedToday: return Tj.Palette.brick
|
||||
case .ongoing: return Tj.Palette.text2
|
||||
case .endedToday: return Tj.Palette.leaf
|
||||
}
|
||||
}
|
||||
}
|
||||
153
康康/Features/Trends/SeriesBucket.swift
Normal file
153
康康/Features/Trends/SeriesBucket.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
/// 长期监测系列在 Trends 折线图里的展示桶。
|
||||
/// 单系列(血糖/体重/...)= 1 个 SeriesLine;血压特殊 = 收缩 + 舒张 2 条线同卡。
|
||||
struct SeriesBucket: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let unit: String
|
||||
let lines: [SeriesLine]
|
||||
let latestDate: Date
|
||||
|
||||
struct SeriesLine: Identifiable {
|
||||
let id: String
|
||||
let seriesKey: String
|
||||
let label: String?
|
||||
let color: Color
|
||||
let points: [Point]
|
||||
let referenceRange: ClosedRange<Double>?
|
||||
|
||||
var latestPoint: Point? { points.last }
|
||||
}
|
||||
|
||||
struct Point: Identifiable, Hashable {
|
||||
let id: String
|
||||
let date: Date
|
||||
let value: Double
|
||||
let status: IndicatorStatus
|
||||
}
|
||||
}
|
||||
|
||||
extension SeriesBucket {
|
||||
/// 把全表 Indicator(无 seriesKey 的会被跳过)折成 SeriesBucket 列表。
|
||||
/// 同 seriesKey 内按 capturedAt 升序;BP 两个 key 合并成一个 bucket;
|
||||
/// `minPoints` 以下的系列不返回,默认 2(单点不画线)。
|
||||
static func build(from indicators: [Indicator],
|
||||
profile: UserProfile? = nil,
|
||||
minPoints: Int = 2) -> [SeriesBucket] {
|
||||
var buckets: [String: [Indicator]] = [:]
|
||||
for i in indicators {
|
||||
guard let key = i.seriesKey, !key.isEmpty else { continue }
|
||||
buckets[key, default: []].append(i)
|
||||
}
|
||||
|
||||
// 合并血压
|
||||
let bpKeys: Set<String> = ["bp.systolic", "bp.diastolic"]
|
||||
let bpIndicators = bpKeys.flatMap { buckets[$0] ?? [] }
|
||||
let bpHasEnoughPoints = bpIndicators.filter { $0.seriesKey == "bp.systolic" }.count >= minPoints
|
||||
|
||||
var results: [SeriesBucket] = []
|
||||
|
||||
if bpHasEnoughPoints {
|
||||
results.append(buildBP(from: buckets, profile: profile))
|
||||
}
|
||||
for k in bpKeys { buckets.removeValue(forKey: k) }
|
||||
|
||||
for (key, items) in buckets {
|
||||
guard items.count >= minPoints else { continue }
|
||||
if let bucket = buildSingle(key: key, items: items, profile: profile) {
|
||||
results.append(bucket)
|
||||
}
|
||||
}
|
||||
|
||||
return results.sorted { $0.latestDate > $1.latestDate }
|
||||
}
|
||||
|
||||
private static func buildSingle(key: String,
|
||||
items: [Indicator],
|
||||
profile: UserProfile?) -> SeriesBucket? {
|
||||
let sorted = items.sorted { $0.capturedAt < $1.capturedAt }
|
||||
guard let latest = sorted.last else { return nil }
|
||||
|
||||
let metric = monitorMetric(for: key)
|
||||
let field = metric?.fields.first { $0.seriesKey == key }
|
||||
let title = metric?.displayName ?? sorted.first?.name ?? key
|
||||
let unit = field?.unit ?? sorted.first?.unit ?? ""
|
||||
let range = field.flatMap { metric?.effectiveRange(for: $0, profile: profile) }
|
||||
|
||||
let line = SeriesLine(
|
||||
id: key,
|
||||
seriesKey: key,
|
||||
label: nil,
|
||||
color: Tj.Palette.ink,
|
||||
points: sorted.compactMap { point(from: $0) },
|
||||
referenceRange: range
|
||||
)
|
||||
|
||||
return SeriesBucket(
|
||||
id: key,
|
||||
title: title,
|
||||
unit: unit,
|
||||
lines: [line],
|
||||
latestDate: latest.capturedAt
|
||||
)
|
||||
}
|
||||
|
||||
private static func buildBP(from buckets: [String: [Indicator]],
|
||||
profile: UserProfile?) -> SeriesBucket {
|
||||
let m = MonitorMetric.bloodPressure
|
||||
let sysField = m.fields[0]
|
||||
let diaField = m.fields[1]
|
||||
|
||||
let sysItems = (buckets["bp.systolic"] ?? []).sorted { $0.capturedAt < $1.capturedAt }
|
||||
let diaItems = (buckets["bp.diastolic"] ?? []).sorted { $0.capturedAt < $1.capturedAt }
|
||||
|
||||
let sysLine = SeriesLine(
|
||||
id: "bp.systolic",
|
||||
seriesKey: "bp.systolic",
|
||||
label: "收缩",
|
||||
color: Tj.Palette.brick,
|
||||
points: sysItems.compactMap { point(from: $0) },
|
||||
referenceRange: m.effectiveRange(for: sysField, profile: profile)
|
||||
)
|
||||
let diaLine = SeriesLine(
|
||||
id: "bp.diastolic",
|
||||
seriesKey: "bp.diastolic",
|
||||
label: "舒张",
|
||||
color: Tj.Palette.leaf,
|
||||
points: diaItems.compactMap { point(from: $0) },
|
||||
referenceRange: m.effectiveRange(for: diaField, profile: profile)
|
||||
)
|
||||
|
||||
let latest = max(
|
||||
sysItems.last?.capturedAt ?? .distantPast,
|
||||
diaItems.last?.capturedAt ?? .distantPast
|
||||
)
|
||||
|
||||
return SeriesBucket(
|
||||
id: "bp",
|
||||
title: "血压",
|
||||
unit: "mmHg",
|
||||
lines: [sysLine, diaLine],
|
||||
latestDate: latest
|
||||
)
|
||||
}
|
||||
|
||||
private static func point(from i: Indicator) -> Point? {
|
||||
guard let v = Double(i.value.trimmingCharacters(in: .whitespaces)) else { return nil }
|
||||
return Point(
|
||||
id: "\(i.persistentModelID)",
|
||||
date: i.capturedAt,
|
||||
value: v,
|
||||
status: i.status
|
||||
)
|
||||
}
|
||||
|
||||
private static func monitorMetric(for seriesKey: String) -> MonitorMetric? {
|
||||
MonitorMetric.allCases.first { m in
|
||||
m.fields.contains { $0.seriesKey == seriesKey }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,243 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
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()
|
||||
enum CalendarMode: String, CaseIterable, Identifiable {
|
||||
case month, year
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .month: return "月"
|
||||
case .year: return "年"
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview { TrendsView() }
|
||||
struct TrendsView: View {
|
||||
@Query(sort: \Indicator.capturedAt, order: .reverse)
|
||||
private var indicators: [Indicator]
|
||||
|
||||
@Query(sort: \Report.reportDate, order: .reverse)
|
||||
private var reports: [Report]
|
||||
|
||||
@Query(sort: \DiaryEntry.createdAt, order: .reverse)
|
||||
private var diaries: [DiaryEntry]
|
||||
|
||||
@Query(sort: \Symptom.startedAt, order: .reverse)
|
||||
private var symptoms: [Symptom]
|
||||
|
||||
@State private var mode: CalendarMode = .month
|
||||
@State private var anchor: Date = .now
|
||||
@State private var selectedDay: SelectedDay?
|
||||
|
||||
private let calendar: Calendar = {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.firstWeekday = 2
|
||||
c.locale = Locale(identifier: "zh_CN")
|
||||
return c
|
||||
}()
|
||||
|
||||
@MainActor
|
||||
private var data: CalendarData {
|
||||
CalendarData.build(
|
||||
indicators: indicators,
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
symptoms: symptoms
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
header.padding(.top, 4)
|
||||
modeSwitch
|
||||
anchorBar
|
||||
calendarBody
|
||||
legend
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.sheet(item: $selectedDay) { sel in
|
||||
DayDetailSheet(
|
||||
date: sel.date,
|
||||
indicators: indicators,
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
symptoms: symptoms
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text("趋势")
|
||||
.font(.tjTitle(26))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
Button {
|
||||
anchor = .now
|
||||
} label: {
|
||||
Text("回到今天")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private var modeSwitch: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(CalendarMode.allCases) { m in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
mode = m
|
||||
}
|
||||
} label: {
|
||||
Text(m.label)
|
||||
.font(.system(size: 13, weight: mode == m ? .semibold : .regular))
|
||||
.foregroundStyle(mode == m ? Tj.Palette.paper : Tj.Palette.text)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
Capsule().fill(mode == m ? Tj.Palette.ink : Color.clear)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
.background(Capsule().fill(Tj.Palette.paper))
|
||||
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
.frame(maxWidth: 220)
|
||||
}
|
||||
|
||||
private var anchorBar: some View {
|
||||
HStack {
|
||||
Button { shiftAnchor(-1) } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Circle().fill(Tj.Palette.paper))
|
||||
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(anchorTitle)
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.snappy, value: anchor)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button { shiftAnchor(1) } label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Circle().fill(Tj.Palette.paper))
|
||||
.overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isAnchorAtFuture)
|
||||
.opacity(isAnchorAtFuture ? 0.4 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var anchorTitle: String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "zh_CN")
|
||||
f.dateFormat = mode == .month ? "yyyy 年 M 月" : "yyyy 年"
|
||||
return f.string(from: anchor)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var calendarBody: some View {
|
||||
switch mode {
|
||||
case .month:
|
||||
CalendarMonthGrid(monthAnchor: anchor, data: data) { day in
|
||||
selectedDay = SelectedDay(date: day)
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.fill(Tj.Palette.paper)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||
)
|
||||
case .year:
|
||||
CalendarYearGrid(
|
||||
year: calendar.component(.year, from: anchor),
|
||||
data: data
|
||||
) { tappedMonth in
|
||||
anchor = tappedMonth
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
mode = .month
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var legend: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("图例")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.tracking(0.5)
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
HStack(spacing: 14) {
|
||||
legendItem(color: Tj.Palette.brick, label: "指标异常")
|
||||
legendItem(color: Tj.Palette.amber, label: "症状持续中")
|
||||
legendItem(color: Tj.Palette.ink2, label: "报告归档")
|
||||
legendItem(color: Tj.Palette.leaf, label: "正常")
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
private func legendItem(color: Color, label: String) -> some View {
|
||||
HStack(spacing: 5) {
|
||||
RoundedRectangle(cornerRadius: 2, style: .continuous)
|
||||
.fill(color)
|
||||
.frame(width: 14, height: 6)
|
||||
Text(label)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text2)
|
||||
}
|
||||
}
|
||||
|
||||
private var isAnchorAtFuture: Bool {
|
||||
switch mode {
|
||||
case .month:
|
||||
return calendar.isDate(anchor, equalTo: .now, toGranularity: .month) ||
|
||||
anchor > .now
|
||||
case .year:
|
||||
let nowYear = calendar.component(.year, from: .now)
|
||||
let anchorYear = calendar.component(.year, from: anchor)
|
||||
return anchorYear >= nowYear
|
||||
}
|
||||
}
|
||||
|
||||
private func shiftAnchor(_ delta: Int) {
|
||||
let component: Calendar.Component = (mode == .month) ? .month : .year
|
||||
if let next = calendar.date(byAdding: component, value: delta, to: anchor) {
|
||||
withAnimation(.snappy) {
|
||||
anchor = next
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TrendsView()
|
||||
.modelContainer(for: [
|
||||
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self
|
||||
], inMemory: true)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user