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 | 47 | public var priorityColor: Hex? |
| 48 | 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 | 74 | public var link: AppLink? { |
| 51 | 75 | guard let path, let offset else { return nil } |
| 52 | 76 | return .entry(path: path, offset: offset) |
| @@ -67,6 +91,14 @@ public struct AgendaSnapshot: Codable, Equatable, Sendable { |
| 67 | 91 | public var rows: [Row] |
| 68 | 92 | |
| 69 | 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 | 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 | 7 | import OrgPresentation |
| 8 | 8 | import OrgWorkspace |
| 9 | 9 | import UIKit |
| 10 | import WidgetKit |
| 10 | 11 | |
| 11 | 12 | /// The app's models, and the settings from the configuration folder: a folder you choose |
| 12 | 13 | /// (a synced copy of the Mac's `~/.config/orgstar`) with `config.toml` and `capture.toml`. |
| @@ -209,5 +210,22 @@ final class MobileModel { |
| 209 | 210 | let reminders = await agenda.reminders(open: session.openTexts, lead: OrgPreferences.reminderLead()) |
| 210 | 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 | 231 | #endif |
Sources/OrgstarMobile/OrgstarMobileApp.swift
+28 −1
| @@ -11,6 +11,10 @@ struct OrgstarMobileApp: App { |
| 11 | 11 | @UIApplicationDelegateAdaptor private var delegate: AppDelegate |
| 12 | 12 | @State private var model = MobileModel.shared |
| 13 | 13 | |
| 14 | init() { |
| 15 | LiveClock.install() |
| 16 | } |
| 17 | |
| 14 | 18 | var body: some Scene { |
| 15 | 19 | WindowGroup { |
| 16 | 20 | if let workspace = model.workspace, let agenda = model.agenda { |
| @@ -197,6 +201,7 @@ struct RootView: View { |
| 197 | 201 | try? await Task.sleep(for: .seconds(1)) |
| 198 | 202 | guard !Task.isCancelled else { return } |
| 199 | 203 | await model.updateReminders() |
| 204 | await model.updateWidgets() |
| 200 | 205 | } |
| 201 | 206 | // Spotlight follows the files and saved edits. |
| 202 | 207 | .task(id: SpotlightKey(files: workspace.files.count, changed: workspace.changedFile?.version, saved: session.dirtyPaths.count)) { |
| @@ -217,7 +222,16 @@ struct RootView: View { |
| 217 | 222 | Task { await workspace.refresh() } |
| 218 | 223 | } |
| 219 | 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 | 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 | 261 | private func clock(_ id: String) { |
| 235 | 262 | if id == "app.clock.report" { |
| 236 | 263 | showingClockReport = true |
ios/Orgstar/Info.plist
+10
| @@ -24,11 +24,21 @@ |
| 24 | 24 | <string>org-protocol</string> |
| 25 | 25 | </array> |
| 26 | 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 | 35 | </array> |
| 28 | 36 | <key>CFBundleVersion</key> |
| 29 | 37 | <string>$(CURRENT_PROJECT_VERSION)</string> |
| 30 | 38 | <key>NSCalendarsFullAccessUsageDescription</key> |
| 31 | 39 | <string>The agenda shows your calendar events alongside your Org entries. Orgstar only reads them.</string> |
| 40 | <key>NSSupportsLiveActivities</key> |
| 41 | <true/> |
| 32 | 42 | <key>UIApplicationSceneManifest</key> |
| 33 | 43 | <dict> |
| 34 | 44 | <key>UIApplicationSupportsMultipleScenes</key> |