fix(calendar): 优化月历布局并修复返回日期状态

This commit is contained in:
ysicing committed 2026-09-29 13:28:12 +08:00
1 parent fd465466d8
commit fd6244295f
4 files changed
+91 -38

No files matched your search

+1
View File
@@ -55,6 +55,7 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布
有 Developer ID Application 证书时,Taskfile 会自动使用钥匙串中第一个证书的唯一指纹;否则使用项目默认的 ad-hoc 签名。
桌面小组件与主应用通过 `group.work.12306.xstats` App Group 共享只读展示摘要。
菜单栏日历提供“日期与星期”“日期与农历”“农历”“自定义”四种显示方式,自定义可自由组合 y/M/d 字段与分隔符,年份可省略,输入时显示预览并提示无效格式,独立于月历的星期开关;支持农历字号与对比度、可选悬停展开,按分钟刷新,休眠或关闭功能后停止。日程和到期提醒默认关闭,通过明确的授权按钮请求 EventKit 权限,只在日历面板可见时查询当前 42 格日期范围;只读展示,不修改日程,也不向 Widget、设置备份或服务端传递内容。列表 ID 仅保存在本机,显示偏好参与设置备份。节假日覆盖年份来自内置 Tyme 数据并随应用更新;假期倒计时与最多 3 天的请假建议只基于已公布的中国调休与双休安排,跨到未知年份时停止推算。
月历使用固定六行布局,日期 ID 在生成日期时计算一次。面板打开时测量初始高度,后续翻月、详情与日程内容变化在内部滚动,不从内容几何回调反向调整窗口,避免窗口高度与懒布局估算形成反馈循环。
月历计算仅缓存最近一个月份(包含周起始日和时区),供实际面板、高度测量和重复打开共用;翻月替换缓存,避免浏览历史导致内存累积。日程日期标记在工作 actor 内随查询结果生成,界面不再逐格扫描日程列表;连续 EventKit 通知按 300 毫秒窗口合并,关闭面板时取消待执行刷新。
用于正式签名的 Apple Developer 团队须为主应用和 Widget 扩展启用同一个 App Group;
EventKit 授权还依赖主 App 的 `com.apple.security.personal-information.calendars` entitlement,配置来源为 `project.yml`。即使已有 Info.plist 权限说明,Hardened Runtime 缺少此 entitlement 仍会直接拒绝授权且不弹窗。本地预览和发布时应检查最终 App 签名中的 entitlement;系统授权必须由用户点击允许访问后决定。
@@ -69,7 +69,8 @@ struct CalendarDay: Identifiable, Equatable {
let plumRain: String?
let nineDays: String?
let seasonalBoundary: String?
var id: String { WidgetSnapshot.dayKey(year: year, month: month, day: day) }
/// 日期身份在创建时确定,避免 SwiftUI 比较列表时反复格式化字符串。
let id: String
var isWeekend: Bool { weekday == 1 || weekday == 7 }
/// 每格仅显示一条主注释,所有启用的信息在选中日期详情中保留。
@@ -186,7 +187,8 @@ enum CalendarEngine {
festivals: festivals, solarTerm: term.dayIndex == 0 ? term.solarTerm.getName() : nil,
holiday: solar.legalHoliday.map { .init(name: $0.name, isWork: $0.isWork) },
dogDays: dog?.description, plumRain: plumRain?.description,
nineDays: nine?.description, seasonalBoundary: seasonalBoundary)
nineDays: nine?.description, seasonalBoundary: seasonalBoundary,
id: WidgetSnapshot.dayKey(year: year, month: month, day: day))
}
/// 年份覆盖来自固定版本的数据本身;未收录年份不能把 nil 解读为“无需调休”。
@@ -30,7 +30,7 @@ struct CalendarPopoverSizing: Equatable {
}
}
/// 月历与黄历详情在同一个面板内切换;返回时保留浏览月份和选中的日期。
/// 月历与黄历详情在同一个面板内切换;返回月历时恢复今天。
struct CalendarPopover: View {
@Environment(AppModel.self) private var model
@Environment(\.isSnapshot) private var isSnapshot
@@ -71,31 +71,16 @@ struct CalendarPopover: View {
else { header(today: today) }
}
.padding(sizing.headerPadding)
PageScroll {
VStack(spacing: sizing.sectionSpacing) {
if showsDayDetails, let selected, let almanac {
CalendarAlmanacView(day: selected, almanac: almanac, features: model.settings.calendarFeatures)
if model.settings.calendarPreferences.showEvents || model.settings.calendarPreferences.showReminders {
CalendarAgendaView(day: selected)
}
} else {
calendarGrid(today: today)
}
if !showsDayDetails, let selected, model.settings.calendarPreferences.showEvents || model.settings.calendarPreferences.showReminders {
CalendarAgendaView(day: selected)
}
if !showsDayDetails, model.settings.calendarFeatures.contains(.holidays),
model.settings.calendarPreferences.showHolidayOverview, let holidayPlan {
CalendarHolidayPlanView(plan: holidayPlan)
}
if model.settings.calendarFeatures.contains(.holidays), !CalendarEngine.hasHolidayData(year: month.year) {
Text(tr("该年份暂无中国法定假日与调休数据"))
.font(.caption)
.foregroundStyle(.secondary)
// 打开时由测量视图确定高度;内容变化只滚动,避免布局回调反向调整窗口。
Group {
if isSnapshot {
calendarContent(today: today)
} else {
ScrollView {
calendarContent(today: today).overlayScrollers()
}
.scrollBounceBehavior(.basedOnSize)
}
.padding(.horizontal, 8)
.padding(.bottom, 8)
}
.environment(\.isPopover, true)
}
@@ -150,6 +135,33 @@ struct CalendarPopover: View {
}
}
private func calendarContent(today: CalendarDay?) -> some View {
VStack(spacing: sizing.sectionSpacing) {
if showsDayDetails, let selected, let almanac {
CalendarAlmanacView(day: selected, almanac: almanac, features: model.settings.calendarFeatures)
if model.settings.calendarPreferences.showEvents || model.settings.calendarPreferences.showReminders {
CalendarAgendaView(day: selected)
}
} else {
calendarGrid(today: today)
}
if !showsDayDetails, let selected, model.settings.calendarPreferences.showEvents || model.settings.calendarPreferences.showReminders {
CalendarAgendaView(day: selected)
}
if !showsDayDetails, model.settings.calendarFeatures.contains(.holidays),
model.settings.calendarPreferences.showHolidayOverview, let holidayPlan {
CalendarHolidayPlanView(plan: holidayPlan)
}
if model.settings.calendarFeatures.contains(.holidays), !CalendarEngine.hasHolidayData(year: month.year) {
Text(tr("该年份暂无中国法定假日与调休数据"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 8 + DS.Space.s3)
.padding(.bottom, 8 + DS.Space.s1)
}
private var agendaQuery: CalendarAgendaQuery {
let calendar = CalendarEngine.gregorian()
let start = calendar.startOfDay(for: days.first?.date ?? referenceDate ?? Date())
@@ -208,19 +220,23 @@ struct CalendarPopover: View {
}
.padding(.bottom, sizing.cellHeight < 50 ? 2 : 3)
}
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7),
spacing: sizing.cellHeight < 50 ? 3 : 4) {
ForEach(days) { day in
CalendarDayCell(day: day, features: features, isCurrentMonth: day.month == month.month,
isToday: day.id == today?.id, isSelected: day.id == selected?.id,
preferences: model.settings.calendarPreferences, sizing: sizing,
hasAgenda: model.calendarAgenda.markedDays.contains(CalendarEngine.gregorian().startOfDay(for: day.date))) {
selected = day
if day.month != month.month, CalendarMonth.years.contains(day.year) {
month = CalendarMonth(year: day.year, month: day.month)
// 月历固定最多 42 格,无需懒布局的高度估算与反复放置。
VStack(spacing: sizing.cellHeight < 50 ? 3 : 4) {
ForEach(0..<6) { row in
HStack(spacing: 4) {
ForEach(days.dropFirst(row * 7).prefix(7)) { day in
CalendarDayCell(day: day, features: features, isCurrentMonth: day.month == month.month,
isToday: day.id == today?.id, isSelected: day.id == selected?.id,
preferences: model.settings.calendarPreferences, sizing: sizing,
hasAgenda: model.calendarAgenda.markedDays.contains(CalendarEngine.gregorian().startOfDay(for: day.date))) {
selected = day
if day.month != month.month, CalendarMonth.years.contains(day.year) {
month = CalendarMonth(year: day.year, month: day.month)
}
showsDayDetails = true
refreshAlmanac()
}
}
showsDayDetails = true
refreshAlmanac()
}
}
}
@@ -230,6 +246,8 @@ struct CalendarPopover: View {
private func detailHeader(today: CalendarDay?) -> some View {
HStack {
Button {
// 单日详情是临时查看;返回默认月历时不留下历史日期的实心选中态。
if let today { goToToday(today) }
showsDayDetails = false
} label: {
Label(tr("返回月历"), systemImage: "chevron.backward")
@@ -1,8 +1,10 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit
import Foundation
import Localization
import SwiftUI
import Testing
@testable import XStatsUI
@@ -10,6 +12,36 @@ import Testing
struct CalendarEnhancementTests {
let zone = TimeZone(identifier: "Asia/Shanghai")!
@Test func calendarLayoutDoesNotFeedOverflowBackIntoWindowSize() async throws {
let name = "CalendarLayoutTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: name))
defer { defaults.removePersistentDomain(forName: name) }
let model = AppModel(settings: AppSettings(defaults: defaults), historyURL: nil,
aiUsageProviders: [], aiQuotaProviders: [])
let date = try #require(ISO8601DateFormatter().date(from: "2026-09-29T04:00:00Z"))
var overflowReports = 0
let reporter = PopoverOverflowReporter { _ in overflowReports += 1 }
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 460, height: 380),
styleMask: .borderless, backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
defer { window.close() }
for details in [false, true] {
let hosting = NSHostingView(rootView: CalendarPopover(referenceDate: date, showsDayDetails: details)
.environment(model)
.environment(\.reportPopoverOverflow, reporter))
window.contentView = hosting
window.orderFrontRegardless()
for height in [380.0, 640.0, 400.0] {
window.setContentSize(NSSize(width: 460, height: height))
hosting.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(100))
#expect(abs(hosting.bounds.height - height) < 1)
}
}
// 内容溢出应留在滚动区域内,不能再反向驱动 StatusPanel.setFrame。
#expect(overflowReports == 0)
}
@Test func menuBarPresetsShowWeekdayAndFullLunarDate() throws {
var preferences = CalendarPreferences()
let date = try #require(ISO8601DateFormatter().date(from: "2026-09-28T16:30:00Z"))