221 lines
8.9 KiB
Swift
Executable File
221 lines
8.9 KiB
Swift
Executable File
import SwiftUI
|
|
import SwiftData
|
|
|
|
struct ScheduleCalendarView: View {
|
|
@Query(sort: \SyncJob.name) private var jobs: [SyncJob]
|
|
@State private var displayedMonth = Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: Date())) ?? Date()
|
|
@State private var selectedDay = Date()
|
|
@State private var editingJob: SyncJob?
|
|
|
|
private let calendar = Calendar.current
|
|
private let columns = Array(repeating: GridItem(.flexible(), spacing: 6), count: 7)
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
VStack(spacing: 0) {
|
|
monthHeader
|
|
Divider()
|
|
calendarGrid
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
Divider()
|
|
|
|
agenda
|
|
.frame(width: 320)
|
|
.background(.bar)
|
|
}
|
|
.sheet(item: $editingJob) { job in
|
|
JobEditorView(job: job)
|
|
}
|
|
}
|
|
|
|
private var monthHeader: some View {
|
|
HStack {
|
|
Button { moveMonth(-1) } label: { Image(systemName: "chevron.left") }
|
|
.buttonStyle(.bordered)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(displayedMonth.formatted(.dateTime.month(.wide).year()))
|
|
.font(.title2.bold())
|
|
Text("\(monthOccurrences.count) scheduled runs")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Button("Today") {
|
|
displayedMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: Date())) ?? Date()
|
|
selectedDay = Date()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
Button { moveMonth(1) } label: { Image(systemName: "chevron.right") }
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.padding(18)
|
|
}
|
|
|
|
private var calendarGrid: some View {
|
|
VStack(spacing: 8) {
|
|
LazyVGrid(columns: columns, spacing: 6) {
|
|
ForEach(calendar.shortWeekdaySymbols, id: \.self) { symbol in
|
|
Text(symbol.uppercased())
|
|
.font(.caption2.weight(.bold))
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
ForEach(monthCells.indices, id: \.self) { index in
|
|
if let date = monthCells[index] {
|
|
dayCell(date)
|
|
} else {
|
|
Color.clear.frame(minHeight: 86)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
}
|
|
|
|
private func dayCell(_ date: Date) -> some View {
|
|
let occurrences = occurrences(on: date)
|
|
let selected = calendar.isDate(date, inSameDayAs: selectedDay)
|
|
let today = calendar.isDateInToday(date)
|
|
|
|
return Button {
|
|
selectedDay = date
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text("\(calendar.component(.day, from: date))")
|
|
.font(.body.weight(today ? .bold : .medium))
|
|
.foregroundStyle(today ? Color.accentColor : .primary)
|
|
Spacer()
|
|
if !occurrences.isEmpty {
|
|
Text("\(occurrences.count)")
|
|
.font(.caption2.bold())
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.accentColor.opacity(0.14), in: Capsule())
|
|
}
|
|
}
|
|
|
|
ForEach(Array(occurrences.prefix(2)), id: \.id) { item in
|
|
HStack(spacing: 4) {
|
|
Circle().fill(Color.accentColor).frame(width: 5, height: 5)
|
|
Text(item.job.name)
|
|
.font(.caption2)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
Text(item.date.formatted(date: .omitted, time: .shortened))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(9)
|
|
.frame(maxWidth: .infinity, minHeight: 86, alignment: .topLeading)
|
|
.background(selected ? Color.accentColor.opacity(0.10) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
.overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).stroke(selected ? Color.accentColor.opacity(0.6) : Color.secondary.opacity(0.12)))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var agenda: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("Agenda").font(.title3.bold())
|
|
Text(selectedDay.formatted(date: .complete, time: .omitted))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
let items = occurrences(on: selectedDay)
|
|
if items.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Scheduled Runs",
|
|
systemImage: "calendar",
|
|
description: Text("Select another day or add a schedule to a sync job.")
|
|
)
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(spacing: 10) {
|
|
ForEach(items, id: \.id) { item in
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Image(systemName: item.job.isRemote ? "server.rack" : "folder.fill")
|
|
.foregroundStyle(Color.accentColor)
|
|
Text(item.job.name).fontWeight(.semibold)
|
|
Spacer()
|
|
}
|
|
Label(item.date.formatted(date: .omitted, time: .shortened), systemImage: "clock")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Text(item.job.destinationDisplay)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
HStack {
|
|
Spacer()
|
|
Button("Edit Schedule") { editingJob = item.job }
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.background, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(18)
|
|
}
|
|
|
|
private var monthInterval: DateInterval {
|
|
calendar.dateInterval(of: .month, for: displayedMonth) ?? DateInterval(start: displayedMonth, duration: 2_592_000)
|
|
}
|
|
|
|
private var monthCells: [Date?] {
|
|
let interval = monthInterval
|
|
let days = calendar.range(of: .day, in: .month, for: interval.start) ?? 1..<2
|
|
let weekday = calendar.component(.weekday, from: interval.start)
|
|
let leading = (weekday - calendar.firstWeekday + 7) % 7
|
|
var cells = Array<Date?>(repeating: nil, count: leading)
|
|
for day in days {
|
|
if let date = calendar.date(byAdding: .day, value: day - 1, to: interval.start) { cells.append(date) }
|
|
}
|
|
while cells.count % 7 != 0 { cells.append(nil) }
|
|
return cells
|
|
}
|
|
|
|
private var monthOccurrences: [CalendarOccurrence] {
|
|
scheduledJobs.flatMap { job in
|
|
job.occurrences(from: monthInterval.start, through: monthInterval.end).map { CalendarOccurrence(job: job, date: $0) }
|
|
}.sorted { $0.date < $1.date }
|
|
}
|
|
|
|
private var scheduledJobs: [SyncJob] {
|
|
jobs.filter { $0.isEnabled && $0.scheduleEnabled && $0.scheduleKind != .manual }
|
|
}
|
|
|
|
private func occurrences(on day: Date) -> [CalendarOccurrence] {
|
|
monthOccurrences.filter { calendar.isDate($0.date, inSameDayAs: day) }
|
|
}
|
|
|
|
private func moveMonth(_ offset: Int) {
|
|
if let next = calendar.date(byAdding: .month, value: offset, to: displayedMonth) {
|
|
displayedMonth = next
|
|
selectedDay = next
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct CalendarOccurrence: Identifiable {
|
|
let job: SyncJob
|
|
let date: Date
|
|
var id: String { "\(job.id.uuidString)-\(date.timeIntervalSince1970)" }
|
|
}
|