Sources/OrgstarMobile/OrgstarMobileApp.swift
284 lines · 11529 bytes
17 symbols in this file
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 } else if case .storeLink(let link, let title)? = OrgProtocol.parse(url.absoluteString) {
230 message = StoredLink.add(StoredLink(link: link, description: title), to: &model.storedLinks)
231 UIPasteboard.general.string = link
232 }
233 }
234 // The Live Activity follows the clock, wherever it starts or stops.
235 .task { await LiveClock.sync(model.clock.running) }
236 .onChange(of: model.clock.running) {
237 Task { await LiveClock.sync(model.clock.running) }
238 }
239 }
240
241 /// What the share extension left, one capture sheet at a time.
242 private func takeShared() {
243 waiting += (model.inbox?.take() ?? []).compactMap(CaptureRequest.init(link:))
244 if capture == nil { showWaiting() }
245 }
246
247 private func showWaiting() {
248 if !waiting.isEmpty { capture = waiting.removeFirst() }
249 }
250
251 /// A widget's or the Live Activity's link.
252 private func open(_ link: AppLink) {
253 switch link {
254 case .agenda:
255 tab = .agenda
256 agendaPath = NavigationPath()
257 case .entry(let path, let offset):
258 model.openRequest = .offset(path, offset)
259 case .clock:
260 clock("app.clock.goto")
261 }
262 }
263
264 private func clock(_ id: String) {
265 if id == "app.clock.report" {
266 showingClockReport = true
267 } else {
268 _ = MobileClock.run(id, model: model, workspace: workspace) { message = $0 }
269 }
270 }
271
272 private var captureButton: some ToolbarContent {
273 ToolbarItem(placement: .primaryAction) {
274 Button("Capture", systemImage: "square.and.pencil") { capture = CaptureRequest() }
275 }
276 }
277}
278#else
279/// The iOS app has nothing to run elsewhere.
280@main
281enum OrgstarMobileUnavailable {
282 static func main() { print("Orgstar for iOS runs on iPhone and iPad.") }
283}
284#endif