krz/orgstar

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

Sources/OrgstarMobile/OrgstarMobileApp.swift

3a3dedba062790e1580c66d38606eda4b02cb501
orgstar/Sources/OrgstarMobile/OrgstarMobileApp.swift history · blame · raw

254 lines · 10444 bytes

  1#if os(iOS)
  2import CoreSpotlight
  3import OrgApp
  4import OrgCore
  5import SwiftUI
  6import UserNotifications
  7
  8/// Orgstar on iPhone and iPad: the agenda, capture, and the folders they read.
  9@main
 10struct OrgstarMobileApp: App {
 11    @UIApplicationDelegateAdaptor private var delegate: AppDelegate
 12    @State private var model = MobileModel.shared
 13
 14    var body: some Scene {
 15        WindowGroup {
 16            if let workspace = model.workspace, let agenda = model.agenda {
 17                RootView(model: model, workspace: workspace, agenda: agenda)
 18                    .environment(model)
 19                    .environment(model.clock)
 20                    .onAppear { delegate.model = model }
 21            } else {
 22                ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? ""))
 23            }
 24        }
 25    }
 26}
 27
 28/// Reminders show while the app is open too, and open their entry when tapped.
 29final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
 30    @MainActor var model: MobileModel?
 31
 32    func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
 33        UNUserNotificationCenter.current().delegate = self
 34        return true
 35    }
 36
 37    nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
 38        [.banner, .sound, .list]
 39    }
 40
 41    nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
 42        let info = response.notification.request.content.userInfo
 43        guard let path = info["path"] as? String, let offset = info["offset"] as? Int else { return }
 44        await MainActor.run { model?.openRequest = .offset(path, offset) }
 45    }
 46}
 47
 48enum AppTab: Hashable { case agenda, folders, settings, search }
 49
 50extension View {
 51    /// Readers pushed for `ReaderRoute`s.
 52    func readerDestination(workspace: WorkspaceModel, session: DocumentSession) -> some View {
 53        navigationDestination(for: ReaderRoute.self) { route in
 54            ReaderScreen(route: route, workspace: workspace, session: session)
 55        }
 56        .navigationDestination(for: EditorRoute.self) { route in
 57            EditorScreen(path: route.path, offset: route.offset, workspace: workspace, session: session)
 58        }
 59    }
 60}
 61
 62/// An org-protocol capture, or a plain one.
 63struct CaptureRequest: Identifiable {
 64    let id = UUID()
 65    var template: String?
 66    var properties: [String: String] = [:]
 67    /// An agenda day (a `Days` number) the entry is scheduled on.
 68    var day: Int?
 69}
 70
 71extension CaptureRequest {
 72    /// An `org-protocol://capture` link's request.
 73    init?(link: String) {
 74        guard case .capture(let template, let properties)? = OrgProtocol.parse(link) else { return nil }
 75        self.init(template: template, properties: properties)
 76    }
 77}
 78
 79struct RootView: View {
 80    let model: MobileModel
 81    let workspace: WorkspaceModel
 82    let agenda: AgendaModel
 83    @State private var capture: CaptureRequest?
 84    /// Captures from the share extension after the one showing.
 85    @State private var waiting: [CaptureRequest] = []
 86    @State private var tab = AppTab.agenda
 87    @State private var agendaPath = NavigationPath()
 88    @State private var hour = 0
 89    @State private var showingClockReport = false
 90    @State private var message: String?
 91    @Environment(\.scenePhase) private var scenePhase
 92
 93    private var session: DocumentSession { model.session }
 94
 95    private struct SpotlightKey: Equatable {
 96        var files: Int
 97        var changed: Int?
 98        var saved: Int
 99    }
100
101    private struct ReminderKey: Equatable {
102        var files: Int
103        var changed: Int?
104        var edits: Int
105        var settings: Int
106        var active: Bool
107        var hour: Int
108    }
109
110    var body: some View {
111        TabView(selection: $tab) {
112            Tab("Agenda", systemImage: "calendar", value: .agenda) {
113                NavigationStack(path: $agendaPath) {
114                    AgendaScreen(workspace: workspace, session: session, agenda: agenda, scheduler: model.scheduler)
115                        .toolbar { captureButton }
116                        .readerDestination(workspace: workspace, session: session)
117                }
118            }
119            Tab("Folders", systemImage: "folder", value: .folders) {
120                NavigationStack {
121                    FoldersScreen(workspace: workspace)
122                        .toolbar { captureButton }
123                        .readerDestination(workspace: workspace, session: session)
124                }
125            }
126            Tab("Settings", systemImage: "gearshape", value: .settings) {
127                NavigationStack { SettingsScreen(model: model) }
128            }
129            Tab(value: .search, role: .search) {
130                NavigationStack {
131                    SearchScreen(workspace: workspace, session: session)
132                        .readerDestination(workspace: workspace, session: session)
133                }
134            }
135        }
136        .clockAccessory(model.clock, perform: clock)
137        .sheet(item: Binding(get: { model.clockPrompts.pending }, set: { if $0 == nil { model.clockPrompts.answer(nil) } })) { pending in
138            ClockPromptSheet(prompt: pending.prompt) { model.clockPrompts.answer($0) }
139        }
140        // Above the tab bar, clear of every screen's toolbar.
141        .overlay(alignment: .bottom) {
142            if let message {
143                Text(message).font(.callout).padding(.horizontal, 14).padding(.vertical, 8)
144                    .background(.regularMaterial, in: Capsule())
145                    .padding(.bottom, 150)
146                    .onTapGesture { self.message = nil }
147                    .task(id: message) {
148                        try? await Task.sleep(for: .seconds(4))
149                        if self.message == message { self.message = nil }
150                    }
151            }
152        }
153        .sheet(isPresented: $showingClockReport) { ClockReportScreen(workspace: workspace) }
154        .onChange(of: model.openRequest) {
155            guard let route = model.openRequest else { return }
156            model.openRequest = nil
157            tab = .agenda
158            agendaPath.append(route)
159        }
160        .onChange(of: model.agendaRequest) {
161            guard let request = model.agendaRequest else { return }
162            model.agendaRequest = nil
163            agenda.mode = .agenda(span: request.count, startOffset: nil)
164            agenda.start = request.start
165            tab = .agenda
166            agendaPath = NavigationPath()
167        }
168        .onChange(of: workspace.changedFile?.version) {
169            // An open file takes in what changed on disk.
170            if let path = workspace.changedFile?.path { session.fileChanged(path) }
171        }
172        .onChange(of: model.captureRequest?.id) {
173            guard let request = model.captureRequest else { return }
174            model.captureRequest = nil
175            capture = request
176        }
177        .sheet(item: $capture, onDismiss: showWaiting) { request in
178            CaptureScreen(workspace: workspace, session: session, request: request)
179        }
180        .task {
181            takeShared()
182            // Development: `ORGSTAR_ROOT` adds a folder at launch, as on the Mac.
183            if let root = ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
184            workspace.startWatching()
185            await workspace.refresh()
186        }
187        .task {
188            while !Task.isCancelled {
189                try? await Task.sleep(for: .seconds(3600))
190                hour += 1
191            }
192        }
193        // Reminders follow the files, the settings, coming back to the app and the hour.
194        .task(id: ReminderKey(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
195                              settings: model.settingsVersion, active: scenePhase == .active, hour: hour)) {
196            guard scenePhase == .active else { return }
197            try? await Task.sleep(for: .seconds(1))
198            guard !Task.isCancelled else { return }
199            await model.updateReminders()
200        }
201        // Spotlight follows the files and saved edits.
202        .task(id: SpotlightKey(files: workspace.files.count, changed: workspace.changedFile?.version, saved: session.dirtyPaths.count)) {
203            await model.spotlight.update(workspace.files)
204        }
205        .onContinueUserActivity(CSSearchableItemActionType) { activity in
206            if let path = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String { model.openRequest = .file(path) }
207        }
208        .onChange(of: scenePhase) { _, phase in
209            // Unsaved edits are written before the app may be stopped.
210            if phase == .background {
211                for path in session.dirtyPaths { _ = session.save(path) }
212            }
213            // Files and settings synced while away; not every sync tool tells file presenters.
214            guard phase == .active else { return }
215            takeShared()
216            model.reloadConfig()
217            Task { await workspace.refresh() }
218        }
219        .onOpenURL { url in
220            if let request = CaptureRequest(link: url.absoluteString) { capture = request }
221        }
222    }
223
224    /// What the share extension left, one capture sheet at a time.
225    private func takeShared() {
226        waiting += (model.inbox?.take() ?? []).compactMap(CaptureRequest.init(link:))
227        if capture == nil { showWaiting() }
228    }
229
230    private func showWaiting() {
231        if !waiting.isEmpty { capture = waiting.removeFirst() }
232    }
233
234    private func clock(_ id: String) {
235        if id == "app.clock.report" {
236            showingClockReport = true
237        } else {
238            _ = MobileClock.run(id, model: model, workspace: workspace) { message = $0 }
239        }
240    }
241
242    private var captureButton: some ToolbarContent {
243        ToolbarItem(placement: .primaryAction) {
244            Button("Capture", systemImage: "square.and.pencil") { capture = CaptureRequest() }
245        }
246    }
247}
248#else
249/// The iOS app has nothing to run elsewhere.
250@main
251enum OrgstarMobileUnavailable {
252    static func main() { print("Orgstar for iOS runs on iPhone and iPad.") }
253}
254#endif