Sources/OrgstarMobile/OrgstarMobileApp.swift
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