iOS: config folder settings and reminders !110
9 files changed, +355 −21
Layout: unified · split
Sources/OrgApp/OrgPreferences.swift added +59
| @@ -0,0 +1,59 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgCore | ||
| 3 | |||
| 4 | /// The Org settings `config.toml` holds, read from user defaults with the file's fallbacks, for | ||
| 5 | /// both apps. | ||
| 6 | public enum OrgPreferences { | ||
| 7 | public static let defaultTodoKeywords = "TODO(t) PROJ(p) LOOP(r) STRT(s) WAIT(w) HOLD(h) IDEA(i) | DONE(d) KILL(k)" | ||
| 8 | |||
| 9 | /// TODO keywords for files without `#+TODO`, and alphabetical lists. | ||
| 10 | public static func orgSettings(_ defaults: UserDefaults = .standard) -> OrgSettings { | ||
| 11 | let line = defaults.string(forKey: "todoKeywords") ?? defaultTodoKeywords | ||
| 12 | var settings = OrgSettings.default.withTodoKeywords(line) ?? .default | ||
| 13 | settings.listAllowAlphabetical = bool("alphabeticalLists", defaults) | ||
| 14 | return settings | ||
| 15 | } | ||
| 16 | |||
| 17 | /// What commands run from the agenda and capture follow: logging and tags. | ||
| 18 | public static func options(_ defaults: UserDefaults = .standard) -> EditingOptions { | ||
| 19 | EditingOptions( | ||
| 20 | tagsColumn: integer("tagsColumn", defaults), insertHeadingRespectContent: bool("headingAfterSubtree", defaults), | ||
| 21 | listAllowAlphabetical: bool("alphabeticalLists", defaults), fillColumn: integer("fillColumn", defaults), | ||
| 22 | logDone: LogHow(rawValue: string("logDone", defaults)), logIntoDrawer: string("logIntoDrawer", defaults).nonEmpty, | ||
| 23 | logReschedule: LogHow(rawValue: string("logReschedule", defaults)), logRedeadline: LogHow(rawValue: string("logRedeadline", defaults)) | ||
| 24 | ) | ||
| 25 | } | ||
| 26 | |||
| 27 | public static func agendaSpan(_ defaults: UserDefaults = .standard) -> Int { integer("agendaSpan", defaults) } | ||
| 28 | public static func agendaStartOffset(_ defaults: UserDefaults = .standard) -> Int { integer("agendaStartOffset", defaults) } | ||
| 29 | public static func agendaSubfolders(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSubfolders", defaults) } | ||
| 30 | public static func reminders(_ defaults: UserDefaults = .standard) -> Bool { bool("reminders", defaults) } | ||
| 31 | /// `appt-message-warning-time`, in minutes. | ||
| 32 | public static func reminderLead(_ defaults: UserDefaults = .standard) -> Int { integer("reminderLead", defaults) } | ||
| 33 | |||
| 34 | private static func fallback(_ key: String) -> TOML.Value? { | ||
| 35 | UserConfig.settings.first { $0.defaultsKey == key }?.fallback | ||
| 36 | } | ||
| 37 | |||
| 38 | private static func bool(_ key: String, _ defaults: UserDefaults) -> Bool { | ||
| 39 | defaults.object(forKey: key) as? Bool ?? fallback(key)?.bool ?? false | ||
| 40 | } | ||
| 41 | |||
| 42 | private static func string(_ key: String, _ defaults: UserDefaults) -> String { | ||
| 43 | defaults.string(forKey: key) ?? fallback(key)?.string ?? "" | ||
| 44 | } | ||
| 45 | |||
| 46 | /// Integers, and day offsets stored as integers with `"-3d"` as the fallback. | ||
| 47 | private static func integer(_ key: String, _ defaults: UserDefaults) -> Int { | ||
| 48 | if let value = defaults.object(forKey: key) as? Int { return value } | ||
| 49 | switch fallback(key) { | ||
| 50 | case .integer(let value)?: return value | ||
| 51 | case .string(let text)?: return Int(text.trimmingCharacters(in: CharacterSet(charactersIn: "+d"))) ?? 0 | ||
| 52 | default: return 0 | ||
| 53 | } | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | private extension String { | ||
| 58 | var nonEmpty: String? { isEmpty ? nil : self } | ||
| 59 | } | ||
Sources/OrgApp/UserConfig.swift +4
| @@ -8,7 +8,11 @@ import OrgWorkspace | |||
| 8 | /// dotfiles repository and share. The file is the source of truth: Settings writes its changes | 8 | /// dotfiles repository and share. The file is the source of truth: Settings writes its changes |
| 9 | /// into it, keeping comments and order, and edits to it apply while the app runs. | 9 | /// into it, keeping comments and order, and edits to it apply while the app runs. |
| 10 | public enum UserConfig { | 10 | public enum UserConfig { |
| 11 | /// A folder chosen in the app (on iOS, a synced copy of the Mac's), used over the default. | ||
| 12 | nonisolated(unsafe) public static var directoryOverride: URL? | ||
| 13 | |||
| 11 | public static var directory: URL { | 14 | public static var directory: URL { |
| 15 | if let directoryOverride { return directoryOverride } | ||
| 12 | let environment = ProcessInfo.processInfo.environment | 16 | let environment = ProcessInfo.processInfo.environment |
| 13 | if let override = environment["ORGSTAR_CONFIG_DIR"] { return URL(fileURLWithPath: override) } | 17 | if let override = environment["ORGSTAR_CONFIG_DIR"] { return URL(fileURLWithPath: override) } |
| 14 | // Development runs keep everything in their data folder. | 18 | // Development runs keep everything in their data folder. |
Sources/OrgApp/WorkspaceModel.swift +7 −1
| @@ -21,7 +21,13 @@ public final class WorkspaceModel { | |||
| 21 | public private(set) var changedFile: (path: String, version: Int)? | 21 | public private(set) var changedFile: (path: String, version: Int)? |
| 22 | 22 | ||
| 23 | public let index: IndexStore | 23 | public let index: IndexStore |
| 24 | public let settings: IndexSettings | 24 | /// A change (other TODO keywords) reindexes the files it affects. |
| 25 | public var settings: IndexSettings { | ||
| 26 | didSet { | ||
| 27 | guard settings.version != oldValue.version else { return } | ||
| 28 | Task { await refresh() } | ||
| 29 | } | ||
| 30 | } | ||
| 25 | private let rootsFile: URL | 31 | private let rootsFile: URL |
| 26 | /// Resolved root folders, keyed by their resolved path, which is how the index stores them. | 32 | /// Resolved root folders, keyed by their resolved path, which is how the index stores them. |
| 27 | private var resolvedRoots: [String: URL] = [:] | 33 | private var resolvedRoots: [String: URL] = [:] |
Sources/Orgstar/Preferences.swift +2 −5
| @@ -4,17 +4,14 @@ import OrgCore | |||
| 4 | 4 | ||
| 5 | /// Org preferences, stored in user defaults by the Settings window. | 5 | /// Org preferences, stored in user defaults by the Settings window. |
| 6 | enum Preferences { | 6 | enum Preferences { |
| 7 | static let defaultTodoKeywords = "TODO(t) PROJ(p) LOOP(r) STRT(s) WAIT(w) HOLD(h) IDEA(i) | DONE(d) KILL(k)" | 7 | static let defaultTodoKeywords = OrgPreferences.defaultTodoKeywords |
| 8 | 8 | ||
| 9 | /// TODO keywords for files without `#+TODO`, read at launch. | 9 | /// TODO keywords for files without `#+TODO`, read at launch. |
| 10 | @MainActor | 10 | @MainActor |
| 11 | static var orgSettings: OrgSettings { | 11 | static var orgSettings: OrgSettings { |
| 12 | // config.toml applies before anything reads settings. | 12 | // config.toml applies before anything reads settings. |
| 13 | ConfigSync.shared.start() | 13 | ConfigSync.shared.start() |
| 14 | let line = UserDefaults.standard.string(forKey: "todoKeywords") ?? defaultTodoKeywords | 14 | return OrgPreferences.orgSettings() |
| 15 | var settings = OrgSettings.default.withTodoKeywords(line) ?? .default | ||
| 16 | settings.listAllowAlphabetical = UserDefaults.standard.object(forKey: "alphabeticalLists") as? Bool ?? true | ||
| 17 | return settings | ||
| 18 | } | 15 | } |
| 19 | 16 | ||
| 20 | static func options( | 17 | static func options( |
Sources/OrgstarMobile/AgendaScreen.swift +19 −1
| @@ -8,6 +8,7 @@ struct AgendaScreen: View { | |||
| 8 | let workspace: WorkspaceModel | 8 | let workspace: WorkspaceModel |
| 9 | let session: DocumentSession | 9 | let session: DocumentSession |
| 10 | @Bindable var agenda: AgendaModel | 10 | @Bindable var agenda: AgendaModel |
| 11 | let scheduler: ReminderScheduler | ||
| 11 | @State private var message: String? | 12 | @State private var message: String? |
| 12 | @State private var tick = 0 | 13 | @State private var tick = 0 |
| 13 | 14 | ||
| @@ -28,6 +29,9 @@ struct AgendaScreen: View { | |||
| 28 | var tick: Int | 29 | var tick: Int |
| 29 | var view: String | 30 | var view: String |
| 30 | var start: Int? | 31 | var start: Int? |
| 32 | var span: Int | ||
| 33 | var startOffset: Int | ||
| 34 | var subfolders: Bool | ||
| 31 | } | 35 | } |
| 32 | 36 | ||
| 33 | var body: some SwiftUI.View { | 37 | var body: some SwiftUI.View { |
| @@ -45,6 +49,9 @@ struct AgendaScreen: View { | |||
| 45 | } else { | 49 | } else { |
| 46 | ForEach(agenda.list.indices, id: \.self) { i in row(agenda.list[i]) } | 50 | ForEach(agenda.list.indices, id: \.self) { i in row(agenda.list[i]) } |
| 47 | } | 51 | } |
| 52 | if let reminderStatus { | ||
| 53 | Section { } footer: { Text(reminderStatus) } | ||
| 54 | } | ||
| 48 | } | 55 | } |
| 49 | .listStyle(.insetGrouped) | 56 | .listStyle(.insetGrouped) |
| 50 | .navigationTitle(agenda.isComputing && agenda.days.isEmpty ? "Agenda…" : "Agenda") | 57 | .navigationTitle(agenda.isComputing && agenda.days.isEmpty ? "Agenda…" : "Agenda") |
| @@ -81,11 +88,22 @@ struct AgendaScreen: View { | |||
| 81 | await agenda.refresh(open: session.openTexts) | 88 | await agenda.refresh(open: session.openTexts) |
| 82 | } | 89 | } |
| 83 | .task(id: Key(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, tick: tick, | 90 | .task(id: Key(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, tick: tick, |
| 84 | view: view.wrappedValue.rawValue, start: agenda.start)) { | 91 | view: view.wrappedValue.rawValue, start: agenda.start, span: agenda.span, startOffset: agenda.startOffset, |
| 92 | subfolders: agenda.includeSubfolders)) { | ||
| 85 | await agenda.refresh(open: session.openTexts) | 93 | await agenda.refresh(open: session.openTexts) |
| 86 | } | 94 | } |
| 87 | } | 95 | } |
| 88 | 96 | ||
| 97 | /// How far ahead notifications are set, as the design asks the agenda to show: iOS keeps | ||
| 98 | /// at most 64, and sets no more while the app isn't opened. | ||
| 99 | private var reminderStatus: String? { | ||
| 100 | guard OrgPreferences.reminders() else { return nil } | ||
| 101 | if !scheduler.isAuthorized { return "Notifications are off for Orgstar in Settings." } | ||
| 102 | guard let through = scheduler.scheduledThrough else { return nil } | ||
| 103 | return "Reminders are set through " + through.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) | ||
| 104 | + ". Open Orgstar to set later ones." | ||
| 105 | } | ||
| 106 | |||
| 89 | private func row(_ item: AgendaItem) -> some SwiftUI.View { | 107 | private func row(_ item: AgendaItem) -> some SwiftUI.View { |
| 90 | VStack(alignment: .leading, spacing: 2) { | 108 | VStack(alignment: .leading, spacing: 2) { |
| 91 | HStack(alignment: .firstTextBaseline, spacing: 6) { | 109 | HStack(alignment: .firstTextBaseline, spacing: 6) { |
Sources/OrgstarMobile/MobileModel.swift added +131
| @@ -0,0 +1,131 @@ | |||
| 1 | #if os(iOS) | ||
| 2 | import Foundation | ||
| 3 | import Observation | ||
| 4 | import OrgApp | ||
| 5 | import OrgCore | ||
| 6 | import OrgIndex | ||
| 7 | import OrgWorkspace | ||
| 8 | |||
| 9 | /// The app's models, and the settings from the configuration folder: a folder you choose | ||
| 10 | /// (a synced copy of the Mac's `~/.config/orgstar`) with `config.toml` and `capture.toml`. | ||
| 11 | @MainActor | ||
| 12 | @Observable | ||
| 13 | final class MobileModel { | ||
| 14 | let workspace: WorkspaceModel? | ||
| 15 | let failure: String? | ||
| 16 | let session = DocumentSession() | ||
| 17 | let agenda: AgendaModel? | ||
| 18 | let scheduler = ReminderScheduler(center: SystemReminderCenter()) | ||
| 19 | private(set) var configFolder: URL? | ||
| 20 | private(set) var configProblems: [String] = [] | ||
| 21 | /// Bumped when settings apply, so views that read them update. | ||
| 22 | private(set) var settingsVersion = 0 | ||
| 23 | @ObservationIgnored private var presenter: FolderPresenter? | ||
| 24 | |||
| 25 | private static let bookmarkKey = "configFolderBookmark" | ||
| 26 | |||
| 27 | init() { | ||
| 28 | // Development: `ORGSTAR_CONFIG_DIR` stands in for a chosen folder. | ||
| 29 | let folder = Self.resolveBookmark() ?? ProcessInfo.processInfo.environment["ORGSTAR_CONFIG_DIR"].map { URL(fileURLWithPath: $0) } | ||
| 30 | UserConfig.directoryOverride = folder | ||
| 31 | let problems = Self.readConfig(folder) | ||
| 32 | let settings = OrgPreferences.orgSettings() | ||
| 33 | do { | ||
| 34 | let workspace = try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: settings)) | ||
| 35 | self.workspace = workspace | ||
| 36 | agenda = AgendaModel(workspace: workspace) | ||
| 37 | failure = nil | ||
| 38 | } catch { | ||
| 39 | workspace = nil | ||
| 40 | agenda = nil | ||
| 41 | failure = error.localizedDescription | ||
| 42 | } | ||
| 43 | configFolder = folder | ||
| 44 | configProblems = problems | ||
| 45 | applySettings() | ||
| 46 | watchConfig() | ||
| 47 | } | ||
| 48 | |||
| 49 | /// Chooses the configuration folder, or with nil stops using one; its settings apply now. | ||
| 50 | func setConfigFolder(_ url: URL?) { | ||
| 51 | if let url { | ||
| 52 | _ = url.startAccessingSecurityScopedResource() | ||
| 53 | if let bookmark = try? url.bookmarkData() { UserDefaults.standard.set(bookmark, forKey: Self.bookmarkKey) } | ||
| 54 | } else { | ||
| 55 | UserDefaults.standard.removeObject(forKey: Self.bookmarkKey) | ||
| 56 | // What the folder set no longer applies. | ||
| 57 | for setting in UserConfig.settings { UserDefaults.standard.removeObject(forKey: setting.defaultsKey) } | ||
| 58 | } | ||
| 59 | configFolder = url | ||
| 60 | reloadConfig() | ||
| 61 | watchConfig() | ||
| 62 | } | ||
| 63 | |||
| 64 | func reloadConfig() { | ||
| 65 | UserConfig.directoryOverride = configFolder | ||
| 66 | configProblems = Self.readConfig(configFolder) | ||
| 67 | applySettings() | ||
| 68 | } | ||
| 69 | |||
| 70 | private func applySettings() { | ||
| 71 | let settings = OrgPreferences.orgSettings() | ||
| 72 | session.defaults = settings | ||
| 73 | session.options = OrgPreferences.options() | ||
| 74 | workspace?.settings = .with(todoKeywords: settings) | ||
| 75 | if let agenda { | ||
| 76 | agenda.span = OrgPreferences.agendaSpan() | ||
| 77 | agenda.startOffset = OrgPreferences.agendaStartOffset() | ||
| 78 | agenda.includeSubfolders = OrgPreferences.agendaSubfolders() | ||
| 79 | } | ||
| 80 | settingsVersion += 1 | ||
| 81 | } | ||
| 82 | |||
| 83 | /// Edits to the folder's files, synced from the Mac, apply as they arrive. | ||
| 84 | private func watchConfig() { | ||
| 85 | presenter?.stop() | ||
| 86 | presenter = nil | ||
| 87 | guard let configFolder else { return } | ||
| 88 | let presenter = FolderPresenter(root: configFolder) { [weak self] _ in | ||
| 89 | Task { @MainActor [weak self] in self?.reloadConfig() } | ||
| 90 | } | ||
| 91 | presenter.start() | ||
| 92 | self.presenter = presenter | ||
| 93 | } | ||
| 94 | |||
| 95 | private static func resolveBookmark() -> URL? { | ||
| 96 | guard let data = UserDefaults.standard.data(forKey: bookmarkKey) else { return nil } | ||
| 97 | var stale = false | ||
| 98 | guard let url = try? URL(resolvingBookmarkData: data, bookmarkDataIsStale: &stale) else { return nil } | ||
| 99 | _ = url.startAccessingSecurityScopedResource() | ||
| 100 | if stale, let fresh = try? url.bookmarkData() { UserDefaults.standard.set(fresh, forKey: bookmarkKey) } | ||
| 101 | return url | ||
| 102 | } | ||
| 103 | |||
| 104 | /// Applies the folder's `config.toml` to the settings; returns its problems. | ||
| 105 | private static func readConfig(_ folder: URL?) -> [String] { | ||
| 106 | guard let folder else { return [] } | ||
| 107 | let file = folder.appendingPathComponent("config.toml") | ||
| 108 | var text: String? | ||
| 109 | var error: NSError? | ||
| 110 | NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: file, options: [], error: &error) { url in | ||
| 111 | text = try? String(contentsOf: url, encoding: .utf8) | ||
| 112 | } | ||
| 113 | guard let text else { return ["No config.toml in \(folder.lastPathComponent); the defaults apply."] } | ||
| 114 | return UserConfig.apply(text, to: .standard) | ||
| 115 | } | ||
| 116 | |||
| 117 | /// Notifications for the next week's timed entries, at most 64, as iOS allows. | ||
| 118 | func updateReminders() async { | ||
| 119 | guard let workspace else { return } | ||
| 120 | guard OrgPreferences.reminders() else { | ||
| 121 | await scheduler.clear() | ||
| 122 | return | ||
| 123 | } | ||
| 124 | guard !workspace.files.isEmpty else { return } | ||
| 125 | let agenda = AgendaModel(workspace: workspace) | ||
| 126 | agenda.includeSubfolders = OrgPreferences.agendaSubfolders() | ||
| 127 | let reminders = await agenda.reminders(open: session.openTexts, lead: OrgPreferences.reminderLead()) | ||
| 128 | await scheduler.reconcile(reminders) | ||
| 129 | } | ||
| 130 | } | ||
| 131 | #endif | ||
Sources/OrgstarMobile/OrgstarMobileApp.swift +56 −14
| @@ -2,25 +2,37 @@ | |||
| 2 | import OrgApp | 2 | import OrgApp |
| 3 | import OrgCore | 3 | import OrgCore |
| 4 | import SwiftUI | 4 | import SwiftUI |
| 5 | import UserNotifications | ||
| 5 | 6 | ||
| 6 | /// Orgstar on iPhone and iPad: the agenda, capture, and the folders they read. | 7 | /// Orgstar on iPhone and iPad: the agenda, capture, and the folders they read. |
| 7 | @main | 8 | @main |
| 8 | struct OrgstarMobileApp: App { | 9 | struct OrgstarMobileApp: App { |
| 9 | @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) } | 10 | @UIApplicationDelegateAdaptor private var delegate: AppDelegate |
| 10 | @State private var session = DocumentSession() | 11 | @State private var model = MobileModel() |
| 11 | 12 | ||
| 12 | var body: some Scene { | 13 | var body: some Scene { |
| 13 | WindowGroup { | 14 | WindowGroup { |
| 14 | switch workspace { | 15 | if let workspace = model.workspace, let agenda = model.agenda { |
| 15 | case .success(let model): | 16 | RootView(model: model, workspace: workspace, agenda: agenda) |
| 16 | RootView(workspace: model, session: session) | 17 | } else { |
| 17 | case .failure(let error): | 18 | ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? "")) |
| 18 | ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription)) | ||
| 19 | } | 19 | } |
| 20 | } | 20 | } |
| 21 | } | 21 | } |
| 22 | } | 22 | } |
| 23 | 23 | ||
| 24 | /// Reminders show while the app is open too. | ||
| 25 | final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { | ||
| 26 | func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { | ||
| 27 | UNUserNotificationCenter.current().delegate = self | ||
| 28 | return true | ||
| 29 | } | ||
| 30 | |||
| 31 | nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { | ||
| 32 | [.banner, .sound, .list] | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 24 | /// An org-protocol capture, or a plain one. | 36 | /// An org-protocol capture, or a plain one. |
| 25 | struct CaptureRequest: Identifiable { | 37 | struct CaptureRequest: Identifiable { |
| 26 | let id = UUID() | 38 | let id = UUID() |
| @@ -29,22 +41,29 @@ struct CaptureRequest: Identifiable { | |||
| 29 | } | 41 | } |
| 30 | 42 | ||
| 31 | struct RootView: View { | 43 | struct RootView: View { |
| 44 | let model: MobileModel | ||
| 32 | let workspace: WorkspaceModel | 45 | let workspace: WorkspaceModel |
| 33 | let session: DocumentSession | 46 | let agenda: AgendaModel |
| 34 | @State private var agenda: AgendaModel | ||
| 35 | @State private var capture: CaptureRequest? | 47 | @State private var capture: CaptureRequest? |
| 48 | @State private var hour = 0 | ||
| 49 | @Environment(\.scenePhase) private var scenePhase | ||
| 50 | |||
| 51 | private var session: DocumentSession { model.session } | ||
| 36 | 52 | ||
| 37 | init(workspace: WorkspaceModel, session: DocumentSession) { | 53 | private struct ReminderKey: Equatable { |
| 38 | self.workspace = workspace | 54 | var files: Int |
| 39 | self.session = session | 55 | var changed: Int? |
| 40 | _agenda = State(initialValue: AgendaModel(workspace: workspace)) | 56 | var edits: Int |
| 57 | var settings: Int | ||
| 58 | var active: Bool | ||
| 59 | var hour: Int | ||
| 41 | } | 60 | } |
| 42 | 61 | ||
| 43 | var body: some View { | 62 | var body: some View { |
| 44 | TabView { | 63 | TabView { |
| 45 | Tab("Agenda", systemImage: "calendar") { | 64 | Tab("Agenda", systemImage: "calendar") { |
| 46 | NavigationStack { | 65 | NavigationStack { |
| 47 | AgendaScreen(workspace: workspace, session: session, agenda: agenda) | 66 | AgendaScreen(workspace: workspace, session: session, agenda: agenda, scheduler: model.scheduler) |
| 48 | .toolbar { captureButton } | 67 | .toolbar { captureButton } |
| 49 | } | 68 | } |
| 50 | } | 69 | } |
| @@ -54,6 +73,9 @@ struct RootView: View { | |||
| 54 | .toolbar { captureButton } | 73 | .toolbar { captureButton } |
| 55 | } | 74 | } |
| 56 | } | 75 | } |
| 76 | Tab("Settings", systemImage: "gearshape") { | ||
| 77 | NavigationStack { SettingsScreen(model: model) } | ||
| 78 | } | ||
| 57 | } | 79 | } |
| 58 | .sheet(item: $capture) { request in | 80 | .sheet(item: $capture) { request in |
| 59 | CaptureScreen(workspace: workspace, session: session, request: request) | 81 | CaptureScreen(workspace: workspace, session: session, request: request) |
| @@ -64,6 +86,26 @@ struct RootView: View { | |||
| 64 | workspace.startWatching() | 86 | workspace.startWatching() |
| 65 | await workspace.refresh() | 87 | await workspace.refresh() |
| 66 | } | 88 | } |
| 89 | .task { | ||
| 90 | while !Task.isCancelled { | ||
| 91 | try? await Task.sleep(for: .seconds(3600)) | ||
| 92 | hour += 1 | ||
| 93 | } | ||
| 94 | } | ||
| 95 | // Reminders follow the files, the settings, coming back to the app and the hour. | ||
| 96 | .task(id: ReminderKey(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, | ||
| 97 | settings: model.settingsVersion, active: scenePhase == .active, hour: hour)) { | ||
| 98 | guard scenePhase == .active else { return } | ||
| 99 | try? await Task.sleep(for: .seconds(1)) | ||
| 100 | guard !Task.isCancelled else { return } | ||
| 101 | await model.updateReminders() | ||
| 102 | } | ||
| 103 | .onChange(of: scenePhase) { _, phase in | ||
| 104 | // Files and settings synced while away; not every sync tool tells file presenters. | ||
| 105 | guard phase == .active else { return } | ||
| 106 | model.reloadConfig() | ||
| 107 | Task { await workspace.refresh() } | ||
| 108 | } | ||
| 67 | .onOpenURL { url in | 109 | .onOpenURL { url in |
| 68 | if case .capture(let template, let properties)? = OrgProtocol.parse(url.absoluteString) { | 110 | if case .capture(let template, let properties)? = OrgProtocol.parse(url.absoluteString) { |
| 69 | capture = CaptureRequest(template: template, properties: properties) | 111 | capture = CaptureRequest(template: template, properties: properties) |
Sources/OrgstarMobile/SettingsScreen.swift added +45
| @@ -0,0 +1,45 @@ | |||
| 1 | #if os(iOS) | ||
| 2 | import OrgApp | ||
| 3 | import OrgCore | ||
| 4 | import SwiftUI | ||
| 5 | |||
| 6 | /// Where settings come from: a folder with the Mac's `config.toml` and `capture.toml`, kept in | ||
| 7 | /// sync by iCloud Drive or another app. | ||
| 8 | struct SettingsScreen: View { | ||
| 9 | let model: MobileModel | ||
| 10 | @State private var choosing = false | ||
| 11 | |||
| 12 | var body: some View { | ||
| 13 | Form { | ||
| 14 | Section { | ||
| 15 | LabeledContent("Folder", value: model.configFolder?.lastPathComponent ?? "None") | ||
| 16 | Button(model.configFolder == nil ? "Choose Folder…" : "Choose Another Folder…") { choosing = true } | ||
| 17 | if model.configFolder != nil { | ||
| 18 | Button("Stop Using This Folder", role: .destructive) { model.setConfigFolder(nil) } | ||
| 19 | } | ||
| 20 | ForEach(model.configProblems, id: \.self) { Text($0).foregroundStyle(.red) } | ||
| 21 | } header: { | ||
| 22 | Text("Configuration") | ||
| 23 | } footer: { | ||
| 24 | 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.") | ||
| 25 | } | ||
| 26 | Section("In use") { | ||
| 27 | let _ = model.settingsVersion | ||
| 28 | LabeledContent("TODO keywords", value: todoKeywords) | ||
| 29 | LabeledContent("Agenda", value: "\(OrgPreferences.agendaSpan()) days from \(OrgPreferences.agendaStartOffset())d") | ||
| 30 | LabeledContent("Reminders", value: OrgPreferences.reminders() ? "\(OrgPreferences.reminderLead()) min before" : "Off") | ||
| 31 | LabeledContent("Capture templates", value: CaptureTemplates.load().templates.map(\.key).joined(separator: " ")) | ||
| 32 | } | ||
| 33 | } | ||
| 34 | .navigationTitle("Settings") | ||
| 35 | .fileImporter(isPresented: $choosing, allowedContentTypes: [.folder]) { result in | ||
| 36 | if case .success(let url) = result { model.setConfigFolder(url) } | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | private var todoKeywords: String { | ||
| 41 | OrgPreferences.orgSettings().todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") } | ||
| 42 | .joined(separator: "; ") | ||
| 43 | } | ||
| 44 | } | ||
| 45 | #endif | ||
Tests/OrgAppTests/OrgPreferencesTests.swift added +32
| @@ -0,0 +1,32 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgCore | ||
| 3 | import Testing | ||
| 4 | @testable import OrgApp | ||
| 5 | |||
| 6 | struct OrgPreferencesTests { | ||
| 7 | @Test func readsWhatConfigTomlSets() throws { | ||
| 8 | let defaults = try #require(UserDefaults(suiteName: "orgstar-preferences-\(UUID().uuidString)")) | ||
| 9 | // Nothing set: the file's fallbacks, which match the Mac's. | ||
| 10 | #expect(OrgPreferences.orgSettings(defaults).todoSequences.first?.active.map(\.name) == ["TODO", "PROJ", "LOOP", "STRT", "WAIT", "HOLD", "IDEA"]) | ||
| 11 | #expect(OrgPreferences.options(defaults).logDone == nil && OrgPreferences.options(defaults).tagsColumn == -77) | ||
| 12 | #expect(OrgPreferences.agendaSpan(defaults) == 10 && OrgPreferences.agendaStartOffset(defaults) == -3) | ||
| 13 | #expect(OrgPreferences.reminders(defaults) && OrgPreferences.reminderLead(defaults) == 12) | ||
| 14 | |||
| 15 | let problems = UserConfig.apply(""" | ||
| 16 | org-todo-keywords = "TODO NEXT | DONE" | ||
| 17 | org-log-done = "time" | ||
| 18 | org-log-into-drawer = "LOGBOOK" | ||
| 19 | org-agenda-span = 7 | ||
| 20 | org-agenda-start-day = "-1d" | ||
| 21 | appt-message-warning-time = 30 | ||
| 22 | [orgstar] | ||
| 23 | reminders = false | ||
| 24 | """, to: defaults) | ||
| 25 | #expect(problems.isEmpty, "\(problems)") | ||
| 26 | #expect(OrgPreferences.orgSettings(defaults).todoSequences.first?.active.map(\.name) == ["TODO", "NEXT"]) | ||
| 27 | let options = OrgPreferences.options(defaults) | ||
| 28 | #expect(options.logDone == .time && options.logIntoDrawer == "LOGBOOK") | ||
| 29 | #expect(OrgPreferences.agendaSpan(defaults) == 7 && OrgPreferences.agendaStartOffset(defaults) == -1) | ||
| 30 | #expect(!OrgPreferences.reminders(defaults) && OrgPreferences.reminderLead(defaults) == 30) | ||
| 31 | } | ||
| 32 | } | ||