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]>
208 lines
6.7 KiB
Swift
208 lines
6.7 KiB
Swift
// 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
|
|
import WidgetKit
|
|
|
|
@MainActor @Observable
|
|
final class AppModel {
|
|
enum Mode: Equatable {
|
|
case unpaired
|
|
case paired(Credentials)
|
|
/// Serves fixtures from `DemoGateway`. Nothing leaves the watch.
|
|
case demo
|
|
}
|
|
|
|
/// The one instance the app delegate and notification handler share with the views.
|
|
static let shared = AppModel(launchArguments: ProcessInfo.processInfo.arguments)
|
|
|
|
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
|
|
self.feed = feed
|
|
self.path = path
|
|
}
|
|
|
|
/// `-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] = []
|
|
if demo, let i = args.firstIndex(of: "-screen"), i + 1 < args.count, args[i + 1] == "new" {
|
|
path = [.newThread]
|
|
}
|
|
if demo, let i = args.firstIndex(of: "-thread"), i + 1 < args.count {
|
|
let parts = args[i + 1].split(separator: "/", maxSplits: 1).map(String.init)
|
|
if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] }
|
|
}
|
|
self.init(mode: mode, path: path)
|
|
shareMode()
|
|
}
|
|
|
|
var isPaired: Bool {
|
|
if case .paired = mode { return true }
|
|
return false
|
|
}
|
|
|
|
var api: any GatewayAPI {
|
|
switch mode {
|
|
case let .paired(credentials):
|
|
GatewayClient(baseURL: credentials.gatewayURL, token: credentials.token)
|
|
case .demo:
|
|
DemoGateway.shared
|
|
case .unpaired:
|
|
GatewayClient(baseURL: URL(string: DeviceStore.defaultGatewayURL)!, token: nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Pairing
|
|
|
|
func pair(gatewayURL: URL, code: String) async throws {
|
|
let deviceName = WKInterfaceDevice.current().name
|
|
let response = try await GatewayClient.pair(baseURL: gatewayURL, code: code, deviceName: deviceName)
|
|
let credentials = Credentials(gatewayURL: gatewayURL, token: response.deviceToken)
|
|
DeviceStore.save(credentials)
|
|
mode = .paired(credentials)
|
|
feed = nil
|
|
feedError = nil
|
|
path = []
|
|
await enableNotifications()
|
|
}
|
|
|
|
func startDemo() {
|
|
mode = .demo
|
|
feed = nil
|
|
feedError = nil
|
|
path = []
|
|
}
|
|
|
|
/// Back to the pairing screen. A real token is forgotten; the gateway URL is kept.
|
|
func signOut() {
|
|
if isPaired { DeviceStore.clearToken() }
|
|
mode = .unpaired
|
|
feed = nil
|
|
feedError = nil
|
|
path = []
|
|
}
|
|
|
|
/// The message to show for a failed call. A 401 sends the app back to pairing and returns nil.
|
|
func message(for error: any Error) -> String? {
|
|
if error is CancellationError { return nil }
|
|
if let error = error as? GatewayError, error == .unauthorized {
|
|
signOut()
|
|
return nil
|
|
}
|
|
return error.localizedDescription
|
|
}
|
|
|
|
// MARK: - Feed
|
|
|
|
func refreshFeed() async {
|
|
guard mode != .unpaired, !refreshing else { return }
|
|
refreshing = true
|
|
defer { refreshing = false }
|
|
do {
|
|
let feed = try await api.feed()
|
|
self.feed = feed
|
|
feedError = nil
|
|
share(Summary(feed))
|
|
} catch {
|
|
feedError = message(for: error)
|
|
}
|
|
}
|
|
|
|
func open(_ route: Route) {
|
|
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 {
|
|
guard isPaired else { return }
|
|
let center = UNUserNotificationCenter.current()
|
|
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
|
if granted {
|
|
WKApplication.shared().registerForRemoteNotifications()
|
|
}
|
|
}
|
|
|
|
func didRegister(apnsToken data: Data) {
|
|
apnsToken = data.map { String(format: "%02x", $0) }.joined()
|
|
Task { await sendPushToken() }
|
|
}
|
|
|
|
private func sendPushToken() async {
|
|
guard isPaired, let apnsToken else { return }
|
|
#if DEBUG
|
|
let environment = PushRegistration.Environment.sandbox
|
|
#else
|
|
let environment = PushRegistration.Environment.production
|
|
#endif
|
|
do {
|
|
try await api.registerPush(PushRegistration(apnsToken: apnsToken, apnsEnvironment: environment))
|
|
} catch {
|
|
_ = message(for: error)
|
|
}
|
|
}
|
|
|
|
func notificationArrived() {
|
|
reloadComplication()
|
|
Task { await refreshFeed() }
|
|
}
|
|
}
|
|
|
|
extension AppModel {
|
|
/// A demo model with the fixture feed already loaded, for previews.
|
|
static func preview(path: [Route] = []) -> AppModel {
|
|
AppModel(mode: .demo, feed: DemoFixtures.previewFeed(), path: path)
|
|
}
|
|
}
|