548 lines
18 KiB
Swift
548 lines
18 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
import MacToolsPluginKit
|
||
|
||
struct CalendarComponentView: View {
|
||
private enum Layout {
|
||
static let contentPadding: CGFloat = 8
|
||
static let sectionSpacing: CGFloat = 3
|
||
static let gridSpacing: CGFloat = 6
|
||
static let headerHeight: CGFloat = 20
|
||
static let weekdayHeight: CGFloat = 10
|
||
static let dayCellSize: CGFloat = 36
|
||
static let cornerRadius: CGFloat = PluginComponentPanelLayoutMetrics.cardCornerRadius
|
||
}
|
||
|
||
@ObservedObject private var viewModel: CalendarComponentViewModel
|
||
private let localization: PluginLocalization
|
||
|
||
init(
|
||
context: PluginComponentContext,
|
||
viewModel: CalendarComponentViewModel,
|
||
localization: PluginLocalization = PluginLocalization(bundle: .main)
|
||
) {
|
||
self.viewModel = viewModel
|
||
self.localization = localization
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
calendarCard
|
||
Spacer(minLength: 0)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
}
|
||
|
||
private var calendarCard: some View {
|
||
VStack(spacing: Layout.sectionSpacing) {
|
||
CalendarHeaderView(
|
||
title: viewModel.month.title,
|
||
localization: localization,
|
||
onPrevious: { viewModel.moveMonth(by: -1) },
|
||
onToday: { viewModel.goToToday() },
|
||
onNext: { viewModel.moveMonth(by: 1) }
|
||
)
|
||
|
||
CalendarWeekdayRow(
|
||
symbols: viewModel.month.weekdaySymbols,
|
||
dayCellSize: Layout.dayCellSize,
|
||
gridSpacing: Layout.gridSpacing
|
||
)
|
||
|
||
CalendarMonthGrid(
|
||
days: viewModel.month.days,
|
||
selectedDayID: viewModel.selectedDay?.id,
|
||
dayCellSize: Layout.dayCellSize,
|
||
gridSpacing: Layout.gridSpacing,
|
||
localization: localization,
|
||
onSelect: { viewModel.select($0) },
|
||
onOpen: { viewModel.open($0) }
|
||
)
|
||
}
|
||
.padding(Layout.contentPadding)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
.background(CalendarComponentBackground(cornerRadius: Layout.cornerRadius))
|
||
}
|
||
|
||
}
|
||
|
||
private struct CalendarHeaderView: View {
|
||
let title: String
|
||
let localization: PluginLocalization
|
||
let onPrevious: () -> Void
|
||
let onToday: () -> Void
|
||
let onNext: () -> Void
|
||
|
||
var body: some View {
|
||
HStack(spacing: 6) {
|
||
Text(title)
|
||
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.lineLimit(1)
|
||
|
||
CalendarIconButton(
|
||
systemName: "chevron.left",
|
||
help: localization.string("header.previous.help", defaultValue: "上个月"),
|
||
action: onPrevious
|
||
)
|
||
Button(localization.string("header.today.button", defaultValue: "今天"), action: onToday)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(.secondary)
|
||
.buttonStyle(.plain)
|
||
.frame(width: 32, height: 20)
|
||
.contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||
.help(localization.string("header.today.help", defaultValue: "回到今天"))
|
||
CalendarIconButton(
|
||
systemName: "chevron.right",
|
||
help: localization.string("header.next.help", defaultValue: "下个月"),
|
||
action: onNext
|
||
)
|
||
}
|
||
.frame(height: 20)
|
||
}
|
||
}
|
||
|
||
private struct CalendarIconButton: View {
|
||
let systemName: String
|
||
let help: String
|
||
let action: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
Image(systemName: systemName)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 20, height: 20)
|
||
.contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help(help)
|
||
}
|
||
}
|
||
|
||
private struct CalendarWeekdayRow: View {
|
||
let symbols: [String]
|
||
let dayCellSize: CGFloat
|
||
let gridSpacing: CGFloat
|
||
|
||
var body: some View {
|
||
HStack(spacing: gridSpacing) {
|
||
ForEach(Array(symbols.enumerated()), id: \.offset) { _, symbol in
|
||
Text(symbol)
|
||
.font(.system(size: 9, weight: .semibold))
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: dayCellSize)
|
||
}
|
||
}
|
||
.frame(height: 10)
|
||
}
|
||
}
|
||
|
||
private struct CalendarMonthGrid: View {
|
||
let days: [CalendarDayModel]
|
||
let selectedDayID: String?
|
||
let dayCellSize: CGFloat
|
||
let gridSpacing: CGFloat
|
||
let localization: PluginLocalization
|
||
let onSelect: (CalendarDayModel) -> Void
|
||
let onOpen: (CalendarDayModel) -> Void
|
||
|
||
@State private var hoveredDayID: String?
|
||
|
||
private var columns: [GridItem] {
|
||
Array(
|
||
repeating: GridItem(.fixed(dayCellSize), spacing: gridSpacing),
|
||
count: 7
|
||
)
|
||
}
|
||
|
||
var body: some View {
|
||
LazyVGrid(columns: columns, spacing: gridSpacing) {
|
||
ForEach(days) { day in
|
||
CalendarDayCell(
|
||
day: day,
|
||
isSelected: selectedDayID == day.id,
|
||
localization: localization,
|
||
onOpen: { onOpen(day) }
|
||
)
|
||
.frame(width: dayCellSize, height: dayCellSize)
|
||
.background(
|
||
CalendarEventPopoverPresenter(
|
||
title: CalendarDayPresentation.dateTitle(for: day),
|
||
subtitle: CalendarDayPresentation.dateSubtitle(for: day, localization: localization),
|
||
events: day.events,
|
||
localization: localization,
|
||
isPresented: hoveredDayID == day.id && !day.events.isEmpty
|
||
)
|
||
)
|
||
.onHover { isHovered in
|
||
if isHovered {
|
||
hoveredDayID = day.id
|
||
onSelect(day)
|
||
} else if hoveredDayID == day.id {
|
||
hoveredDayID = nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
|
||
private struct CalendarDayCell: View {
|
||
let day: CalendarDayModel
|
||
let isSelected: Bool
|
||
let localization: PluginLocalization
|
||
let onOpen: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: onOpen) {
|
||
ZStack(alignment: .topTrailing) {
|
||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||
.fill(backgroundColor)
|
||
|
||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||
.strokeBorder(borderColor, lineWidth: day.isToday ? 1.5 : 0)
|
||
|
||
VStack(spacing: 1) {
|
||
Text(day.dayNumber)
|
||
.font(.system(size: 13, weight: day.isToday ? .bold : .semibold, design: .rounded))
|
||
.foregroundStyle(primaryTextStyle)
|
||
.lineLimit(1)
|
||
|
||
if !day.lunarText.isEmpty {
|
||
Text(day.lunarText)
|
||
.font(.system(size: 8.5, weight: .medium))
|
||
.foregroundStyle(secondaryTextStyle)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.65)
|
||
}
|
||
|
||
CalendarEventDots(events: day.visibleEvents)
|
||
.padding(.top, 1)
|
||
}
|
||
.padding(.horizontal, 2)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
|
||
if let holidayKind = day.holidayKind {
|
||
CalendarHolidayBadge(kind: holidayKind, localization: localization)
|
||
.offset(x: 2, y: -3)
|
||
}
|
||
}
|
||
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(accessibilityLabel)
|
||
}
|
||
|
||
private var backgroundColor: Color {
|
||
if isSelected {
|
||
return Color.accentColor.opacity(0.16)
|
||
}
|
||
|
||
if day.isToday {
|
||
return Color.accentColor.opacity(0.08)
|
||
}
|
||
|
||
return Color(nsColor: .controlBackgroundColor).opacity(day.isInDisplayedMonth ? 0.35 : 0.12)
|
||
}
|
||
|
||
private var borderColor: Color {
|
||
day.isToday ? Color.accentColor.opacity(0.95) : .clear
|
||
}
|
||
|
||
private var primaryTextStyle: HierarchicalShapeStyle {
|
||
if day.isInDisplayedMonth {
|
||
return day.isWeekend ? .secondary : .primary
|
||
}
|
||
|
||
return .tertiary
|
||
}
|
||
|
||
private var secondaryTextStyle: HierarchicalShapeStyle {
|
||
day.isInDisplayedMonth ? .secondary : .tertiary
|
||
}
|
||
|
||
private var accessibilityLabel: String {
|
||
var parts = [day.dayNumber]
|
||
if !day.lunarText.isEmpty {
|
||
parts.append(day.lunarText)
|
||
}
|
||
if day.isToday {
|
||
parts.append(localization.string("accessibility.today", defaultValue: "今天"))
|
||
}
|
||
if let holidayKind = day.holidayKind {
|
||
parts.append(CalendarDayPresentation.holidayText(for: holidayKind, localization: localization))
|
||
}
|
||
if !day.events.isEmpty {
|
||
parts.append(
|
||
localization.format(
|
||
"accessibility.eventCount",
|
||
defaultValue: "%d 个日程",
|
||
day.events.count
|
||
)
|
||
)
|
||
}
|
||
return parts.joined(
|
||
separator: localization.string("list.separator.comma", defaultValue: ",")
|
||
)
|
||
}
|
||
}
|
||
|
||
private struct CalendarHolidayBadge: View {
|
||
let kind: CalendarHolidayKind
|
||
let localization: PluginLocalization
|
||
|
||
var body: some View {
|
||
Text(kind.badgeText(localization: localization))
|
||
.font(.system(size: 7, weight: .bold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: 14, height: 14)
|
||
.background(
|
||
Circle()
|
||
.fill(kind == .holiday ? Color(nsColor: .systemTeal) : Color(nsColor: .systemOrange))
|
||
.shadow(color: .black.opacity(0.12), radius: 1, y: 0.5)
|
||
)
|
||
}
|
||
}
|
||
|
||
private struct CalendarEventDots: View {
|
||
private static let dotSize: CGFloat = 3
|
||
private static let rowHeight: CGFloat = 5
|
||
|
||
let events: [CalendarEventSummary]
|
||
|
||
var body: some View {
|
||
HStack(spacing: 2) {
|
||
if events.isEmpty {
|
||
Color.clear
|
||
.frame(width: Self.dotSize, height: Self.dotSize)
|
||
} else {
|
||
ForEach(events) { event in
|
||
Circle()
|
||
.fill(Color(calendarEventColor: event.color))
|
||
.frame(width: Self.dotSize, height: Self.dotSize)
|
||
}
|
||
}
|
||
}
|
||
.frame(height: Self.rowHeight)
|
||
}
|
||
}
|
||
|
||
private struct CalendarEventPopoverPresenter: NSViewRepresentable {
|
||
let title: String
|
||
let subtitle: String
|
||
let events: [CalendarEventSummary]
|
||
let localization: PluginLocalization
|
||
let isPresented: Bool
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator()
|
||
}
|
||
|
||
func makeNSView(context: Context) -> NSView {
|
||
NSView(frame: .zero)
|
||
}
|
||
|
||
func updateNSView(_ nsView: NSView, context: Context) {
|
||
context.coordinator.update(
|
||
title: title,
|
||
subtitle: subtitle,
|
||
events: events,
|
||
localization: localization,
|
||
isPresented: isPresented,
|
||
sourceView: nsView
|
||
)
|
||
}
|
||
|
||
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
|
||
coordinator.close()
|
||
}
|
||
|
||
final class Coordinator {
|
||
private var popover: NSPopover?
|
||
|
||
@MainActor
|
||
func update(
|
||
title: String,
|
||
subtitle: String,
|
||
events: [CalendarEventSummary],
|
||
localization: PluginLocalization,
|
||
isPresented: Bool,
|
||
sourceView: NSView
|
||
) {
|
||
guard isPresented, !events.isEmpty, sourceView.window != nil else {
|
||
close()
|
||
return
|
||
}
|
||
|
||
let popover = popover ?? makePopover()
|
||
let content = CalendarFloatingEventPopoverContent(
|
||
title: title,
|
||
subtitle: subtitle,
|
||
events: events,
|
||
localization: localization
|
||
)
|
||
let hostingController = NSHostingController(rootView: content)
|
||
CalendarAppearancePreference.stored().apply(to: hostingController.view)
|
||
popover.contentViewController = hostingController
|
||
popover.contentSize = NSSize(width: 230, height: min(hostingController.view.fittingSize.height, 260))
|
||
CalendarAppearancePreference.stored().apply(to: popover)
|
||
|
||
if !popover.isShown {
|
||
popover.show(relativeTo: sourceView.bounds, of: sourceView, preferredEdge: .maxY)
|
||
CalendarAppearancePreference.stored().apply(to: popover)
|
||
}
|
||
|
||
self.popover = popover
|
||
}
|
||
|
||
@MainActor
|
||
func close() {
|
||
popover?.performClose(nil)
|
||
popover = nil
|
||
}
|
||
|
||
@MainActor
|
||
private func makePopover() -> NSPopover {
|
||
let popover = NSPopover()
|
||
popover.behavior = .applicationDefined
|
||
popover.animates = false
|
||
return popover
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct CalendarFloatingEventPopoverContent: View {
|
||
let title: String
|
||
let subtitle: String
|
||
let events: [CalendarEventSummary]
|
||
let localization: PluginLocalization
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 7) {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(title)
|
||
.font(.system(size: 11, weight: .semibold))
|
||
.lineLimit(1)
|
||
|
||
Text(subtitle)
|
||
.font(.system(size: 10, weight: .medium))
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
|
||
Divider()
|
||
|
||
VStack(alignment: .leading, spacing: 5) {
|
||
ForEach(Array(events.prefix(6))) { event in
|
||
CalendarEventRow(event: event)
|
||
}
|
||
|
||
if events.count > 6 {
|
||
Text(
|
||
localization.format(
|
||
"event.moreCount",
|
||
defaultValue: "还有 %d 个日程",
|
||
events.count - 6
|
||
)
|
||
)
|
||
.font(.system(size: 10, weight: .medium))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.padding(10)
|
||
.frame(width: 230, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
private enum CalendarDayPresentation {
|
||
static func dateTitle(for day: CalendarDayModel) -> String {
|
||
let formatter = DateFormatter()
|
||
formatter.locale = PluginRuntimeLocalization.locale
|
||
formatter.dateStyle = .medium
|
||
formatter.timeStyle = .none
|
||
return formatter.string(from: day.date)
|
||
}
|
||
|
||
static func dateSubtitle(
|
||
for day: CalendarDayModel,
|
||
localization: PluginLocalization = PluginLocalization(bundle: .main)
|
||
) -> String {
|
||
var parts: [String] = []
|
||
if !day.lunarText.isEmpty {
|
||
parts.append(day.lunarText)
|
||
}
|
||
if let holidayKind = day.holidayKind {
|
||
parts.append(holidayText(for: holidayKind, localization: localization))
|
||
} else if day.isWeekend {
|
||
parts.append(localization.string("day.weekend", defaultValue: "周末"))
|
||
}
|
||
if day.events.count > CalendarDayModel.maximumVisibleEvents {
|
||
parts.append(
|
||
localization.format(
|
||
"event.moreCount",
|
||
defaultValue: "还有 %d 个日程",
|
||
day.events.count - CalendarDayModel.maximumVisibleEvents
|
||
)
|
||
)
|
||
}
|
||
return parts.joined(separator: localization.string("list.separator.dot", defaultValue: " · "))
|
||
}
|
||
|
||
static func holidayText(
|
||
for holidayKind: CalendarHolidayKind,
|
||
localization: PluginLocalization = PluginLocalization(bundle: .main)
|
||
) -> String {
|
||
switch holidayKind {
|
||
case .holiday:
|
||
return localization.string("day.holiday", defaultValue: "休息日")
|
||
case .workday:
|
||
return localization.string("day.workday", defaultValue: "调休工作日")
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct CalendarEventRow: View {
|
||
let event: CalendarEventSummary
|
||
|
||
var body: some View {
|
||
HStack(spacing: 6) {
|
||
Circle()
|
||
.fill(Color(calendarEventColor: event.color))
|
||
.frame(width: 6, height: 6)
|
||
|
||
Text(event.title)
|
||
.font(.system(size: 10.5, weight: .medium))
|
||
.lineLimit(1)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
Text(event.timeText)
|
||
.font(.system(size: 9.5, weight: .medium, design: .rounded))
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct CalendarComponentBackground: View {
|
||
let cornerRadius: CGFloat
|
||
|
||
var body: some View {
|
||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||
.fill(Color.primary.opacity(0.045))
|
||
}
|
||
}
|
||
|
||
private extension Color {
|
||
init(calendarEventColor color: CalendarEventColor) {
|
||
self.init(
|
||
red: color.red,
|
||
green: color.green,
|
||
blue: color.blue,
|
||
opacity: color.alpha
|
||
)
|
||
}
|
||
}
|