Watch: waiting-count complication and multi-select questions

A WidgetKit extension (accessoryCircular, Rectangular, Inline, Corner) shows
GET /v1/summary. The device token moves to the App Group's Keychain access
group on launch so the extension can read it without re-pairing; the app
reloads the timeline when its feed counts change and on notifications.
Multi-select questions toggle options and send an array of values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
gibandClaude Opus 5.5 committed 2026-11-01 11:06:38 -05:00
1 parent d4d67295c6
commit 0994fd7d5a
18 files changed
+800 -23

No files matched your search

+47 -8
View File
@@ -1,14 +1,12 @@
// App-wide state: whether the watch is paired, the navigation path, and the latest feed.
// App-wide state: whether the watch is paired, the navigation path, and the latest feed. It also
// keeps the complication current: it shares the mode and latest counts through the App Group and
// reloads the complication's timeline when they change.
import Foundation
import Observation
import UserNotifications
import WatchKit
enum Route: Hashable, Sendable {
case thread(hostId: String, threadId: String)
case newThread
}
import WidgetKit
@MainActor @Observable
final class AppModel {
@@ -22,12 +20,16 @@ final class AppModel {
/// The one instance the app delegate and notification handler share with the views.
static let shared = AppModel(launchArguments: ProcessInfo.processInfo.arguments)
var mode: Mode
var mode: Mode {
didSet { if mode != oldValue { shareMode() } }
}
var path: [Route] = []
private(set) var feed: Feed?
private(set) var feedError: String?
private var apnsToken: String?
private var refreshing = false
/// The counts the complication was last told about.
private var sharedSummary: Summary?
init(mode: Mode, feed: Feed? = nil, path: [Route] = []) {
self.mode = mode
@@ -38,6 +40,7 @@ final class AppModel {
/// `-demo` starts in demo mode. `-screen new` or `-thread <hostId>/<threadId>` opens that
/// screen, which is how the screenshots in watch/screenshots are taken.
convenience init(launchArguments args: [String]) {
DeviceStore.migrateToSharedGroup()
let demo = args.contains("-demo")
let mode: Mode = demo ? .demo : DeviceStore.load().map(Mode.paired) ?? .unpaired
var path: [Route] = []
@@ -49,6 +52,7 @@ final class AppModel {
if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] }
}
self.init(mode: mode, path: path)
shareMode()
}
var isPaired: Bool {
@@ -114,8 +118,10 @@ final class AppModel {
refreshing = true
defer { refreshing = false }
do {
feed = try await api.feed()
let feed = try await api.feed()
self.feed = feed
feedError = nil
share(Summary(feed))
} catch {
feedError = message(for: error)
}
@@ -125,6 +131,38 @@ final class AppModel {
path = [route]
}
/// Opens a `wristcode://` link from the complication.
func open(url: URL) {
guard mode != .unpaired, let route = Route(url: url) else { return }
open(route)
}
// MARK: - Complication
/// Tells the complication about the mode. Counts from another mode, or from before signing
/// out, are forgotten.
private func shareMode() {
let demo = mode == .demo
if SharedState.demo != demo || mode == .unpaired { SharedState.lastSummary = nil }
SharedState.demo = demo
sharedSummary = nil
reloadComplication()
}
/// Hands the complication fresh counts. The feed refreshes every 10 s while on screen, so the
/// timeline only reloads when the counts or the next thread change.
private func share(_ summary: Summary) {
guard summary != sharedSummary else { return }
sharedSummary = summary
SharedState.lastSummary = summary
reloadComplication()
}
/// Asks the complication to fetch the summary again, as after a push or a notification action.
func reloadComplication() {
WidgetCenter.shared.reloadAllTimelines()
}
// MARK: - Notifications
func enableNotifications() async {
@@ -156,6 +194,7 @@ final class AppModel {
}
func notificationArrived() {
reloadComplication()
Task { await refreshFeed() }
}
}