Commit 08c0099083
08c0099083bdaefdf673453b8c2875fedcb094aa
parent: c17e16cfba
Verified · cmc
cmc <hello@cleberg.net> · 2026-10-09 05:46 UTC
iOS: agenda snapshot for widgets, orgstar:// links, clock Live Activity
The app writes the next seven days to the app group when the agenda's
inputs change and reloads widget timelines. orgstar://agenda, entry and
clock links open the agenda, an entry, or the clocked entry. A Live
Activity follows the running clock; its Clock Out intent runs the app's
clock-out.
Layout: unified · split
Sources/OrgApp/AgendaSnapshot.swift
+32
| @@ -47,6 +47,30 @@ public struct AgendaSnapshot: Codable, Equatable, Sendable { |
| 47 | public var priorityColor: Hex? |
47 | public var priorityColor: Hex? |
| 48 | public var categoryColor: Hex? |
48 | public var categoryColor: Hex? |
| 49 | |
49 | |
| |
50 | public init( |
| |
51 | id: String, gutter: String, status: String, title: String, category: String, todo: String?, priority: String?, isDone: Bool, |
| |
52 | isEvent: Bool, start: Date?, end: Date?, path: String?, offset: Int?, statusColor: Hex?, todoColor: Hex?, priorityColor: Hex?, |
| |
53 | categoryColor: Hex? |
| |
54 | ) { |
| |
55 | self.id = id |
| |
56 | self.gutter = gutter |
| |
57 | self.status = status |
| |
58 | self.title = title |
| |
59 | self.category = category |
| |
60 | self.todo = todo |
| |
61 | self.priority = priority |
| |
62 | self.isDone = isDone |
| |
63 | self.isEvent = isEvent |
| |
64 | self.start = start |
| |
65 | self.end = end |
| |
66 | self.path = path |
| |
67 | self.offset = offset |
| |
68 | self.statusColor = statusColor |
| |
69 | self.todoColor = todoColor |
| |
70 | self.priorityColor = priorityColor |
| |
71 | self.categoryColor = categoryColor |
| |
72 | } |
| |
73 | |
| 50 | public var link: AppLink? { |
74 | public var link: AppLink? { |
| 51 | guard let path, let offset else { return nil } |
75 | guard let path, let offset else { return nil } |
| 52 | return .entry(path: path, offset: offset) |
76 | return .entry(path: path, offset: offset) |
| @@ -67,6 +91,14 @@ public struct AgendaSnapshot: Codable, Equatable, Sendable { |
| 67 | public var rows: [Row] |
91 | public var rows: [Row] |
| 68 | |
92 | |
| 69 | public var id: Int { day } |
93 | public var id: Int { day } |
| |
94 | |
| |
95 | public init(day: Int, date: Date, header: String, overdue: [Row], rows: [Row]) { |
| |
96 | self.day = day |
| |
97 | self.date = date |
| |
98 | self.header = header |
| |
99 | self.overdue = overdue |
| |
100 | self.rows = rows |
| |
101 | } |
| 70 | } |
102 | } |
| 71 | |
103 | |
| 72 | public var generated: Date |
104 | public var generated: Date |
Sources/OrgApp/ClockActivity.swift
added
+48
| @@ -0,0 +1,48 @@ |
| |
1 | #if os(iOS) |
| |
2 | import ActivityKit |
| |
3 | import AppIntents |
| |
4 | import Foundation |
| |
5 | |
| |
6 | /// The running clock's Live Activity, which the app starts and the widget extension draws. |
| |
7 | public struct ClockActivityAttributes: ActivityAttributes { |
| |
8 | public struct ContentState: Codable, Hashable, Sendable { |
| |
9 | public var heading: String |
| |
10 | public var startedAt: Date |
| |
11 | |
| |
12 | public init(heading: String, startedAt: Date) { |
| |
13 | self.heading = heading |
| |
14 | self.startedAt = startedAt |
| |
15 | } |
| |
16 | } |
| |
17 | |
| |
18 | /// The clock's file and the stamp in its `CLOCK: [start]` line, which name the clock. |
| |
19 | public var path: String |
| |
20 | public var start: String |
| |
21 | |
| |
22 | public init(path: String, start: String) { |
| |
23 | self.path = path |
| |
24 | self.start = start |
| |
25 | } |
| |
26 | } |
| |
27 | |
| |
28 | /// The Live Activity's Clock Out button. It runs in the app's process, which sets `handler`. |
| |
29 | public struct ClockOutIntent: LiveActivityIntent { |
| |
30 | public static let title: LocalizedStringResource = "Clock Out" |
| |
31 | public static let description = IntentDescription("Stops the running clock.") |
| |
32 | public static let isDiscoverable = false |
| |
33 | |
| |
34 | /// Clocks out; set by the app at launch. |
| |
35 | @MainActor public static var handler: (@MainActor @Sendable () async -> Void)? |
| |
36 | |
| |
37 | public init() {} |
| |
38 | |
| |
39 | public func perform() async throws -> some IntentResult { |
| |
40 | let handler = await MainActor.run { Self.handler } |
| |
41 | await handler?() |
| |
42 | return .result() |
| |
43 | } |
| |
44 | } |
| |
45 | |
| |
46 | /// OrgApp's App Intents, for the app and the widget extension to include. |
| |
47 | public struct OrgAppIntents: AppIntentsPackage {} |
| |
48 | #endif |
Sources/OrgstarMobile/LiveClock.swift
added
+47
| @@ -0,0 +1,47 @@ |
| |
1 | #if os(iOS) |
| |
2 | import ActivityKit |
| |
3 | import AppIntents |
| |
4 | import OrgApp |
| |
5 | |
| |
6 | /// The running clock's Live Activity: one while a clock runs, started when it starts and |
| |
7 | /// ended when it stops, however that happens. |
| |
8 | @MainActor |
| |
9 | enum LiveClock { |
| |
10 | nonisolated private static func isLive(_ activity: Activity<ClockActivityAttributes>) -> Bool { |
| |
11 | activity.activityState == .active || activity.activityState == .stale |
| |
12 | } |
| |
13 | |
| |
14 | nonisolated private static func shows(_ activity: Activity<ClockActivityAttributes>, _ running: ClockModel.Running) -> Bool { |
| |
15 | activity.attributes.path == running.path && activity.attributes.start == running.start |
| |
16 | } |
| |
17 | |
| |
18 | /// Ends activities of other clocks; starts one for `running` unless it has one. |
| |
19 | nonisolated static func sync(_ running: ClockModel.Running?) async { |
| |
20 | let activities = Activity<ClockActivityAttributes>.activities.filter(isLive) |
| |
21 | for activity in activities where running.map({ !shows(activity, $0) }) ?? true { |
| |
22 | await activity.end(nil, dismissalPolicy: .immediate) |
| |
23 | } |
| |
24 | guard let running, !activities.contains(where: { shows($0, running) }), ActivityAuthorizationInfo().areActivitiesEnabled else { return } |
| |
25 | let state = ClockActivityAttributes.ContentState(heading: running.heading, startedAt: running.startedAt) |
| |
26 | _ = try? Activity.request( |
| |
27 | attributes: ClockActivityAttributes(path: running.path, start: running.start), content: ActivityContent(state: state, staleDate: nil) |
| |
28 | ) |
| |
29 | } |
| |
30 | |
| |
31 | /// The Clock Out button's work, in the app's process. |
| |
32 | static func install() { |
| |
33 | ClockOutIntent.handler = { |
| |
34 | let model = MobileModel.shared |
| |
35 | let path = model.clock.running?.path |
| |
36 | _ = model.clock.clockOut(model.session) |
| |
37 | if let path, model.session.dirtyPaths.contains(path) { _ = model.session.save(path) } |
| |
38 | await sync(model.clock.running) |
| |
39 | } |
| |
40 | } |
| |
41 | } |
| |
42 | |
| |
43 | /// The App Intents OrgApp declares. |
| |
44 | struct OrgstarIntents: AppIntentsPackage { |
| |
45 | static var includedPackages: [any AppIntentsPackage.Type] { [OrgAppIntents.self] } |
| |
46 | } |
| |
47 | #endif |
Sources/OrgstarMobile/MobileModel.swift
+18
| @@ -7,6 +7,7 @@ import OrgIndex |
| 7 | import OrgPresentation |
7 | import OrgPresentation |
| 8 | import OrgWorkspace |
8 | import OrgWorkspace |
| 9 | import UIKit |
9 | import UIKit |
| |
10 | import WidgetKit |
| 10 | |
11 | |
| 11 | /// The app's models, and the settings from the configuration folder: a folder you choose |
12 | /// The app's models, and the settings from the configuration folder: a folder you choose |
| 12 | /// (a synced copy of the Mac's `~/.config/orgstar`) with `config.toml` and `capture.toml`. |
13 | /// (a synced copy of the Mac's `~/.config/orgstar`) with `config.toml` and `capture.toml`. |
| @@ -209,5 +210,22 @@ final class MobileModel { |
| 209 | let reminders = await agenda.reminders(open: session.openTexts, lead: OrgPreferences.reminderLead()) |
210 | let reminders = await agenda.reminders(open: session.openTexts, lead: OrgPreferences.reminderLead()) |
| 210 | await scheduler.reconcile(reminders) |
211 | await scheduler.reconcile(reminders) |
| 211 | } |
212 | } |
| |
213 | |
| |
214 | /// The widgets' agenda: today and the next days in the app group's container, with the |
| |
215 | /// widgets reloaded when it changed. |
| |
216 | func updateWidgets() async { |
| |
217 | guard let workspace, let file = AgendaSnapshot.file() else { return } |
| |
218 | let days = AgendaModel(workspace: workspace) |
| |
219 | days.mode = .agenda(span: AgendaSnapshot.dayCount, startOffset: 0) |
| |
220 | if let agenda { |
| |
221 | days.includeSubfolders = agenda.includeSubfolders |
| |
222 | days.options = agenda.options |
| |
223 | days.calendarEvents = agenda.calendarEvents |
| |
224 | days.calendarNames = agenda.calendarNames |
| |
225 | } |
| |
226 | await days.refresh(open: session.openTexts) |
| |
227 | let snapshot = AgendaSnapshot(groups: days.groups(), today: days.today, theme: theme, generated: Date()) |
| |
228 | if (try? snapshot.write(to: file)) == true { WidgetCenter.shared.reloadAllTimelines() } |
| |
229 | } |
| 212 | } |
230 | } |
| 213 | #endif |
231 | #endif |
Sources/OrgstarMobile/OrgstarMobileApp.swift
+28 −1
| @@ -11,6 +11,10 @@ struct OrgstarMobileApp: App { |
| 11 | @UIApplicationDelegateAdaptor private var delegate: AppDelegate |
11 | @UIApplicationDelegateAdaptor private var delegate: AppDelegate |
| 12 | @State private var model = MobileModel.shared |
12 | @State private var model = MobileModel.shared |
| 13 | |
13 | |
| |
14 | init() { |
| |
15 | LiveClock.install() |
| |
16 | } |
| |
17 | |
| 14 | var body: some Scene { |
18 | var body: some Scene { |
| 15 | WindowGroup { |
19 | WindowGroup { |
| 16 | if let workspace = model.workspace, let agenda = model.agenda { |
20 | if let workspace = model.workspace, let agenda = model.agenda { |
| @@ -197,6 +201,7 @@ struct RootView: View { |
| 197 | try? await Task.sleep(for: .seconds(1)) |
201 | try? await Task.sleep(for: .seconds(1)) |
| 198 | guard !Task.isCancelled else { return } |
202 | guard !Task.isCancelled else { return } |
| 199 | await model.updateReminders() |
203 | await model.updateReminders() |
| |
204 | await model.updateWidgets() |
| 200 | } |
205 | } |
| 201 | // Spotlight follows the files and saved edits. |
206 | // Spotlight follows the files and saved edits. |
| 202 | .task(id: SpotlightKey(files: workspace.files.count, changed: workspace.changedFile?.version, saved: session.dirtyPaths.count)) { |
207 | .task(id: SpotlightKey(files: workspace.files.count, changed: workspace.changedFile?.version, saved: session.dirtyPaths.count)) { |
| @@ -217,7 +222,16 @@ struct RootView: View { |
| 217 | Task { await workspace.refresh() } |
222 | Task { await workspace.refresh() } |
| 218 | } |
223 | } |
| 219 | .onOpenURL { url in |
224 | .onOpenURL { url in |
| 220 | if let request = CaptureRequest(link: url.absoluteString) { capture = request } |
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) } |
| 221 | } |
235 | } |
| 222 | } |
236 | } |
| 223 | |
237 | |
| @@ -231,6 +245,19 @@ struct RootView: View { |
| 231 | if !waiting.isEmpty { capture = waiting.removeFirst() } |
245 | if !waiting.isEmpty { capture = waiting.removeFirst() } |
| 232 | } |
246 | } |
| 233 | |
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 | |
| 234 | private func clock(_ id: String) { |
261 | private func clock(_ id: String) { |
| 235 | if id == "app.clock.report" { |
262 | if id == "app.clock.report" { |
| 236 | showingClockReport = true |
263 | showingClockReport = true |
ios/Orgstar/Info.plist
+10
| @@ -24,11 +24,21 @@ |
| 24 | <string>org-protocol</string> |
24 | <string>org-protocol</string> |
| 25 | </array> |
25 | </array> |
| 26 | </dict> |
26 | </dict> |
| |
27 | <dict> |
| |
28 | <key>CFBundleURLName</key> |
| |
29 | <string>sh.krz.orgstar</string> |
| |
30 | <key>CFBundleURLSchemes</key> |
| |
31 | <array> |
| |
32 | <string>orgstar</string> |
| |
33 | </array> |
| |
34 | </dict> |
| 27 | </array> |
35 | </array> |
| 28 | <key>CFBundleVersion</key> |
36 | <key>CFBundleVersion</key> |
| 29 | <string>$(CURRENT_PROJECT_VERSION)</string> |
37 | <string>$(CURRENT_PROJECT_VERSION)</string> |
| 30 | <key>NSCalendarsFullAccessUsageDescription</key> |
38 | <key>NSCalendarsFullAccessUsageDescription</key> |
| 31 | <string>The agenda shows your calendar events alongside your Org entries. Orgstar only reads them.</string> |
39 | <string>The agenda shows your calendar events alongside your Org entries. Orgstar only reads them.</string> |
| |
40 | <key>NSSupportsLiveActivities</key> |
| |
41 | <true/> |
| 32 | <key>UIApplicationSceneManifest</key> |
42 | <key>UIApplicationSceneManifest</key> |
| 33 | <dict> |
43 | <dict> |
| 34 | <key>UIApplicationSupportsMultipleScenes</key> |
44 | <key>UIApplicationSupportsMultipleScenes</key> |