diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0ac6d8f..28bd71d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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;系统授权必须由用户点击允许访问后决定。 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift index 8bee8b0..0499cb8 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarEngine.swift @@ -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 解读为“无需调休”。 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift index 9292573..b73964d 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Calendar/CalendarPopover.swift @@ -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") diff --git a/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift index 0b04de6..a7b989f 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/CalendarEnhancementTests.swift @@ -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"))