From 678303187dde23aba25fcf6e70f54b01540ac412 Mon Sep 17 00:00:00 2001 From: ysicing Date: Wed, 30 Sep 2026 18:11:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(calendar):=20=E6=8C=89=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E4=B8=8E=E5=9C=B0=E5=8C=BA=E8=B0=83=E6=95=B4=E6=97=A5=E5=8E=86?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=92=8C=E9=BB=98=E8=AE=A4=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT.md | 3 +- .../Localization/CalendarTranslations.swift | 3 +- .../Sources/Localization/Resources/ar.tsv | 3 +- .../Sources/Localization/Resources/de.tsv | 3 +- .../Sources/Localization/Resources/es.tsv | 3 +- .../Sources/Localization/Resources/fr.tsv | 3 +- .../Sources/Localization/Resources/ja.tsv | 3 +- .../Sources/Localization/Resources/ko.tsv | 3 +- .../Localization/Resources/zh-Hant.tsv | 3 +- .../Sources/WidgetData/WidgetSnapshot.swift | 7 +- .../XStatsUI/AppKit/AppController.swift | 12 ++- .../XStatsUI/Calendar/CalendarAlmanac.swift | 14 ++-- .../Calendar/CalendarAlmanacView.swift | 26 ++++--- .../XStatsUI/Calendar/CalendarEngine.swift | 9 ++- .../XStatsUI/Calendar/CalendarPopover.swift | 31 ++++++-- .../Calendar/CalendarPreferences.swift | 27 ++++++- .../XStatsUI/Calendar/CalendarSettings.swift | 12 ++- .../XStatsUI/Snapshot/SnapshotRenderer.swift | 3 +- .../Sources/XStatsUI/State/AppSettings.swift | 19 +++-- .../LanguageSupportTests.swift | 3 +- .../WidgetDataTests/WidgetSnapshotTests.swift | 4 +- .../XStatsUITests/CalendarAlmanacTests.swift | 21 ++++++ .../CalendarEnhancementTests.swift | 74 ++++++++++++++++++- .../Tests/XStatsUITests/CalendarTests.swift | 4 +- Widget/CalendarWidget.swift | 28 +++---- 25 files changed, 248 insertions(+), 73 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e1b5620..2b17b30 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -54,7 +54,8 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布 构建固定使用 `arm64`,仅支持 Apple Silicon Mac。 有 Developer ID Application 证书时,Taskfile 会自动使用钥匙串中第一个证书的唯一指纹;否则由构建脚本逐层补齐 ad-hoc 签名与 entitlement。临时签名没有团队身份,不能加载启用团队验证的 Sparkle 动态库,因此仅此本地/CI 路径关闭 Hardened Runtime;正式 Developer ID 构建仍强制启用,且临时签名构建不能在线安装或操作特权辅助工具。 桌面小组件与主应用通过 `group.work.12306.xstats` App Group 共享只读展示摘要。 -菜单栏日历提供“日期与星期”“日期与农历”“农历”“自定义”四种显示方式,自定义可自由组合 y/M/d 字段与分隔符,年份可省略,输入时显示预览并提示无效格式,独立于月历的星期开关;支持农历字号与对比度、可选悬停展开,按分钟刷新,休眠或关闭功能后停止。日程和到期提醒默认关闭,通过明确的授权按钮请求 EventKit 权限,只在日历面板可见时查询当前 42 格日期范围;只读展示,不修改日程,也不向 Widget、设置备份或服务端传递内容。列表 ID 仅保存在本机,显示偏好参与设置备份。节假日覆盖年份来自内置 Tyme 数据并随应用更新;假期倒计时与最多 3 天的请假建议只基于已公布的中国调休与双休安排,跨到未知年份时停止推算。 +菜单栏日历提供“日期与星期”“自定义”显示方式;应用语言为简体中文或繁体中文(含自动检测为中文)时,额外提供“日期与农历”“农历”。切到非中文时,已保存的农历方式暂以“日期与星期”呈现,切回中文恢复原选择,不改写备份。自定义可自由组合 y/M/d 字段与分隔符,年份可省略,输入时显示预览并提示无效格式,独立于月历的星期开关;支持农历字号与对比度、可选悬停展开,按分钟刷新,休眠或关闭功能后停止。日程和到期提醒默认关闭,通过明确的授权按钮请求 EventKit 权限,只在日历面板可见时查询当前 42 格日期范围;只读展示,不修改日程,也不向 Widget、设置备份或服务端传递内容。列表 ID 仅保存在本机,显示偏好参与设置备份。节假日覆盖年份来自内置 Tyme 数据并随应用更新;假期倒计时与最多 3 天的请假建议只基于已公布的中国调休与双休安排,跨到未知年份时停止推算。 +日历首次初始化按 macOS 地区设置决定默认值,不按设备销售地或应用语言判断:中国大陆(CN)默认启用农历、星期、大陆节假日与调休、传统与公历节日、节气、黄历及假期概览;其他地区(含台湾、香港、澳门)默认只启用星期。干支、三伏/梅雨/数九、藏历与回历各地区均默认关闭。用户可自行启用或关闭,首次默认值保存后不随语言、地区变化重置;已有本机偏好及旧备份保持原选择。大陆节假日数据不能用于推断其他地区的法定安排。单日及整月小组件遵循日历选项,未启用大陆节假日时“明天上班吗”显示无法判断;黄历关闭后不再计算宜忌摘要。 月历使用固定六行布局,日期 ID 在生成日期时计算一次。面板打开时测量初始高度,后续翻月、详情与日程内容变化在内部滚动,不从内容几何回调反向调整窗口,避免窗口高度与懒布局估算形成反馈循环。 月历计算仅缓存最近一个月份(包含周起始日和时区),供实际面板、高度测量和重复打开共用;翻月替换缓存,避免浏览历史导致内存累积。日程日期标记在工作 actor 内随查询结果生成,界面不再逐格扫描日程列表;连续 EventKit 通知按 300 毫秒窗口合并,关闭面板时取消待执行刷新。 用于正式签名的 Apple Developer 团队须为主应用和 Widget 扩展启用同一个 App Group; diff --git a/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift b/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift index f7df55d..a7874dc 100644 --- a/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift +++ b/Packages/XStatsKit/Sources/Localization/CalendarTranslations.swift @@ -13,8 +13,9 @@ extension Translations { 星期一 Monday 星期日 Sunday 农历 Lunar calendar +黄历 Chinese almanac 星期 Weekdays -法定节假日与调休 Holidays and makeup workdays +中国大陆节假日与调休 Mainland China holidays and makeup workdays 传统与公历节日 Festivals 二十四节气 Solar terms 干支 Sexagenary cycle diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index db9412a..00314e0 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -1314,7 +1314,7 @@ Apple 智能 Apple Intelligence 星期日 الأحد 农历 التقويم القمري الصيني 星期 أيام الأسبوع -法定节假日与调休 العطلات وأيام العمل التعويضية +中国大陆节假日与调休 العطلات وأيام العمل التعويضية في البر الرئيسي للصين 传统与公历节日 المناسبات التقليدية والميلادية 二十四节气 الفصول الشمسية الأربعة والعشرون 干支 الدورة الستينية @@ -1623,3 +1623,4 @@ GitHub 项目 مشروع GitHub 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 يفتح صفحة عمليات XStats عند تفعيل الميزة، أو مراقب نشاط النظام عند إيقافها. 不能在 XStats 内卸载或退出 XStats 自身 لا يمكن لهذه الأداة إلغاء تثبيت XStats نفسه أو إنهاؤه 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 تعذر نقل التطبيق. نُقلت {} من العناصر المرتبطة إلى سلة المهملات (نحو {}). عالج الخطأ ثم أعد المحاولة. +黄历 التقويم الصيني التقليدي diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index 4dced8c..d43cd8c 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -1314,7 +1314,7 @@ Apple 智能 Apple Intelligence 星期日 Sonntag 农历 Chinesischer Mondkalender 星期 Wochentage -法定节假日与调休 Feiertage und Ausgleichsarbeitstage +中国大陆节假日与调休 Feiertage und Ausgleichsarbeitstage in Festlandchina 传统与公历节日 Traditionelle und gregorianische Feste 二十四节气 24 Sonnenperioden 干支 Sechzigjahreszyklus @@ -1623,3 +1623,4 @@ GitHub 项目 GitHub-Projekt 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 Öffnet bei aktivierter Prozessfunktion die XStats-Prozessseite, andernfalls die macOS-Aktivitätsanzeige. 不能在 XStats 内卸载或退出 XStats 自身 XStats kann sich mit diesem Werkzeug nicht selbst deinstallieren oder beenden 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 Die App konnte nicht verschoben werden. Zugehörige Objekte im Papierkorb: {} (ca. {}). Beheben Sie den Fehler und versuchen Sie es erneut. +黄历 Chinesischer Almanach diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 2c7525d..047d94a 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -1314,7 +1314,7 @@ Apple 智能 Apple Intelligence 星期日 Domingo 农历 Calendario lunar chino 星期 Días de la semana -法定节假日与调休 Festivos y días laborales compensatorios +中国大陆节假日与调休 Festivos y días laborales compensatorios de China continental 传统与公历节日 Fiestas tradicionales y gregorianas 二十四节气 24 términos solares 干支 Ciclo sexagenario @@ -1623,3 +1623,4 @@ GitHub 项目 Proyecto en GitHub 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 Abre Procesos de XStats si la función está activada; si está desactivada, abre el Monitor de Actividad de macOS. 不能在 XStats 内卸载或退出 XStats 自身 XStats no puede desinstalarse ni cerrarse desde esta herramienta 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 No se pudo mover la app. Se movieron {} elementos relacionados a la Papelera (aprox. {}). Resuelve el error e inténtalo de nuevo. +黄历 Almanaque chino diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 748f2e3..a121ff0 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -1314,7 +1314,7 @@ Apple 智能 Apple Intelligence 星期日 Dimanche 农历 Calendrier lunaire chinois 星期 Jours de la semaine -法定节假日与调休 Jours fériés et jours de travail compensatoires +中国大陆节假日与调休 Jours fériés et jours de travail compensatoires en Chine continentale 传统与公历节日 Fêtes traditionnelles et grégoriennes 二十四节气 24 périodes solaires 干支 Cycle sexagésimal @@ -1623,3 +1623,4 @@ GitHub 项目 Projet GitHub 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 Ouvre les processus XStats si la fonction est activée, sinon le Moniteur d’activité de macOS. 不能在 XStats 内卸载或退出 XStats 自身 XStats ne peut pas se désinstaller ou se quitter depuis cet outil 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 L’app n’a pas pu être déplacée. Éléments associés placés dans la Corbeille : {} (environ {}). Corrigez l’erreur et réessayez. +黄历 Almanach chinois diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 6d548f2..397eddf 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -1314,7 +1314,7 @@ Apple 智能 Apple Intelligence 星期日 日曜日 农历 旧暦 星期 曜日 -法定节假日与调休 祝日と振替出勤日 +中国大陆节假日与调休 中国本土の祝日・振替出勤日 传统与公历节日 伝統と西暦の祝祭日 二十四节气 二十四節気 干支 干支 @@ -1623,3 +1623,4 @@ GitHub 项目 GitHub プロジェクト 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 プロセス機能がオンの場合は XStats のプロセスページを、オフの場合はシステムのアクティビティモニタを開きます。 不能在 XStats 内卸载或退出 XStats 自身 このツールからXStats自身をアンインストールまたは終了することはできません 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 アプリ本体を移動できませんでした。関連項目を{}件(約{})ゴミ箱に移動しました。エラーを解決して再試行してください。 +黄历 中国の伝統暦 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index e1c7b40..a4b7629 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -1314,7 +1314,7 @@ Apple 智能 Apple Intelligence 星期日 일요일 农历 음력 星期 요일 -法定节假日与调休 공휴일 및 대체 근무일 +中国大陆节假日与调休 중국 본토 공휴일 및 대체 근무일 传统与公历节日 전통 및 양력 기념일 二十四节气 24절기 干支 간지 @@ -1623,3 +1623,4 @@ GitHub 项目 GitHub 프로젝트 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 프로세스 기능이 켜져 있으면 XStats 프로세스 페이지를, 꺼져 있으면 시스템 활성 상태 보기를 엽니다. 不能在 XStats 内卸载或退出 XStats 自身 이 도구에서 XStats 자체를 제거하거나 종료할 수 없습니다 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 앱 본체를 이동하지 못했습니다. 관련 항목 {}개(약 {})를 휴지통으로 이동했습니다. 오류를 해결한 후 다시 시도하세요. +黄历 중국 전통 역서 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 88412f9..456c036 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -1315,7 +1315,7 @@ Apple 智能 Apple Intelligence 星期日 星期日 农历 農曆 星期 星期 -法定节假日与调休 法定節假日與調休 +中国大陆节假日与调休 中國大陸節假日與調休 传统与公历节日 傳統與公曆節日 二十四节气 二十四節氣 干支 干支 @@ -1624,3 +1624,4 @@ GitHub 项目 GitHub 專案 进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 程序功能開啟時開啟 XStats 程序頁,關閉時開啟系統活動監視器。 不能在 XStats 内卸载或退出 XStats 自身 不能在 XStats 內卸載或退出 XStats 自身 应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 應用本體未能移動;已將 {} 項殘留移到廢紙簍,約 {}。請解決錯誤後重試。 +黄历 黃曆 diff --git a/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift b/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift index 51ff8b1..8043271 100644 --- a/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift +++ b/Packages/XStatsKit/Sources/WidgetData/WidgetSnapshot.swift @@ -166,6 +166,8 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { public var showsLunar: Bool /// 可选以兼容旧版摘要;单日日历仅在用户开启时显示具体时令天数。 public var showsSeasonal: Bool? + /// nil 表示旧快照尚未按黄历开关生成,主应用会按需更新一次。 + public var showsAlmanac: Bool? /// 可选以兼容旧版主应用写入的 App Group 摘要。 public var calendarDays: [CalendarSummary]? /// 当前与下个月的六周网格;旧版缓存缺失时 Widget 仍显示公历日期。 @@ -176,9 +178,9 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { localUsageEnabled: Bool? = nil, publicIPEnabled: Bool = false, addresses: [Address] = [], language: String = "system", - calendarFirstWeekday: Int = 2, showsLunar: Bool = true, + calendarFirstWeekday: Int = 2, showsLunar: Bool = Locale.current.region?.identifier == "CN", calendarDays: [CalendarSummary]? = nil, monthSummaries: [MonthSummary]? = nil, - showsSeasonal: Bool? = nil, calendarDataVersion: String? = nil) { + showsSeasonal: Bool? = nil, calendarDataVersion: String? = nil, showsAlmanac: Bool? = nil) { self.aiEnabled = aiEnabled self.quotas = quotas self.dailyTokens = dailyTokens @@ -189,6 +191,7 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { self.calendarFirstWeekday = calendarFirstWeekday self.showsLunar = showsLunar self.showsSeasonal = showsSeasonal + self.showsAlmanac = showsAlmanac self.calendarDays = calendarDays self.monthSummaries = monthSummaries self.calendarDataVersion = calendarDataVersion diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index bf07ad3..9243e13 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -402,6 +402,7 @@ public final class AppController: NSObject, NSApplicationDelegate { _ = model.settings.language _ = model.settings.calendarFirstWeekday _ = model.settings.calendarFeatures + _ = model.settings.calendarPreferences.showAlmanac _ = model.aiUsage.states _ = model.aiUsage.quotaStates _ = model.network.publicResults @@ -422,19 +423,22 @@ public final class AppController: NSObject, NSApplicationDelegate { let todayKey = CalendarEngine.today(at: today)?.id let dataVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String let reusesCalendar = previous.calendarDataVersion == dataVersion + let monthFeatures = settings.calendarFeatures.map(\.rawValue).sorted() // 多预备几天,Widget 午夜换日时无需唤醒主应用;主应用运行期间按小时补足窗口。 let calendarDays: [WidgetSnapshot.CalendarSummary] = { - if reusesCalendar, previous.showsSeasonal != nil, previous.calendarDays?.count == 8, + if reusesCalendar, previous.showsSeasonal != nil, + previous.showsAlmanac == settings.calendarPreferences.showAlmanac, + previous.monthSummaries?.first?.featureKeys == monthFeatures, previous.calendarDays?.count == 8, previous.calendarDays?.first?.dateKey == todayKey { return previous.calendarDays ?? [] } return (0..<8).compactMap { offset in guard let date = calendar.date(byAdding: .day, value: offset, to: today), let day = CalendarEngine.today(at: date) else { return nil } - return CalendarEngine.widgetSummary(for: day) + return CalendarEngine.widgetSummary(for: day, features: settings.calendarFeatures, + showsAlmanac: settings.calendarPreferences.showAlmanac) } }() - let monthFeatures = settings.calendarFeatures.map(\.rawValue).sorted() let monthSummaries: [WidgetSnapshot.MonthSummary] = (0...1).compactMap { offset in guard let date = calendar.date(byAdding: .month, value: offset, to: today) else { return nil } let parts = calendar.dateComponents([.year, .month], from: date) @@ -488,7 +492,7 @@ public final class AppController: NSObject, NSApplicationDelegate { showsLunar: settings.calendarFeatures.contains(.lunar), calendarDays: calendarDays, monthSummaries: monthSummaries, showsSeasonal: settings.calendarFeatures.contains(.seasonal), - calendarDataVersion: dataVersion) + calendarDataVersion: dataVersion, showsAlmanac: settings.calendarPreferences.showAlmanac) guard snapshot != previous, widgetStore.save(snapshot) else { return } for kind in snapshot.changedWidgetKinds(from: previous) { WidgetCenter.shared.reloadTimelines(ofKind: kind) diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift index da4acaa..2eff79d 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanac.swift @@ -46,9 +46,10 @@ extension CalendarEngine { "2026-05-04", "2026-05-05", "2026-10-05", "2026-10-06", "2026-10-07" ] - static func widgetSummary(for day: CalendarDay) -> WidgetSnapshot.CalendarSummary { + static func widgetSummary(for day: CalendarDay, features: Set = Set(CalendarFeature.allCases), + showsAlmanac: Bool = true) -> WidgetSnapshot.CalendarSummary { let schedule: WidgetSnapshot.CalendarSummary.Schedule - if !hasHolidayData(year: day.year) { + if !features.contains(.holidays) || !hasHolidayData(year: day.year) { schedule = .unknown } else if let holiday = day.holiday { if holiday.isWork { @@ -67,10 +68,11 @@ extension CalendarEngine { } else { schedule = day.isWeekend ? .weekend : .work } - let almanac = almanac(for: day) - let seasons = [day.dogDays, day.plumRain, day.nineDays].compactMap { $0 } - return .init(dateKey: day.id, festivals: day.festivals, - solarTerm: day.solarTerm, schedule: schedule, holidayName: day.holiday?.name, + let almanac = showsAlmanac ? almanac(for: day) : nil + let seasons = features.contains(.seasonal) ? [day.dogDays, day.plumRain, day.nineDays].compactMap { $0 } : [] + return .init(dateKey: day.id, festivals: features.contains(.festivals) ? day.festivals : [], + solarTerm: features.contains(.solarTerms) ? day.solarTerm : nil, + schedule: schedule, holidayName: features.contains(.holidays) ? day.holiday?.name : nil, twelveStar: almanac?.twelveStar, isEcliptic: almanac?.twelveStarIsLucky ?? false, recommends: Array(almanac?.recommends.prefix(3) ?? []), avoids: Array(almanac?.avoids.prefix(3) ?? []), diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanacView.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanacView.swift index 37aa2b2..ed9296a 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanacView.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarAlmanacView.swift @@ -5,10 +5,10 @@ import Foundation import Localization import SwiftUI -/// 传统黄历详情:以分格表呈现同一日期的数据,正文保留 Tyme 的传统名称。 +/// 日期详情保留已启用的历法信息;黄历开启时才展示传统宜忌表。 struct CalendarAlmanacView: View { let day: CalendarDay - let almanac: CalendarAlmanac + let almanac: CalendarAlmanac? let features: Set private let heading = Color.dynamic(light: 0xAE743F, dark: 0xDBAC79) private let lucky = Color.dynamic(light: 0x288B3B, dark: 0x63CC76) @@ -20,13 +20,19 @@ struct CalendarAlmanacView: View { if features.contains(.lunar) { Text(day.lunarSummary).font(.system(size: 21)).foregroundStyle(.red) } - Text(almanac.ganzhiSummary).font(.system(size: 13)).foregroundStyle(.secondary) + if let almanac { + Text(almanac.ganzhiSummary).font(.system(size: 13)).foregroundStyle(.secondary) + } else if features.contains(.ganzhi) { + Text(day.ganzhiSummary).font(.system(size: 13)).foregroundStyle(.secondary) + } } - VStack(alignment: .leading, spacing: 10) { - advice(tr("宜"), entries: almanac.recommends, color: lucky) - advice(tr("忌"), entries: almanac.avoids, color: .red) + if let almanac { + VStack(alignment: .leading, spacing: 10) { + advice(tr("宜"), entries: almanac.recommends, color: lucky) + advice(tr("忌"), entries: almanac.avoids, color: .red) + } + almanacTable(almanac) } - almanacTable supplementaryDetails } .padding(.bottom, 6) @@ -53,7 +59,7 @@ struct CalendarAlmanacView: View { } } - private var almanacTable: some View { + private func almanacTable(_ almanac: CalendarAlmanac) -> some View { VStack(spacing: 0) { HStack(spacing: 0) { tile(tr("纳音")) { Text(almanac.sound) } @@ -69,7 +75,7 @@ struct CalendarAlmanacView: View { } .fixedSize(horizontal: false, vertical: true) Divider() - hourTable + hourTable(almanac) Divider() HStack(spacing: 0) { sideTile(tr("建除十二神"), value: almanac.duty) @@ -125,7 +131,7 @@ struct CalendarAlmanacView: View { .padding(.vertical, 10) } - private var hourTable: some View { + private func hourTable(_ almanac: CalendarAlmanac) -> some View { HStack(spacing: 0) { Text(tr("时辰吉凶")) .font(.system(size: 15)).foregroundStyle(heading) diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift index 0499cb8..4fdb7bb 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift @@ -11,13 +11,18 @@ import WidgetData public enum CalendarFeature: String, CaseIterable, Identifiable, Sendable { case lunar, weekdays, holidays, festivals, solarTerms, ganzhi, seasonal, tibetan, hijri public var id: String { rawValue } - public static let defaults: Set = [.lunar, .weekdays, .holidays, .festivals, .solarTerms] + public static var defaults: Set { defaults(for: .current) } + + /// 默认值依据使用地区,不依据应用语言或设备销售地;其他地区可自行启用。 + public static func defaults(for locale: Locale) -> Set { + locale.region?.identifier == "CN" ? [.lunar, .weekdays, .holidays, .festivals, .solarTerms] : [.weekdays] + } var title: String { switch self { case .lunar: tr("农历") case .weekdays: tr("星期") - case .holidays: tr("法定节假日与调休") + case .holidays: tr("中国大陆节假日与调休") case .festivals: tr("传统与公历节日") case .solarTerms: tr("二十四节气") case .ganzhi: tr("干支") diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift index b73964d..cc39b37 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift @@ -56,10 +56,10 @@ struct CalendarPopover: View { _yearInput = State(initialValue: String(month.year)) _selected = State(initialValue: today) _showsDayDetails = State(initialValue: showsDayDetails) - _almanac = State(initialValue: showsDayDetails ? today.flatMap { CalendarEngine.almanac(for: $0) } : nil) + _almanac = State(initialValue: nil) _lastTodayID = State(initialValue: today?.id) _days = State(initialValue: CalendarEngine.month(year: month.year, month: month.month, firstWeekday: firstWeekday)) - _holidayPlan = State(initialValue: CalendarEngine.holidayPlan(from: referenceDate ?? Date())) + _holidayPlan = State(initialValue: nil) } var body: some View { @@ -91,7 +91,7 @@ struct CalendarPopover: View { month = CalendarMonth(year: today.year, month: today.month) } lastTodayID = newID - holidayPlan = CalendarEngine.holidayPlan(from: referenceDate ?? context.date) + refreshHolidayPlan(at: referenceDate ?? context.date) } } .frame(width: isSnapshot ? sizing.width : nil) @@ -103,6 +103,8 @@ struct CalendarPopover: View { .overlay { RoundedRectangle(cornerRadius: DS.Radius.xl).strokeBorder(DS.Palette.border) } .onAppear { reload() + refreshAlmanac() + refreshHolidayPlan(at: referenceDate ?? Date()) if !isSnapshot { model.calendarAgenda.refreshAuthorization() } } .task(id: agendaQuery) { @@ -123,10 +125,15 @@ struct CalendarPopover: View { reload() } .onChange(of: selected?.id) { _, _ in refreshAlmanac() } + .onChange(of: model.settings.calendarPreferences.showAlmanac) { _, _ in refreshAlmanac() } + .onChange(of: model.settings.calendarFeatures) { _, _ in refreshHolidayPlan(at: referenceDate ?? Date()) } + .onChange(of: model.settings.calendarPreferences.showHolidayOverview) { _, _ in + refreshHolidayPlan(at: referenceDate ?? Date()) + } .onChange(of: model.settings.calendarFirstWeekday) { _, _ in reload() } .onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in reload() - holidayPlan = CalendarEngine.holidayPlan(from: referenceDate ?? Date()) + refreshHolidayPlan(at: referenceDate ?? Date()) } .onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in guard referenceDate == nil, let today = CalendarEngine.today() else { return } @@ -137,7 +144,7 @@ struct CalendarPopover: View { private func calendarContent(today: CalendarDay?) -> some View { VStack(spacing: sizing.sectionSpacing) { - if showsDayDetails, let selected, let almanac { + if showsDayDetails, let selected { CalendarAlmanacView(day: selected, almanac: almanac, features: model.settings.calendarFeatures) if model.settings.calendarPreferences.showEvents || model.settings.calendarPreferences.showReminders { CalendarAgendaView(day: selected) @@ -263,10 +270,22 @@ struct CalendarPopover: View { } private func refreshAlmanac() { - guard showsDayDetails, let selected, almanac?.id != selected.id else { return } + guard showsDayDetails, model.settings.calendarPreferences.showAlmanac, let selected else { + almanac = nil + return + } + guard almanac?.id != selected.id else { return } almanac = CalendarEngine.almanac(for: selected) } + private func refreshHolidayPlan(at date: Date) { + guard model.settings.calendarFeatures.contains(.holidays), model.settings.calendarPreferences.showHolidayOverview else { + holidayPlan = nil + return + } + holidayPlan = CalendarEngine.holidayPlan(from: date) + } + private func reload() { selected = CalendarEngine.selection(selected, in: month) days = CalendarEngine.month(year: month.year, month: month.month, firstWeekday: model.settings.calendarFirstWeekday) diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPreferences.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPreferences.swift index 1fa21ff..9b0f1bc 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPreferences.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPreferences.swift @@ -9,6 +9,18 @@ public struct CalendarPreferences: Codable, Equatable, Sendable { public enum Display: String, Codable, CaseIterable, Identifiable, Sendable { case standard, dateLunar, lunar, custom public var id: String { rawValue } + + static func available(for locale: Locale) -> [Self] { + locale.language.languageCode?.identifier == "zh" ? allCases : [.standard, .custom] + } + + /// 切换语言只改变呈现,不覆盖已保存的农历选择,切回中文后可恢复。 + func effective(for locale: Locale) -> Self { + if (self == .dateLunar || self == .lunar), locale.language.languageCode?.identifier != "zh" { + return .standard + } + return self + } var title: String { switch self { case .standard: tr("日期与星期") @@ -24,15 +36,20 @@ public struct CalendarPreferences: Codable, Equatable, Sendable { public var largeLunarText = false public var strongerLunarText = false public var openOnHover = false - public var showHolidayOverview = true + public var showHolidayOverview: Bool + public var showAlmanac: Bool public var showEvents = false public var showReminders = false - public init() {} + public init(locale: Locale = .current) { + let isMainlandChina = locale.region?.identifier == "CN" + showHolidayOverview = isMainlandChina + showAlmanac = isMainlandChina + } private enum CodingKeys: String, CodingKey { case display, dateFormat, largeLunarText, strongerLunarText, openOnHover - case showHolidayOverview, showEvents, showReminders + case showHolidayOverview, showAlmanac, showEvents, showReminders } public init(from decoder: any Decoder) throws { @@ -45,6 +62,8 @@ public struct CalendarPreferences: Codable, Equatable, Sendable { strongerLunarText = try values.decodeIfPresent(Bool.self, forKey: .strongerLunarText) ?? false openOnHover = try values.decodeIfPresent(Bool.self, forKey: .openOnHover) ?? false showHolidayOverview = try values.decodeIfPresent(Bool.self, forKey: .showHolidayOverview) ?? true + // 旧偏好始终展示黄历,缺失新字段时保留已有行为;全新偏好使用地区默认值。 + showAlmanac = try values.decodeIfPresent(Bool.self, forKey: .showAlmanac) ?? true showEvents = try values.decodeIfPresent(Bool.self, forKey: .showEvents) ?? false showReminders = try values.decodeIfPresent(Bool.self, forKey: .showReminders) ?? false } @@ -73,7 +92,7 @@ public struct CalendarPreferences: Codable, Equatable, Sendable { calendar.timeZone = timeZone formatter.calendar = calendar formatter.timeZone = timeZone - switch display { + switch display.effective(for: locale) { case .standard: formatter.setLocalizedDateFormatFromTemplate("MdEEE") case .custom: // 用户明确选择顺序,不能再用本地化模板把日月重新排回月日。 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift index 31ee7c4..519196d 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarSettings.swift @@ -60,6 +60,11 @@ struct CalendarOptionsPopover: View { } } } + GroupRow { + SettingRow(title: tr("黄历")) { + DSToggle(isOn: $settings.calendarPreferences.showAlmanac, label: tr("黄历")) + } + } GroupRow { Text(tr("公历始终显示。藏历与回历显示在日期详情中;梅雨天按传统历法推算,并非天气预报。")) .dsFont(.xs) @@ -108,8 +113,11 @@ struct CalendarOptionsPopover: View { return SettingsGroup(caption: tr("菜单栏")) { GroupRow(showsDivider: false) { SettingRow(title: tr("显示方式")) { - Picker(tr("显示方式"), selection: $settings.calendarPreferences.display) { - ForEach(CalendarPreferences.Display.allCases) { Text($0.title).tag($0) } + Picker(tr("显示方式"), selection: Binding( + get: { settings.calendarPreferences.display.effective(for: L10n.locale) }, + set: { settings.calendarPreferences.display = $0 } + )) { + ForEach(CalendarPreferences.Display.available(for: L10n.locale)) { Text($0.title).tag($0) } }.labelsHidden().fixedSize() } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift index edb3d40..68e1fa7 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift @@ -155,7 +155,8 @@ enum SnapshotRenderer { settings.calendarPreferences.display = .dateLunar write(CalendarOptionsPopover(), model: model, appearance: appearance, to: outputDirectory.appendingPathComponent("calendar-lunar-\(suffix).png")) - settings.calendarPreferences = CalendarPreferences() + // 黄历示例明确启用;普通日历与设置截图仍使用本机地区默认值。 + settings.calendarPreferences = CalendarPreferences(locale: Locale(identifier: "zh_CN")) write(CalendarPopover(referenceDate: september, showsDayDetails: true), model: model, appearance: appearance, to: outputDirectory.appendingPathComponent("calendar-almanac-\(suffix).png")) settings.calendarFeatures = Set(CalendarFeature.allCases) diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift index ca4084f..1b90010 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift @@ -733,7 +733,7 @@ public final class AppSettings { public static let alertLoadOptions = [70, 80, 90] public static let cpuChartOptions = [60, 180, 300] - public init(defaults: UserDefaults = .standard) { + public init(defaults: UserDefaults = .standard, calendarLocale: Locale = .current) { self.defaults = defaults let isRestEnabled = defaults.bool(forKey: Keys.restEnabled) restEnabled = isRestEnabled @@ -750,12 +750,14 @@ public final class AppSettings { restSound = defaults.string(forKey: Keys.restSound).flatMap(RestSound.init(rawValue:)) ?? .off restHUDStyle = defaults.string(forKey: Keys.restHUDStyle).flatMap(RestHUDStyle.init(rawValue:)) ?? .countdown calendarEnabled = defaults.bool(forKey: Keys.calendarEnabled) - calendarPreferences = defaults.data(forKey: Keys.calendarPreferences) - .flatMap { try? JSONDecoder().decode(CalendarPreferences.self, from: $0) } ?? CalendarPreferences() + let savedCalendarPreferences = defaults.data(forKey: Keys.calendarPreferences) + calendarPreferences = savedCalendarPreferences + .flatMap { try? JSONDecoder().decode(CalendarPreferences.self, from: $0) } ?? CalendarPreferences(locale: calendarLocale) calendarEventSourceIDs = defaults.stringArray(forKey: "calendarEventSourceIDs").map { Set($0) } calendarReminderSourceIDs = defaults.stringArray(forKey: "calendarReminderSourceIDs").map { Set($0) } - calendarFeatures = defaults.stringArray(forKey: Keys.calendarFeatures) - .map(CalendarFeature.restored(from:)) ?? CalendarFeature.defaults + let savedCalendarFeatures = defaults.stringArray(forKey: Keys.calendarFeatures) + calendarFeatures = savedCalendarFeatures + .map(CalendarFeature.restored(from:)) ?? CalendarFeature.defaults(for: calendarLocale) calendarFirstWeekday = defaults.integer(forKey: Keys.calendarFirstWeekday) == 1 ? 1 : 2 let items = defaults.stringArray(forKey: Keys.menuBarItems)?.compactMap(MenuBarItem.init(rawValue:)) menuBarItems = Set(items ?? [.cpu, .memory, .network]) @@ -828,6 +830,13 @@ public final class AppSettings { ? defaults.integer(forKey: Keys.alertCPULoad) : 80 cpuChartSeconds = Self.cpuChartOptions.contains(defaults.integer(forKey: Keys.cpuChartSeconds)) ? defaults.integer(forKey: Keys.cpuChartSeconds) : 60 + // 仅在没有偏好时确定默认值;后续语言、地区变化及备份恢复不重置用户选择。 + if savedCalendarPreferences == nil { + defaults.set(try? JSONEncoder().encode(calendarPreferences), forKey: Keys.calendarPreferences) + } + if savedCalendarFeatures == nil { + defaults.set(calendarFeatures.map(\.rawValue).sorted(), forKey: Keys.calendarFeatures) + } } /// 按固定顺序返回当前实际显示的菜单栏项目;可选模块关闭时保留展示偏好但不渲染。 diff --git a/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift b/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift index aa88bb8..32be2e3 100644 --- a/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift +++ b/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift @@ -86,7 +86,8 @@ struct LanguageSupportTests { let catalog = try #require(Translations.catalog(for: language)) #expect(catalog.sourceKeys == Translations.shared.sourceKeys) for key in ["语言", "应用 UI 语言", "自动检测", "搜索语言", "没有匹配的语言", - "设置", "内存", "下载并应用", "XStats 基于 OpenStats 开发,感谢原项目的开源贡献 · MIT License"] { + "设置", "内存", "下载并应用", "中国大陆节假日与调休", "黄历", + "XStats 基于 OpenStats 开发,感谢原项目的开源贡献 · MIT License"] { #expect(catalog.translate(key)?.isEmpty == false) } } diff --git a/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift b/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift index 7a03957..b6c3276 100644 --- a/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift +++ b/Packages/XStatsKit/Tests/WidgetDataTests/WidgetSnapshotTests.swift @@ -18,7 +18,7 @@ import WidgetData publicIPEnabled: true, addresses: [.init(family: "v4", ip: "203.0.113.1", countryCode: "CN", city: nil, organization: nil, purityScore: 82, purityGrade: "A")], - language: "japanese", calendarFirstWeekday: 1, showsLunar: false) + language: "japanese", calendarFirstWeekday: 1, showsLunar: false, showsAlmanac: false) #expect(store.save(snapshot)) #expect(store.load() == snapshot) #expect(snapshot.currentQuotas(at: now).count == 1) @@ -169,7 +169,7 @@ import WidgetData organization: nil, purityScore: 82, purityGrade: "A") let base = WidgetSnapshot(aiEnabled: true, quotas: [quota(fetchedAt: now)], dailyTokens: [.init(provider: "codex", day: now, tokens: 10)], - publicIPEnabled: true, addresses: [address]) + publicIPEnabled: true, addresses: [address], showsLunar: true) // 重新抓取但额度未变:抓取时间不显示在 Widget 上,不应消耗重载预算 var refetched = base diff --git a/Packages/XStatsKit/Tests/XStatsUITests/CalendarAlmanacTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/CalendarAlmanacTests.swift index f933f2f..1f319d5 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/CalendarAlmanacTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/CalendarAlmanacTests.swift @@ -7,6 +7,27 @@ import Testing @MainActor struct CalendarAlmanacTests { + @Test(arguments: [(2026, 9, 25), (2026, 9, 23), (2026, 12, 23), (2026, 10, 10)]) + func widgetsDoNotExposeDisabledRegionalFeatures(_ date: (Int, Int, Int)) throws { + let day = try #require(CalendarEngine.day(year: date.0, month: date.1, day: date.2)) + let summary = CalendarEngine.widgetSummary(for: day, features: [.weekdays], showsAlmanac: false) + #expect(summary.festivals.isEmpty && summary.solarTerm == nil) + #expect(summary.holidayName == nil && summary.schedule.needsWork == nil) + #expect(summary.seasonalDescriptions == nil) + #expect(summary.twelveStar == nil && summary.recommends.isEmpty && summary.avoids.isEmpty) + } + + @Test func manuallyEnabledFeaturesWorkOutsideDefaultRegions() throws { + let day = try #require(CalendarEngine.day(year: 2026, month: 9, day: 25)) + let summary = CalendarEngine.widgetSummary(for: day, features: [.festivals, .holidays], showsAlmanac: false) + #expect(!summary.festivals.isEmpty && summary.holidayName != nil) + #expect(summary.schedule.needsWork == false) + #expect(summary.twelveStar == nil) + let almanacOnly = CalendarEngine.widgetSummary(for: day, features: [], showsAlmanac: true) + #expect(almanacOnly.twelveStar != nil && !almanacOnly.recommends.isEmpty) + #expect(almanacOnly.schedule.needsWork == nil && almanacOnly.festivals.isEmpty) + } + @Test func referenceDateMatchesAlmanacFields() throws { let day = try #require(CalendarEngine.day(year: 2026, month: 9, day: 23)) let detail = try #require(CalendarEngine.almanac(for: day)) diff --git a/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift index d2018b6..93ba8a7 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift @@ -63,6 +63,31 @@ struct CalendarEnhancementTests { #expect(CalendarEngine.lunarDateTitle(at: newYear, locale: Locale(identifier: "zh_Hans_CN"), timeZone: zone) == "正月初一") } + @Test(arguments: ["zh_Hans_CN", "zh_Hans_SG", "zh_Hant_TW", "zh_Hant_HK"]) + func chineseLanguagesOfferLunarDisplayRegardlessOfRegion(localeID: String) throws { + let locale = Locale(identifier: localeID) + #expect(CalendarPreferences.Display.available(for: locale) == [.standard, .dateLunar, .lunar, .custom]) + #expect(CalendarPreferences.Display.lunar.effective(for: locale) == .lunar) + #expect(CalendarPreferences.Display.dateLunar.effective(for: locale) == .dateLunar) + } + + @Test(arguments: ["en_CN", "en_US", "ja_JP", "ko_KR", "de_DE", "es_ES", "fr_FR", "ar_SA"]) + func nonChineseLanguagesTemporarilyUseStandardDisplay(localeID: String) throws { + let locale = Locale(identifier: localeID) + #expect(CalendarPreferences.Display.available(for: locale) == [.standard, .custom]) + let date = try #require(ISO8601DateFormatter().date(from: "2026-09-28T04:00:00Z")) + let standard = CalendarPreferences().title(at: date, locale: locale, timeZone: zone) + for display in [CalendarPreferences.Display.lunar, .dateLunar] { + var preferences = CalendarPreferences() + preferences.display = display + #expect(preferences.title(at: date, locale: locale, timeZone: zone) == standard) + // 呈现回退不应被写入备份,切回繁体中文仍使用原选择。 + let restored = try JSONDecoder().decode(CalendarPreferences.self, from: JSONEncoder().encode(preferences)) + #expect(restored.display == display) + #expect(restored.display.effective(for: Locale(identifier: "zh_Hant_TW")) == display) + } + } + @Test(arguments: ["en_US", "en_GB", "zh_Hans_CN"]) func customDateKeepsUserOrderAndSeparators(localeID: String) throws { var preferences = CalendarPreferences() @@ -104,12 +129,59 @@ struct CalendarEnhancementTests { @Test func calendarPreferencesKeepSafeDefaultsForOlderDocuments() throws { let old = try JSONDecoder().decode(CalendarPreferences.self, from: Data("{}".utf8)) - #expect(old == CalendarPreferences()) + #expect(old == CalendarPreferences(locale: Locale(identifier: "zh_CN"))) + #expect(old.showAlmanac && old.showHolidayOverview) #expect(!old.showEvents && !old.showReminders && !old.openOnHover) let future = try JSONDecoder().decode(CalendarPreferences.self, from: Data(#"{"display":"future"}"#.utf8)) #expect(future.display == .standard) } + @Test(arguments: ["en_US", "en_CN", "zh_Hans_CN", "zh_Hant_CN", "zh_Hant_TW", "zh_Hant_HK", "zh_Hant_MO", + "zh_Hans_SG", "ja_JP", "ko_KR", "ar_SA", "fr_FR"]) + func freshCalendarDefaultsFollowRegionAndRemainStable(localeID: String) throws { + let name = "CalendarRegionTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let locale = Locale(identifier: localeID) + let mainland = locale.region?.identifier == "CN" + let expected: Set = mainland ? [.lunar, .weekdays, .holidays, .festivals, .solarTerms] : [.weekdays] + let settings = AppSettings(defaults: defaults, calendarLocale: locale) + #expect(settings.calendarFeatures == expected) + #expect(settings.calendarPreferences.showHolidayOverview == mainland) + #expect(settings.calendarPreferences.showAlmanac == mainland) + #expect(!settings.calendarFeatures.contains(.seasonal)) + + // 更改地区不应重置首次保存的默认值,明确关闭的选项和空集合也须保留。 + let otherRegion = Locale(identifier: mainland ? "zh_TW" : "en_CN") + let restored = AppSettings(defaults: defaults, calendarLocale: otherRegion) + #expect(restored.calendarFeatures == expected) + #expect(restored.calendarPreferences == settings.calendarPreferences) + settings.calendarFeatures = [] + settings.calendarPreferences.showHolidayOverview = false + settings.calendarPreferences.showAlmanac = false + let disabled = AppSettings(defaults: defaults, calendarLocale: Locale(identifier: "zh_CN")) + #expect(disabled.calendarFeatures.isEmpty) + #expect(!disabled.calendarPreferences.showHolidayOverview && !disabled.calendarPreferences.showAlmanac) + } + + @Test func existingCalendarChoicesAndBackupsSurviveRegionalDefaults() throws { + let name = "CalendarExistingRegionTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + defaults.set(["lunar", "holidays", "dogDays"], forKey: "calendarFeatures") + defaults.set(Data(#"{"display":"lunar","showHolidayOverview":true}"#.utf8), forKey: "calendarPreferences") + let settings = AppSettings(defaults: defaults, calendarLocale: Locale(identifier: "en_US")) + #expect(settings.calendarFeatures == [.lunar, .holidays, .seasonal]) + #expect(settings.calendarPreferences.display == .lunar) + #expect(settings.calendarPreferences.showHolidayOverview && settings.calendarPreferences.showAlmanac) + let backup = try JSONDecoder().decode(SettingsDocument.self, from: JSONEncoder().encode(settings.exportDocument())) + settings.calendarFeatures = [.weekdays] + settings.calendarPreferences.showAlmanac = false + settings.apply(backup) + #expect(settings.calendarFeatures == [.lunar, .holidays, .seasonal]) + #expect(settings.calendarPreferences.showAlmanac) + } + @Test func displayPreferencesRoundTripWithoutSyncingLocalCalendarIDs() throws { let name = "CalendarEnhancementTests.\(UUID())" let defaults = try #require(UserDefaults(suiteName: name)) diff --git a/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift index ae73149..8671706 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/CalendarTests.swift @@ -61,7 +61,7 @@ struct CalendarTests { @Test func widgetMonthContainsSixWeeksAndOfficialHolidayNames() throws { let month = CalendarEngine.widgetMonthSummary(year: 2026, month: 9, firstWeekday: 2, - features: CalendarFeature.defaults, timeZone: zone) + features: CalendarFeature.defaults(for: Locale(identifier: "zh_CN")), timeZone: zone) #expect(month.days.count == 42) #expect(month.days.first?.dateKey == "2026-08-31") #expect(month.days.last?.dateKey == "2026-10-11") @@ -71,7 +71,7 @@ struct CalendarTests { #expect(midAutumn.isWork == false) let sundayFirst = CalendarEngine.widgetMonthSummary(year: 2026, month: 9, firstWeekday: 1, - features: CalendarFeature.defaults, timeZone: zone) + features: CalendarFeature.defaults(for: Locale(identifier: "zh_CN")), timeZone: zone) #expect(sundayFirst.days.first?.dateKey == "2026-08-30") let plain = CalendarEngine.widgetMonthSummary(year: 2026, month: 9, firstWeekday: 2, diff --git a/Widget/CalendarWidget.swift b/Widget/CalendarWidget.swift index 23ddb2e..015daf9 100644 --- a/Widget/CalendarWidget.swift +++ b/Widget/CalendarWidget.swift @@ -18,7 +18,7 @@ struct CalendarWidgetEntry: TimelineEntry { struct CalendarWidgetProvider: TimelineProvider { func placeholder(in context: Context) -> CalendarWidgetEntry { - .init(date: .now, showsLunar: true, showsSeasonal: false, firstWeekday: 2, + .init(date: .now, showsLunar: WidgetSnapshot().showsLunar, showsSeasonal: false, firstWeekday: 2, today: nil, tomorrow: nil, month: nil) } @@ -116,26 +116,22 @@ private struct CalendarWidgetView: View { } } .frame(maxWidth: .infinity, alignment: .leading) - Rectangle().fill(.quaternary).frame(width: 1) - VStack(alignment: .leading, spacing: 7) { - if let today = entry.today { - if let star = today.twelveStar { - HStack(spacing: 5) { - Text(tr(today.isEcliptic ? "黄道日" : "黑道日")) - .font(.caption.weight(.semibold)) - .foregroundStyle(today.isEcliptic ? .green : .secondary) - Text(star).font(.caption2).foregroundStyle(.secondary) - } - Divider() + if let today = entry.today, let star = today.twelveStar { + Rectangle().fill(.quaternary).frame(width: 1) + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 5) { + Text(tr(today.isEcliptic ? "黄道日" : "黑道日")) + .font(.caption.weight(.semibold)) + .foregroundStyle(today.isEcliptic ? .green : .secondary) + Text(star).font(.caption2).foregroundStyle(.secondary) } + Divider() advice(tr("宜"), values: today.recommends) advice(tr("忌"), values: today.avoids) - } else { - Text(tr("暂无安排")).font(.caption).foregroundStyle(.secondary) + Spacer(minLength: 0) } - Spacer(minLength: 0) + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) } .padding(12) }