主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施:Localizable.xcstrings(String Catalog,sourceLanguage=zh-Hans)
+ pbxproj developmentRegion/knownRegions 注册 en/ja/ko
- 全部硬编码 Locale("zh_CN") → Locale.current;中文 dateFormat → Date.FormatStyle(跟随系统)
- UI 中文字面量统一为 String(appLoc:)(显式绑定所选语言 bundle+locale,即时切换)
Text 字面量走环境 \.locale + Bundle 重定向
- 549 个 catalog key 全部 en/ja/ko 翻译完成(0 未翻译)
- App 内语言切换:我的 → 语言(LanguageManager + 即时生效,无需重启)
- 双用预设(症状/监测指标/慢病)本地化:static→computed 避免缓存
注:本提交为 WIP,一并打包了并行进行的功能模块
(HealthExport 健康导出、Security/Face ID 锁、DiaryAssist 日记 AI 辅助)
及 App 图标、CLAUDE.md、docs/scripts。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
315 lines
11 KiB
Swift
315 lines
11 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
|
|
enum CalendarMode: String, CaseIterable, Identifiable {
|
|
case month, year
|
|
var id: String { rawValue }
|
|
var label: String {
|
|
switch self {
|
|
case .month: return String(appLoc: "月")
|
|
case .year: return String(appLoc: "年")
|
|
}
|
|
}
|
|
}
|
|
|
|
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]
|
|
|
|
@Query private var profiles: [UserProfile]
|
|
|
|
@Query private var customMetrics: [CustomMonitorMetric]
|
|
|
|
@State private var mode: CalendarMode = .month
|
|
@State private var anchor: Date = .now
|
|
/// 选中的当天 — 默认选今天,日历下方 inline 显示该日详情
|
|
@State private var selectedDate: Date = .now
|
|
|
|
private var profile: UserProfile? { profiles.first }
|
|
|
|
private var seriesBuckets: [SeriesBucket] {
|
|
SeriesBucket.build(from: indicators,
|
|
profile: profile,
|
|
customMetrics: customMetrics)
|
|
}
|
|
|
|
private let calendar: Calendar = {
|
|
var c = Calendar(identifier: .gregorian)
|
|
c.firstWeekday = 2
|
|
c.locale = Locale.current
|
|
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
|
|
if mode == .month {
|
|
dayDetailInline
|
|
}
|
|
seriesSection
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 24)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
|
}
|
|
|
|
/// 日历下方 inline 显示选中天的详情(symptoms / indicators / reports / diaries)
|
|
private var dayDetailInline: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
DayDetailContent(
|
|
date: selectedDate,
|
|
indicators: indicators,
|
|
reports: reports,
|
|
diaries: diaries,
|
|
symptoms: symptoms,
|
|
showHeader: true
|
|
)
|
|
.padding(14)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.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)
|
|
)
|
|
.animation(.snappy(duration: 0.2), value: selectedDate)
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(alignment: .lastTextBaseline) {
|
|
Text("趋势")
|
|
.font(.tjTitle(26))
|
|
.foregroundStyle(Tj.Palette.text)
|
|
Spacer()
|
|
Button {
|
|
withAnimation(.snappy(duration: 0.2)) {
|
|
anchor = .now
|
|
selectedDate = .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 style: Date.FormatStyle = mode == .month
|
|
? .dateTime.year().month()
|
|
: .dateTime.year()
|
|
return anchor.formatted(style)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var calendarBody: some View {
|
|
switch mode {
|
|
case .month:
|
|
CalendarMonthGrid(monthAnchor: anchor, data: data, selectedDate: selectedDate) { day in
|
|
withAnimation(.snappy(duration: 0.2)) {
|
|
selectedDate = 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var seriesSection: some View {
|
|
let buckets = seriesBuckets
|
|
if !buckets.isEmpty {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(alignment: .lastTextBaseline) {
|
|
Text("长期监测")
|
|
.font(.tjH2())
|
|
.foregroundStyle(Tj.Palette.text)
|
|
Text("\(buckets.count) 项")
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
Spacer()
|
|
}
|
|
.padding(.top, 8)
|
|
|
|
VStack(spacing: 12) {
|
|
ForEach(buckets) { bucket in
|
|
SeriesChartCard(bucket: bucket)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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: String(appLoc: "指标异常"))
|
|
legendItem(color: Tj.Palette.amber, label: String(appLoc: "症状持续中"))
|
|
legendItem(color: Tj.Palette.ink2, label: String(appLoc: "报告归档"))
|
|
legendItem(color: Tj.Palette.leaf, label: String(appLoc: "正常"))
|
|
}
|
|
}
|
|
.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
|
|
// 翻月时把 selection 跟着走:同月内停在今天(如果是当前月)或 1 号
|
|
if mode == .month {
|
|
if calendar.isDate(next, equalTo: .now, toGranularity: .month) {
|
|
selectedDate = .now
|
|
} else if let first = calendar.dateInterval(of: .month, for: next)?.start {
|
|
selectedDate = first
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
TrendsView()
|
|
.modelContainer(for: [
|
|
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self
|
|
], inMemory: true)
|
|
}
|