Sources/OrgstarMobile/SettingsScreen.swift
161 lines · 7721 bytes
1#if os(iOS)
2import OrgApp
3import OrgCore
4import OrgPresentation
5import SwiftUI
6import UIKit
7
8/// Where settings come from: a folder with the Mac's `config.toml` and `capture.toml`, kept in
9/// sync by iCloud Drive or another app.
10struct SettingsScreen: View {
11 let model: MobileModel
12 @State private var choosing = false
13 @State private var calendars: [CalendarChoice] = []
14
15 private struct CalendarChoice {
16 let id: String
17 let title: String
18 let color: RGBA?
19 }
20
21 var body: some View {
22 Form {
23 Section {
24 LabeledContent("Folder", value: model.configFolder?.lastPathComponent ?? "None")
25 Button(model.configFolder == nil ? "Choose Folder…" : "Choose Another Folder…") { choosing = true }
26 if model.configFolder != nil {
27 Button("Stop Using This Folder", role: .destructive) { model.setConfigFolder(nil) }
28 }
29 ForEach(model.configProblems, id: \.self) { Text($0).foregroundStyle(.red) }
30 } header: {
31 Text("Configuration")
32 } footer: {
33 Text("A folder with config.toml and capture.toml, such as a synced copy of ~/.config/orgstar from your Mac. Changes to them apply as they sync.")
34 }
35 Section("Agenda") {
36 let _ = model.settingsVersion
37 Toggle("Show days without entries", isOn: Binding {
38 OrgPreferences.agendaShowAllDates()
39 } set: { model.set("agendaShowAllDates", to: .bool($0)) })
40 Picker("Blocked entries", selection: Binding {
41 UserDefaults.standard.string(forKey: "agendaDimBlockedTasks") ?? "t"
42 } set: { model.set("agendaDimBlockedTasks", to: .string($0)) }) {
43 Text("Dimmed").tag("t")
44 Text("Hidden").tag("invisible")
45 Text("Shown as usual").tag("nil")
46 }
47 }
48 Section {
49 let _ = model.settingsVersion
50 Toggle("Not done before TODO children", isOn: Binding {
51 UserDefaults.standard.bool(forKey: "enforceTodoDependencies")
52 } set: { model.set("enforceTodoDependencies", to: .bool($0)) })
53 Toggle("Not done with unchecked boxes", isOn: Binding {
54 UserDefaults.standard.bool(forKey: "enforceTodoCheckboxDependencies")
55 } set: { model.set("enforceTodoCheckboxDependencies", to: .bool($0)) })
56 } header: {
57 Text("TODO Dependencies")
58 } footer: {
59 Text("org-enforce-todo-dependencies and org-enforce-todo-checkbox-dependencies.")
60 }
61 calendarSection
62 Section("In use") {
63 let _ = model.settingsVersion
64 LabeledContent("TODO keywords", value: todoKeywords)
65 LabeledContent("Agenda", value: agenda)
66 LabeledContent("Reminders", value: OrgPreferences.reminders() ? "\(OrgPreferences.reminderLead()) min before" : "Off")
67 LabeledContent("Capture templates", value: CaptureTemplates.load().templates.map(\.key).joined(separator: " "))
68 LabeledContent("Theme", value: UserDefaults.standard.string(forKey: "themeFile").flatMap { $0.isEmpty ? nil : $0 } ?? "Default")
69 LabeledContent("Font", value: font)
70 }
71 }
72 .navigationTitle("Settings")
73 .fileImporter(isPresented: $choosing, allowedContentTypes: [.folder]) { result in
74 if case .success(let url) = result { model.setConfigFolder(url) }
75 }
76 }
77
78 /// `calendar-events` and `calendar-event-calendars`, written to config.toml.
79 private var calendarSection: some View {
80 let _ = model.settingsVersion
81 let on = OrgPreferences.calendarEvents()
82 let chosen = OrgPreferences.calendarEventCalendars()
83 return Section {
84 Toggle("Show Calendar Events", isOn: Binding {
85 on
86 } set: { value in
87 model.set("calendarEvents", to: .bool(value))
88 if value { Task { await requestCalendars() } }
89 })
90 if on {
91 if !CalendarEvents.isAuthorized {
92 Button("Allow Access to Calendars…") { Task { await requestCalendars() } }
93 }
94 ForEach(calendars, id: \.id) { calendar in
95 let shown = CalendarEvents.chooses(id: calendar.id, title: calendar.title, chosen: chosen)
96 Button {
97 toggle(calendar.title, shown: shown, chosen: chosen)
98 } label: {
99 HStack {
100 Circle().fill(calendar.color?.swiftUI ?? .secondary).frame(width: 10, height: 10)
101 Text(calendar.title).foregroundStyle(.primary)
102 Spacer()
103 if shown { Image(systemName: "checkmark").foregroundStyle(Color.accentColor) }
104 }
105 }
106 .tint(.primary)
107 }
108 }
109 } header: {
110 Text("Calendar")
111 } footer: {
112 Text(on ? "Events from the checked calendars show in the agenda, read-only." : "Show events from Calendar in the agenda, read-only.")
113 }
114 .task(id: on) { loadCalendars() }
115 }
116
117 private func requestCalendars() async {
118 _ = await model.calendarEvents.requestAccess()
119 loadCalendars()
120 model.reloadConfig()
121 }
122
123 private func loadCalendars() {
124 calendars = model.calendarEvents.calendars()
125 .sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
126 .map { CalendarChoice(id: $0.id, title: $0.title, color: $0.color) }
127 }
128
129 /// Shows or hides a calendar's events; with every calendar checked the setting is empty.
130 /// The last checked calendar stays checked, as an empty list means every calendar.
131 private func toggle(_ title: String, shown: Bool, chosen: [String]) {
132 var titles = calendars.filter { CalendarEvents.chooses(id: $0.id, title: $0.title, chosen: chosen) }.map(\.title)
133 titles.removeAll { $0 == title }
134 if !shown { titles.append(title) }
135 guard !titles.isEmpty else { return }
136 let value = Set(titles) == Set(calendars.map(\.title)) ? "" : titles.joined(separator: ", ")
137 model.set("calendarEventCalendars", to: .string(value))
138 }
139
140 /// The span and start day, worded as the Mac's Settings word the start.
141 private var agenda: String {
142 func days(_ count: Int) -> String { count == 1 ? "1 day" : "\(count) days" }
143 let offset = OrgPreferences.agendaStartOffset()
144 let start = offset == 0 ? "today" : offset < 0 ? "\(days(-offset)) before today" : "\(days(offset)) after today"
145 return "\(days(OrgPreferences.agendaSpan())), starting \(start)"
146 }
147
148 /// The theme's family, or the system's monospaced font when it's unset or not on the device.
149 private var font: String {
150 let spec = model.theme
151 let family = spec.font.isEmpty || UIFont.fontNames(forFamilyName: spec.font).isEmpty ? "System monospaced" : spec.font
152 let missing = !spec.font.isEmpty && family != spec.font ? " (\(spec.font) isn't installed)" : ""
153 return "\(family), \(spec.fontSize) pt\(missing)"
154 }
155
156 private var todoKeywords: String {
157 OrgPreferences.orgSettings().todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") }
158 .joined(separator: "; ")
159 }
160}
161#endif