fix(calendar): 优化月历布局并修复返回日期状态
This commit is contained in:
1 parent
fd465466d8
commit
fd6244295f
4 files changed
+91
-38
No files matched your search
@@ -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"))
|
||||
|
||||
Reference in new issue
Block a user