Files
busymirror/BusyMirror/PreferencesView.swift
T
tomas.kracmarandClaude Sonnet 5 edddf65288 Release 1.8.0
- 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>
2026-08-26 21:26:07 +02:00

166 lines
7.3 KiB
Swift

import SwiftUI
/// Real macOS Settings (⌘,) window. Holds only the pure, globally-persisted
/// defaults (all @AppStorage, shared automatically with ContentView's copies
/// of the same keys) — anything tied to live session state (routes, the
/// dry-run toggle, calendar access) stays in the main window since it has no
/// meaning outside that window's lifecycle.
struct PreferencesView: View {
@EnvironmentObject private var appController: BusyMirrorAppController
@AppStorage("daysForward") private var daysForward: Int = 7
@AppStorage("daysBack") private var daysBack: Int = 1
@AppStorage("mergeGapHours") private var mergeGapHours: Int = 0
@AppStorage("hideDetails") private var hideDetails: Bool = true
@AppStorage("copyDescription") private var copyDescription: Bool = false
@AppStorage("syncReminders") private var syncReminders: Bool = false
@AppStorage("mirrorAllDay") private var mirrorAllDay: Bool = false
@AppStorage("overlapMode") private var overlapModeRaw: String = OverlapMode.allow.rawValue
@AppStorage("filterByWorkHours") private var filterByWorkHours: Bool = false
@AppStorage("workHoursStart") private var workHoursStart: Int = 9
@AppStorage("workHoursEnd") private var workHoursEnd: Int = 17
@AppStorage("mirrorAcceptedOnly") private var mirrorAcceptedOnly: Bool = false
@AppStorage("excludedTitleFilters") private var excludedTitleFiltersRaw: String = ""
@AppStorage("excludedOrganizerFilters") private var excludedOrganizerFiltersRaw: String = ""
@AppStorage("titlePrefix") private var titlePrefix: String = "🪞 "
@AppStorage("placeholderTitle") private var placeholderTitle: String = "Busy"
@AppStorage("autoDeleteMissing") private var autoDeleteMissing: Bool = true
private static let intFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximumFractionDigits = 0
return f
}()
private static let hourFormatter: NumberFormatter = {
let f = NumberFormatter()
f.minimum = 0
f.maximum = 24
f.maximumFractionDigits = 0
return f
}()
private var disabled: Bool { appController.isSyncing }
private func clampWorkHours() {
let clampedStart = min(max(workHoursStart, 0), 23)
if clampedStart != workHoursStart { workHoursStart = clampedStart }
let clampedEnd = min(max(workHoursEnd, 1), 24)
if clampedEnd != workHoursEnd { workHoursEnd = clampedEnd }
if workHoursEnd <= workHoursStart {
let adjustedEnd = min(workHoursStart + 1, 24)
if workHoursEnd != adjustedEnd { workHoursEnd = adjustedEnd }
}
}
var body: some View {
Form {
Section("Default time window") {
HStack {
Text("Days back")
TextField("1", value: $daysBack, formatter: Self.intFormatter)
.frame(width: 64)
}
.disabled(disabled)
HStack {
Text("Days forward")
TextField("7", value: $daysForward, formatter: Self.intFormatter)
.frame(width: 64)
}
.disabled(disabled)
HStack {
Text("Default merge gap (hours)")
TextField("0", value: $mergeGapHours, formatter: Self.intFormatter)
.frame(width: 64)
}
.disabled(disabled)
}
.onChange(of: daysBack) { v in daysBack = max(0, v) }
.onChange(of: daysForward) { v in daysForward = max(0, v) }
.onChange(of: mergeGapHours) { v in mergeGapHours = max(0, v) }
Section("Mirroring defaults") {
Toggle("Hide details (use \"Busy\" title)", isOn: $hideDetails)
Toggle("Copy description when mirroring", isOn: $copyDescription)
.disabled(hideDetails)
Toggle("Sync reminders when mirroring", isOn: $syncReminders)
Toggle("Mirror all-day events", isOn: $mirrorAllDay)
Toggle("Mirror accepted events only", isOn: $mirrorAcceptedOnly)
Toggle("Auto-delete mirrors if source is removed", isOn: $autoDeleteMissing)
Picker("Overlap mode", selection: $overlapModeRaw) {
ForEach(OverlapMode.allCases) { mode in
Text(mode.rawValue).tag(mode.rawValue)
}
}
}
.disabled(disabled)
Section("Placeholder title") {
HStack {
Text("Title prefix")
TextField("🪞 ", text: $titlePrefix)
.frame(width: 90)
}
HStack {
Text("Placeholder title")
TextField("Busy", text: $placeholderTitle)
.frame(width: 170)
}
}
.disabled(disabled)
Section("Work hours") {
Toggle("Limit mirroring to work hours", isOn: $filterByWorkHours)
if filterByWorkHours {
HStack {
Text("Start hour")
TextField("9", value: $workHoursStart, formatter: Self.hourFormatter)
.frame(width: 56)
Text("End hour")
TextField("17", value: $workHoursEnd, formatter: Self.hourFormatter)
.frame(width: 56)
Text("(local time, end exclusive)").foregroundStyle(.secondary)
}
.onChange(of: workHoursStart) { _ in clampWorkHours() }
.onChange(of: workHoursEnd) { _ in clampWorkHours() }
}
}
.disabled(disabled)
Section("Skip filters") {
VStack(alignment: .leading, spacing: 6) {
Text("Skip source titles (one per line)")
.font(.subheadline.weight(.semibold))
TextEditor(text: $excludedTitleFiltersRaw)
.font(.body)
.frame(minHeight: 70)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
}
VStack(alignment: .leading, spacing: 6) {
Text("Skip organizers (name or email, one per line)")
.font(.subheadline.weight(.semibold))
TextEditor(text: $excludedOrganizerFiltersRaw)
.font(.body)
.frame(minHeight: 70)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.secondary.opacity(0.22))
)
}
Text("Matches are case-insensitive and apply before mirroring.")
.foregroundStyle(.secondary)
.font(.footnote)
}
.disabled(disabled)
}
.formStyle(.grouped)
.frame(width: 480, height: 560)
}
}