krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgstarMobile/SettingsScreen.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgstarMobile/SettingsScreen.swift history · blame · raw

161 lines · 7721 bytes

6 symbols in this file
  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