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