Files
2026-09-08 20:06:58 +01:00

234 lines
7.9 KiB
Swift

import SwiftUI
import SwiftData
struct ContentView: View {
let startupStorageError: String?
@EnvironmentObject private var automation: BackgroundSyncCoordinator
@Environment(\.openWindow) private var openWindow
@State private var selectedJob: SyncJob?
@State private var section: AppSection = .jobs
@State private var showingNewJob = false
@State private var showingSplash = true
@State private var showingStorageAlert = false
init(startupStorageError: String? = nil) {
self.startupStorageError = startupStorageError
}
var body: some View {
ZStack {
mainContent
.opacity(showingSplash ? 0 : 1)
if showingSplash {
SplashView()
.transition(.opacity.combined(with: .scale(scale: 1.01)))
.zIndex(10)
}
}
.sheet(isPresented: $showingNewJob) { JobEditorView() }
.alert("Storage Recovery Mode", isPresented: $showingStorageAlert) {
Button("OK", role: .cancel) {}
} message: {
Text(startupStorageError ?? "Rsync Studio is using temporary storage.")
}
.onAppear { showingStorageAlert = startupStorageError != nil }
.task {
guard showingSplash else { return }
try? await Task.sleep(for: .seconds(1.0))
withAnimation(.easeOut(duration: 0.28)) { showingSplash = false }
}
}
private var mainContent: some View {
VStack(spacing: 0) {
sectionHeader
Divider()
switch section {
case .jobs:
HSplitView {
JobListView(selection: $selectedJob, showingNewJob: $showingNewJob)
.frame(minWidth: 320, idealWidth: 360, maxWidth: 390)
Group {
if let selectedJob {
JobDetailView(job: selectedJob)
} else {
emptyJobsView
}
}
.frame(minWidth: 620, maxWidth: .infinity, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .calendar:
ScheduleCalendarView()
case .history:
RunHistoryView(onBack: { section = .jobs })
}
}
.background(Color(nsColor: .windowBackgroundColor))
}
private var emptyJobsView: some View {
VStack(spacing: 20) {
ZStack {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color.accentColor.opacity(0.10))
.frame(width: 92, height: 92)
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 42, weight: .medium))
.foregroundStyle(Color.accentColor)
}
VStack(spacing: 7) {
Text("Select a Sync Job")
.font(.title2.bold())
Text("Choose a job from the list to review its paths, automation and recent status.")
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 430)
}
Button {
showingNewJob = true
} label: {
Label("New Sync Job", systemImage: "plus")
.frame(minWidth: 120)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(40)
}
private var sectionHeader: some View {
HStack(spacing: 18) {
HStack(spacing: 11) {
Image(systemName: "arrow.triangle.2.circlepath.circle.fill")
.font(.system(size: 27, weight: .medium))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(Color.accentColor)
VStack(alignment: .leading, spacing: 1) {
Text("Rsync Studio")
.font(.headline)
Text(section.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if automation.activity != .idle {
HStack(spacing: 7) {
AnimatedSyncIndicator(size: 18, lineWidth: 2.6)
Text(automation.activityDisplayTitle)
.font(.caption.weight(.medium))
.lineLimit(1)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.thinMaterial, in: Capsule())
}
if let serviceError = automation.lastError {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Color.orange)
.help(serviceError)
.accessibilityLabel("Background service error")
}
Picker("Section", selection: $section) {
Label("Jobs", systemImage: "rectangle.stack").tag(AppSection.jobs)
Label("Calendar", systemImage: "calendar").tag(AppSection.calendar)
Label("History", systemImage: "clock.arrow.circlepath").tag(AppSection.history)
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(width: 390)
Button {
openWindow(id: "help")
} label: {
Label("Help", systemImage: "questionmark.circle")
}
.buttonStyle(.bordered)
.help("Open Rsync Studio Help")
}
.padding(.horizontal, 18)
.frame(height: 70)
.background(.bar)
}
}
enum AppSection: Hashable {
case jobs, calendar, history
var subtitle: String {
switch self {
case .jobs: return "Jobs & Automation"
case .calendar: return "Schedule Calendar"
case .history: return "Run History"
}
}
}
struct AnimatedSyncIndicator: View {
let size: CGFloat
var lineWidth: CGFloat = 7
@State private var rotation = 0.0
@State private var pulse = false
var body: some View {
ZStack {
Circle()
.stroke(Color.accentColor.opacity(0.10), lineWidth: lineWidth)
Circle()
.trim(from: 0.05, to: 0.76)
.stroke(
AngularGradient(
colors: [
Color.accentColor.opacity(0.18),
Color.accentColor,
Color.accentColor.opacity(0.40)
],
center: .center
),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(rotation))
Circle()
.fill(Color.accentColor)
.frame(width: max(4, lineWidth * 1.05), height: max(4, lineWidth * 1.05))
.offset(y: -(size / 2))
.rotationEffect(.degrees(rotation + 18))
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: size * 0.31, weight: .semibold))
.foregroundStyle(Color.accentColor)
.scaleEffect(pulse ? 1.04 : 0.94)
.opacity(pulse ? 1 : 0.72)
}
.frame(width: size, height: size)
.onAppear {
rotation = 0
pulse = false
withAnimation(.linear(duration: 1.15).repeatForever(autoreverses: false)) {
rotation = 360
}
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
pulse = true
}
}
}
}