Files
RSync-Studio/Rsync Studio/Views/ScheduleCalendarView.swift
2026-09-08 20:06:58 +01:00

221 lines
8.9 KiB
Swift

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)" }
}