- Real Settings (Cmd+,) window via PreferencesView, hosting the pure @AppStorage-backed defaults that used to live in the main window's "General Settings" panel. Both windows share the same live values since they're backed by the same UserDefaults keys. - Menu bar icon reflects idle/syncing/error state; dropdown shows last-sync time/result and whether auto-sync is armed, sourced from the same lastRunAtISO/lastRunOK/lastRunSummary keys --status reads. - ContentView.swift split from ~2000 lines into CalendarsSectionView, RoutesSectionView, ScheduleSectionView, LogSectionView + a small shared CalendarDisplay.swift. View-layer extraction only — state ownership and settings persistence deliberately left alone. - Fixed a latent revert-on-relaunch bug caught while building the Settings window: moving preference controls out of ContentView meant they no longer re-triggered saveSettingsToDefaults(), so settings.v2 could go stale and the next launch would silently revert a just-changed preference via the shared applySnapshot path. Split launch-time restore (routes + selection only) from Import's full restore. All 47 unit tests pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
184 lines
7.4 KiB
Swift
184 lines
7.4 KiB
Swift
import SwiftUI
|
||
import EventKit
|
||
|
||
/// The "Routes (multi-source)" panel — extracted from ContentView so the
|
||
/// route list/editor is its own small, native-feeling view instead of one
|
||
/// piece of a 2000-line file. Owns no persisted state itself: `routes` is a
|
||
/// binding into ContentView's own @State (which still owns saving it to
|
||
/// UserDefaults), and everything else here is read-only context passed down.
|
||
struct RoutesSectionView: View {
|
||
@Binding var routes: [Route]
|
||
let calendars: [EKCalendar]
|
||
let isRunning: Bool
|
||
let titlePrefix: String
|
||
let placeholderTitle: String
|
||
let canAddRoute: Bool
|
||
let onAddRoute: () -> Void
|
||
|
||
private static let intFormatter: NumberFormatter = {
|
||
let f = NumberFormatter()
|
||
f.minimum = 0
|
||
f.maximumFractionDigits = 0
|
||
return f
|
||
}()
|
||
|
||
private func labelForCalendar(id: String) -> String {
|
||
calendars.first(where: { $0.calendarIdentifier == id }).map(calLabel) ?? id
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Text("Routes (multi-source)")
|
||
.font(.headline)
|
||
Spacer()
|
||
Button("Add from current selection", action: onAddRoute)
|
||
.disabled(isRunning || !canAddRoute)
|
||
.buttonStyle(.borderedProminent)
|
||
Button("Clear") { routes.removeAll() }
|
||
.disabled(isRunning || routes.isEmpty)
|
||
.buttonStyle(.bordered)
|
||
}
|
||
if routes.isEmpty {
|
||
Text("No routes yet. Pick a Source and Targets above, then click ‘Add from current selection’.")
|
||
.foregroundStyle(.secondary)
|
||
.padding(.vertical, 8)
|
||
} else {
|
||
LazyVStack(spacing: 10) {
|
||
ForEach($routes, id: \.id) { routeBinding in
|
||
routeCard(for: routeBinding)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func routeCard(for routeBinding: Binding<Route>) -> some View {
|
||
let route = routeBinding.wrappedValue
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
HStack(alignment: .top, spacing: 10) {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
sourceSummaryView(for: route)
|
||
targetSummaryView(for: route)
|
||
}
|
||
Spacer(minLength: 12)
|
||
Button(role: .destructive) {
|
||
routes.removeAll { $0.id == route.id }
|
||
} label: { Text("Remove") }
|
||
}
|
||
|
||
Divider()
|
||
|
||
Toggle("Private", isOn: routeBinding.privacy)
|
||
.help("If ON, mirror as ‘\(titlePrefix)\(placeholderTitle)’ with no notes. If OFF, mirror source title (and optionally notes).")
|
||
Toggle("Copy description", isOn: routeBinding.copyNotes)
|
||
.disabled(isRunning || route.privacy)
|
||
.help("If ON and Private is OFF, copy the source event’s notes/description into the placeholder.")
|
||
Toggle("Sync reminders", isOn: routeBinding.syncReminders)
|
||
.disabled(isRunning)
|
||
.help("If ON, copy the source event’s reminders/alarms into the placeholder.")
|
||
Toggle("Mirror all-day events for this route", isOn: routeBinding.allDay)
|
||
.disabled(isRunning)
|
||
.help("Mirror all-day events for this source.")
|
||
|
||
HStack(spacing: 16) {
|
||
mergeGapField(for: routeBinding)
|
||
overlapPicker(for: routeBinding)
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
.padding(12)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.fill(Color(nsColor: .controlBackgroundColor))
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.stroke(Color.primary.opacity(0.25), lineWidth: 1.1)
|
||
)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func sourceSummaryView(for route: Route) -> some View {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text("Source")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
if let sCal = calendars.first(where: { $0.calendarIdentifier == route.sourceID }) {
|
||
HStack(spacing: 6) {
|
||
Circle().fill(calColor(sCal)).frame(width: 10, height: 10)
|
||
Text(calLabel(sCal))
|
||
.fontWeight(.semibold)
|
||
}
|
||
} else {
|
||
Text(labelForCalendar(id: route.sourceID))
|
||
.fontWeight(.semibold)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func targetSummaryView(for route: Route) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("Targets")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
ForEach(route.targetIDs.sorted(by: <), id: \.self) { tid in
|
||
if let tCal = calendars.first(where: { $0.calendarIdentifier == tid }) {
|
||
HStack(spacing: 6) {
|
||
Circle().fill(calColor(tCal)).frame(width: 9, height: 9)
|
||
Text(calLabel(tCal))
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 5)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 999, style: .continuous)
|
||
.fill(Color.primary.opacity(0.1))
|
||
)
|
||
} else {
|
||
Text(labelForCalendar(id: tid))
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 5)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 999, style: .continuous)
|
||
.fill(Color.primary.opacity(0.1))
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func mergeGapField(for routeBinding: Binding<Route>) -> some View {
|
||
HStack(spacing: 8) {
|
||
Text("Merge gap")
|
||
TextField("0", value: routeBinding.mergeGapHours, formatter: Self.intFormatter)
|
||
.frame(width: 56)
|
||
.disabled(isRunning)
|
||
.help("Merge adjacent source events separated by ≤ this many hours (e.g., flight legs). 0 = no merge.")
|
||
Text("h").foregroundStyle(.secondary)
|
||
}
|
||
.font(.subheadline)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func overlapPicker(for routeBinding: Binding<Route>) -> some View {
|
||
HStack(spacing: 8) {
|
||
Text("Overlap")
|
||
Picker("Overlap", selection: routeBinding.overlap) {
|
||
ForEach(OverlapMode.allCases) { mode in
|
||
Text(mode.rawValue).tag(mode)
|
||
}
|
||
}
|
||
.frame(width: 170)
|
||
.help("allow = always place; skipCovered = skip if target already has a block covering the time; fillGaps = only fill uncovered gaps within the source block.")
|
||
}
|
||
.font(.subheadline)
|
||
}
|
||
}
|