latest updates
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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)" }
|
||||
}
|
||||
Reference in New Issue
Block a user