krz/orgstar

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

Sources/OrgstarMobile/OrgstarMobileApp.swift

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

247 lines · 10161 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}
 68
 69extension CaptureRequest {
 70    /// An `org-protocol://capture` link's request.
 71    init?(link: String) {
 72        guard case .capture(let template, let properties)? = OrgProtocol.parse(link) else { return nil }
 73        self.init(template: template, properties: properties)
 74    }
 75}
 76
 77struct RootView: View {
 78    let model: MobileModel
 79    let workspace: WorkspaceModel
 80    let agenda: AgendaModel
 81    @State private var capture: CaptureRequest?
 82    /// Captures from the share extension after the one showing.
 83    @State private var waiting: [CaptureRequest] = []
 84    @State private var tab = AppTab.agenda
 85    @State private var agendaPath = NavigationPath()
 86    @State private var hour = 0
 87    @State private var showingClockReport = false
 88    @State private var message: String?
 89    @Environment(\.scenePhase) private var scenePhase
 90
 91    private var session: DocumentSession { model.session }
 92
 93    private struct SpotlightKey: Equatable {
 94        var files: Int
 95        var changed: Int?
 96        var saved: Int
 97    }
 98
 99    private struct ReminderKey: Equatable {
100        var files: Int
101        var changed: Int?
102        var edits: Int
103        var settings: Int
104        var active: Bool
105        var hour: Int
106    }
107
108    var body: some View {
109        TabView(selection: $tab) {
110            Tab("Agenda", systemImage: "calendar", value: .agenda) {
111                NavigationStack(path: $agendaPath) {
112                    AgendaScreen(workspace: workspace, session: session, agenda: agenda, scheduler: model.scheduler)
113                        .toolbar { captureButton }
114                        .readerDestination(workspace: workspace, session: session)
115                }
116            }
117            Tab("Folders", systemImage: "folder", value: .folders) {
118                NavigationStack {
119                    FoldersScreen(workspace: workspace)
120                        .toolbar { captureButton }
121                        .readerDestination(workspace: workspace, session: session)
122                }
123            }
124            Tab("Settings", systemImage: "gearshape", value: .settings) {
125                NavigationStack { SettingsScreen(model: model) }
126            }
127            Tab(value: .search, role: .search) {
128                NavigationStack {
129                    SearchScreen(workspace: workspace, session: session)
130                        .readerDestination(workspace: workspace, session: session)
131                }
132            }
133        }
134        .clockAccessory(model.clock, perform: clock)
135        .sheet(item: Binding(get: { model.clockPrompts.pending }, set: { if $0 == nil { model.clockPrompts.answer(nil) } })) { pending in
136            ClockPromptSheet(prompt: pending.prompt) { model.clockPrompts.answer($0) }
137        }
138        // Above the tab bar, clear of every screen's toolbar.
139        .overlay(alignment: .bottom) {
140            if let message {
141                Text(message).font(.callout).padding(.horizontal, 14).padding(.vertical, 8)
142                    .background(.regularMaterial, in: Capsule())
143                    .padding(.bottom, 150)
144                    .onTapGesture { self.message = nil }
145                    .task(id: message) {
146                        try? await Task.sleep(for: .seconds(4))
147                        if self.message == message { self.message = nil }
148                    }
149            }
150        }
151        .sheet(isPresented: $showingClockReport) { ClockReportScreen(workspace: workspace) }
152        .onChange(of: model.openRequest) {
153            guard let route = model.openRequest else { return }
154            model.openRequest = nil
155            tab = .agenda
156            agendaPath.append(route)
157        }
158        .onChange(of: model.agendaRequest) {
159            guard let request = model.agendaRequest else { return }
160            model.agendaRequest = nil
161            agenda.mode = .agenda(span: request.count, startOffset: nil)
162            agenda.start = request.start
163            tab = .agenda
164            agendaPath = NavigationPath()
165        }
166        .onChange(of: workspace.changedFile?.version) {
167            // An open file takes in what changed on disk.
168            if let path = workspace.changedFile?.path { session.fileChanged(path) }
169        }
170        .sheet(item: $capture, onDismiss: showWaiting) { request in
171            CaptureScreen(workspace: workspace, session: session, request: request)
172        }
173        .task {
174            takeShared()
175            // Development: `ORGSTAR_ROOT` adds a folder at launch, as on the Mac.
176            if let root = ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
177            workspace.startWatching()
178            await workspace.refresh()
179        }
180        .task {
181            while !Task.isCancelled {
182                try? await Task.sleep(for: .seconds(3600))
183                hour += 1
184            }
185        }
186        // Reminders follow the files, the settings, coming back to the app and the hour.
187        .task(id: ReminderKey(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
188                              settings: model.settingsVersion, active: scenePhase == .active, hour: hour)) {
189            guard scenePhase == .active else { return }
190            try? await Task.sleep(for: .seconds(1))
191            guard !Task.isCancelled else { return }
192            await model.updateReminders()
193        }
194        // Spotlight follows the files and saved edits.
195        .task(id: SpotlightKey(files: workspace.files.count, changed: workspace.changedFile?.version, saved: session.dirtyPaths.count)) {
196            await model.spotlight.update(workspace.files)
197        }
198        .onContinueUserActivity(CSSearchableItemActionType) { activity in
199            if let path = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String { model.openRequest = .file(path) }
200        }
201        .onChange(of: scenePhase) { _, phase in
202            // Unsaved edits are written before the app may be stopped.
203            if phase == .background {
204                for path in session.dirtyPaths { _ = session.save(path) }
205            }
206            // Files and settings synced while away; not every sync tool tells file presenters.
207            guard phase == .active else { return }
208            takeShared()
209            model.reloadConfig()
210            Task { await workspace.refresh() }
211        }
212        .onOpenURL { url in
213            if let request = CaptureRequest(link: url.absoluteString) { capture = request }
214        }
215    }
216
217    /// What the share extension left, one capture sheet at a time.
218    private func takeShared() {
219        waiting += (model.inbox?.take() ?? []).compactMap(CaptureRequest.init(link:))
220        if capture == nil { showWaiting() }
221    }
222
223    private func showWaiting() {
224        if !waiting.isEmpty { capture = waiting.removeFirst() }
225    }
226
227    private func clock(_ id: String) {
228        if id == "app.clock.report" {
229            showingClockReport = true
230        } else {
231            _ = MobileClock.run(id, model: model, workspace: workspace) { message = $0 }
232        }
233    }
234
235    private var captureButton: some ToolbarContent {
236        ToolbarItem(placement: .primaryAction) {
237            Button("Capture", systemImage: "square.and.pencil") { capture = CaptureRequest() }
238        }
239    }
240}
241#else
242/// The iOS app has nothing to run elsewhere.
243@main
244enum OrgstarMobileUnavailable {
245    static func main() { print("Orgstar for iOS runs on iPhone and iPad.") }
246}
247#endif