Files
kangkang/康康/Features/Trends/CalendarMonthGrid.swift
link2026 d2c77d5c51 feat: 国际化(i18n) en/ja/ko + App 内语言切换
主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施: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>
2026-05-30 10:28:24 +08:00

224 lines
7.9 KiB
Swift

import SwiftUI
struct CalendarMonthGrid: View {
let monthAnchor: Date
let data: CalendarData
let selectedDate: Date?
let onTapDay: (Date) -> Void
init(monthAnchor: Date,
data: CalendarData,
selectedDate: Date? = nil,
onTapDay: @escaping (Date) -> Void) {
self.monthAnchor = monthAnchor
self.data = data
self.selectedDate = selectedDate
self.onTapDay = onTapDay
}
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.firstWeekday = 2 //
c.locale = Locale.current
return c
}()
private let weekdayLabels = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: "")
]
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),
isSelected: selectedDate.map {
calendar.isDate(cell.date, inSameDayAs: $0)
} ?? false,
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 isSelected: Bool
let calendar: Calendar
private var dayNumber: Int {
calendar.component(.day, from: cell.date)
}
var body: some View {
ZStack(alignment: .top) {
// :selected > today
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(backgroundFill)
//
if isSelected {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.strokeBorder(Tj.Palette.brick, lineWidth: 1.5)
}
VStack(spacing: 2) {
Text("\(dayNumber)")
.font(.system(size: 13,
weight: (isToday || isSelected) ? .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 isSelected { return Tj.Palette.brick }
if isToday { return Tj.Palette.ink }
return Tj.Palette.text
}
private var backgroundFill: Color {
if isSelected { return Tj.Palette.brickSoft.opacity(0.5) }
if isToday { return Tj.Palette.sand2 }
return .clear
}
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
}
}
}