251 lines
8.6 KiB
Swift
Executable File
251 lines
8.6 KiB
Swift
Executable File
import SwiftUI
|
|
import SwiftData
|
|
|
|
struct ContentView: View {
|
|
let startupStorageError: String?
|
|
@EnvironmentObject private var automation: BackgroundSyncCoordinator
|
|
@EnvironmentObject private var licensing: LicensingManager
|
|
@Environment(\.openWindow) private var openWindow
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@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 {
|
|
Group {
|
|
if licensing.canUseApp { mainContent }
|
|
else { LicenseRequiredView() }
|
|
}
|
|
.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 }
|
|
}
|
|
.task {
|
|
await licensing.revalidateIfNeeded()
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(LicensingManager.remoteValidationInterval))
|
|
if Task.isCancelled { return }
|
|
await licensing.revalidateIfNeeded()
|
|
}
|
|
}
|
|
.onChange(of: scenePhase) { _, phase in
|
|
guard phase == .active else { return }
|
|
Task { await licensing.revalidateIfNeeded(force: true) }
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|