- 已有安装缺少日历选项时沿用旧版默认与周一起始,不按地区重新初始化 - “明天上班吗”区分未启用节假日与安排尚未公布,恢复“暂无法判断”译文 - 截图固定中国大陆默认值与周起始日,移除默认参数与无调用方的 defaults
350 lines
15 KiB
Swift
350 lines
15 KiB
Swift
// Copyright (C) 2026 ysicing
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import Localization
|
|
import SwiftUI
|
|
import WidgetData
|
|
import WidgetKit
|
|
|
|
struct CalendarWidgetEntry: TimelineEntry {
|
|
let date: Date
|
|
let showsLunar: Bool
|
|
let showsSeasonal: Bool
|
|
let firstWeekday: Int
|
|
let today: WidgetSnapshot.CalendarSummary?
|
|
let tomorrow: WidgetSnapshot.CalendarSummary?
|
|
let month: WidgetSnapshot.MonthSummary?
|
|
}
|
|
|
|
struct CalendarWidgetProvider: TimelineProvider {
|
|
func placeholder(in context: Context) -> CalendarWidgetEntry {
|
|
let snapshot = WidgetSnapshot()
|
|
return .init(date: .now, showsLunar: snapshot.showsLunar, showsSeasonal: false, firstWeekday: snapshot.calendarFirstWeekday,
|
|
today: nil, tomorrow: nil, month: nil)
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (CalendarWidgetEntry) -> Void) {
|
|
completion(context.isPreview ? placeholder(in: context) : read())
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<CalendarWidgetEntry>) -> Void) {
|
|
let entry = read()
|
|
let now = entry.date
|
|
let nextDay = Calendar.autoupdatingCurrent.date(byAdding: .day, value: 1,
|
|
to: Calendar.autoupdatingCurrent.startOfDay(for: now))
|
|
?? now.addingTimeInterval(24 * 60 * 60)
|
|
completion(Timeline(entries: [entry], policy: .after(nextDay)))
|
|
}
|
|
|
|
private func read() -> CalendarWidgetEntry {
|
|
let snapshot = WidgetSnapshotStore().load()
|
|
L10n.configure(AppLanguage(rawValue: snapshot.language) ?? .system)
|
|
let now = Date.now
|
|
let nextDay = WidgetSnapshot.keyCalendar.date(byAdding: .day, value: 1, to: now) ?? now
|
|
return .init(date: now, showsLunar: snapshot.showsLunar, showsSeasonal: snapshot.showsSeasonal == true,
|
|
firstWeekday: snapshot.calendarFirstWeekday,
|
|
today: snapshot.calendarSummary(for: now),
|
|
tomorrow: snapshot.calendarSummary(for: nextDay), month: snapshot.monthSummary(for: now))
|
|
}
|
|
}
|
|
|
|
private struct CalendarWidgetView: View {
|
|
@Environment(\.widgetFamily) private var family
|
|
let entry: CalendarWidgetEntry
|
|
|
|
var body: some View {
|
|
Group {
|
|
if family == .systemMedium { monthView }
|
|
else { dayView }
|
|
}
|
|
.containerBackground(.background, for: .widget)
|
|
.environment(\.locale, L10n.locale)
|
|
}
|
|
|
|
private var dayView: some View {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Label(tr("日历"), systemImage: "calendar")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
Spacer(minLength: 0)
|
|
Text(entry.date, format: .dateTime.day())
|
|
.font(.system(size: 50, weight: .medium, design: .rounded))
|
|
.minimumScaleFactor(0.7)
|
|
Text(entry.date, format: .dateTime.weekday(.wide).month(.wide))
|
|
.font(.subheadline)
|
|
.lineLimit(1)
|
|
if entry.showsLunar {
|
|
Text(lunarDate)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
if let occasion = occasion {
|
|
Text(occasion)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(.orange)
|
|
.lineLimit(1)
|
|
}
|
|
if entry.showsSeasonal, let seasons = entry.today?.seasonalDescriptions, !seasons.isEmpty {
|
|
Text(seasons.map(tr).joined(separator: " · "))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.75)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.padding(12)
|
|
}
|
|
|
|
private var monthView: some View {
|
|
HStack(alignment: .top, spacing: 14) {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
Label(tr("日历"), systemImage: "calendar")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
Text(entry.date, format: .dateTime.day())
|
|
.font(.system(size: 50, weight: .medium, design: .rounded))
|
|
Text(entry.date, format: .dateTime.weekday(.wide).month(.wide))
|
|
.font(.caption)
|
|
.lineLimit(1)
|
|
if entry.showsLunar {
|
|
Text(lunarDate).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
}
|
|
if let occasion {
|
|
Text(occasion).font(.caption.weight(.medium)).foregroundStyle(.orange).lineLimit(1)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
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)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
.padding(12)
|
|
}
|
|
|
|
private func advice(_ title: String, values: [String]) -> some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 5) {
|
|
Text(title).fontWeight(.semibold).foregroundStyle(.secondary)
|
|
Text(values.isEmpty ? tr("无") : values.joined(separator: " · "))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
}
|
|
.font(.caption2)
|
|
}
|
|
|
|
private var occasion: String? {
|
|
if let festival = entry.today?.festivals.first { return tr(festival) }
|
|
if let term = entry.today?.solarTerm { return tr(term) }
|
|
return nil
|
|
}
|
|
|
|
private var lunarDate: String {
|
|
let formatter = DateFormatter()
|
|
formatter.calendar = Calendar(identifier: .chinese)
|
|
formatter.locale = L10n.locale
|
|
formatter.dateFormat = "MMMMd"
|
|
return formatter.string(from: entry.date)
|
|
}
|
|
}
|
|
|
|
struct CalendarWidget: Widget {
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: WidgetKind.calendar, provider: CalendarWidgetProvider()) { entry in
|
|
CalendarWidgetView(entry: entry)
|
|
}
|
|
.configurationDisplayName(tr("日历"))
|
|
.description(tr("查看今天的节日与黄历。"))
|
|
.supportedFamilies([.systemSmall, .systemMedium])
|
|
}
|
|
}
|
|
|
|
private struct MonthCalendarWidgetView: View {
|
|
let entry: CalendarWidgetEntry
|
|
|
|
private var firstWeekday: Int {
|
|
(1...7).contains(entry.firstWeekday) ? entry.firstWeekday : Calendar.current.firstWeekday
|
|
}
|
|
|
|
private var calendar: Calendar { WidgetSnapshot.keyCalendar }
|
|
private var monthKey: String { WidgetSnapshot.monthKey(for: entry.date) }
|
|
private var todayKey: String { WidgetSnapshot.dayKey(for: entry.date) }
|
|
|
|
private var days: [WidgetSnapshot.MonthDay] {
|
|
if let month = entry.month, month.days.count == 42 { return month.days }
|
|
let parts = calendar.dateComponents([.year, .month], from: entry.date)
|
|
guard let year = parts.year, let month = parts.month,
|
|
let first = calendar.date(from: DateComponents(year: year, month: month, day: 1, hour: 12)) else { return [] }
|
|
let offset = (calendar.component(.weekday, from: first) - firstWeekday + 7) % 7
|
|
return (0..<42).compactMap { index in
|
|
guard let date = calendar.date(byAdding: .day, value: index - offset, to: first) else { return nil }
|
|
let parts = calendar.dateComponents([.year, .month, .day, .weekday], from: date)
|
|
guard let year = parts.year, let month = parts.month, let day = parts.day,
|
|
let weekday = parts.weekday else { return nil }
|
|
return WidgetSnapshot.MonthDay(dateKey: WidgetSnapshot.dayKey(year: year, month: month, day: day),
|
|
number: day, subtitle: "", holidayName: nil, isWork: nil,
|
|
isWeekend: weekday == 1 || weekday == 7)
|
|
}
|
|
}
|
|
|
|
private var weekdays: [String] {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = L10n.locale
|
|
formatter.calendar = calendar
|
|
return (0..<7).map { offset in
|
|
let weekday = (firstWeekday - 1 + offset) % 7
|
|
return formatter.shortWeekdaySymbols[weekday]
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 9) {
|
|
HStack(spacing: 7) {
|
|
Image(systemName: "calendar").foregroundStyle(Color.accentColor)
|
|
Text(entry.date, format: .dateTime.year().month(.wide))
|
|
.font(.system(size: 17, weight: .semibold))
|
|
Spacer(minLength: 0)
|
|
}
|
|
HStack(spacing: 3) {
|
|
ForEach(Array(weekdays.enumerated()), id: \.offset) { _, name in
|
|
Text(name).font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.secondary).lineLimit(1).frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 3), count: 7), spacing: 4) {
|
|
ForEach(days, id: \.dateKey) { day in
|
|
dayCell(day)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(14)
|
|
.containerBackground(.background, for: .widget)
|
|
.environment(\.locale, L10n.locale)
|
|
}
|
|
|
|
private func dayCell(_ day: WidgetSnapshot.MonthDay) -> some View {
|
|
let isToday = day.dateKey == todayKey
|
|
let isRest = day.isWork.map { !$0 } ?? day.isWeekend
|
|
return VStack(spacing: 2) {
|
|
Text(String(day.number))
|
|
.font(.system(size: 15, weight: isToday ? .semibold : .regular, design: .rounded))
|
|
.foregroundStyle(isToday ? Color.white : isRest ? Color.red : Color.primary)
|
|
.frame(width: 24, height: 24)
|
|
.background(isToday ? Color.accentColor : .clear, in: Circle())
|
|
Text(day.subtitle.isEmpty ? " " : tr(day.subtitle))
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(day.holidayName == nil ? Color.secondary : Color.red)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.7)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 40)
|
|
.overlay(alignment: .topTrailing) {
|
|
if let isWork = day.isWork {
|
|
Text(tr(isWork ? "班" : "休"))
|
|
.font(.system(size: 8, weight: .semibold))
|
|
.foregroundStyle(isWork ? Color.secondary : Color.red)
|
|
}
|
|
}
|
|
.opacity(day.dateKey.hasPrefix(monthKey) ? 1 : 0.38)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel([day.dateKey, tr(day.subtitle), day.isWork.map { tr($0 ? "调休上班" : "放假") }]
|
|
.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "))
|
|
}
|
|
}
|
|
|
|
struct MonthCalendarWidget: Widget {
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: WidgetKind.calendarMonth, provider: CalendarWidgetProvider()) { entry in
|
|
MonthCalendarWidgetView(entry: entry)
|
|
}
|
|
.configurationDisplayName(tr("月历"))
|
|
.description(tr("查看整月的公历、农历与节假日。"))
|
|
.supportedFamilies([.systemLarge])
|
|
}
|
|
}
|
|
|
|
private struct TomorrowWorkWidgetView: View {
|
|
let entry: CalendarWidgetEntry
|
|
|
|
private var needsWork: Bool? { entry.tomorrow?.schedule.needsWork }
|
|
private var tomorrowDate: Date {
|
|
WidgetSnapshot.keyCalendar.date(byAdding: .day, value: 1, to: entry.date) ?? entry.date
|
|
}
|
|
|
|
private var scheduleTitle: String? {
|
|
guard let schedule = entry.tomorrow?.schedule else { return nil }
|
|
return switch schedule {
|
|
case .work: tr("工作日")
|
|
case .weekend: entry.tomorrow?.holidayName.flatMap { $0.isEmpty ? nil : tr($0) } ?? tr("周末")
|
|
case .holiday: entry.tomorrow?.holidayName.flatMap { $0.isEmpty ? nil : tr($0) } ?? tr("节假日")
|
|
case .makeupWork: tr("调休补班")
|
|
case .makeupDayOff: tr("调休放假")
|
|
case .dayOff: tr("放假")
|
|
case .unknown, .holidaysDisabled: nil
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(tr("明天上班吗"))
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
Text(tomorrowDate, format: .dateTime.month(.abbreviated).day().weekday(.abbreviated))
|
|
.font(.subheadline.weight(.medium))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
Spacer(minLength: 0)
|
|
if let needsWork {
|
|
Text(tr(needsWork ? "上班" : "不上班"))
|
|
.font(.system(size: 27, weight: .semibold, design: .rounded))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
if let scheduleTitle {
|
|
Text(scheduleTitle)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.75)
|
|
}
|
|
} else {
|
|
Text(tr(entry.tomorrow?.schedule == .holidaysDisabled ? "仅支持中国大陆节假日" : "暂无法判断"))
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.padding(14)
|
|
.containerBackground(.background, for: .widget)
|
|
.environment(\.locale, L10n.locale)
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|
|
|
|
struct TomorrowWorkWidget: Widget {
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: WidgetKind.tomorrowWork, provider: CalendarWidgetProvider()) { entry in
|
|
TomorrowWorkWidgetView(entry: entry)
|
|
}
|
|
.configurationDisplayName(tr("明天上班吗"))
|
|
.description(tr("查看明天是否上班或放假。"))
|
|
.supportedFamilies([.systemSmall])
|
|
}
|
|
}
|