krz/orgstar

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

Sources/OrgstarMobile/OrgstarMobileApp.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Sources/OrgstarMobile/OrgstarMobileApp.swift history · blame · raw

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