Watch app MVP: XcodeGen project, gateway client, pair, feed, thread, new thread, notifications
SwiftUI watch-only app in watch/, generated by XcodeGen. Codable models mirror gateway/src/contract.ts, with lenient string enums so new phases or decisions still decode. A DemoGateway serves fixtures from the docs/api.md examples for previews and demo mode. Swift Testing covers decoding the documented JSON, request encoding, notification action parsing and the small view helpers. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ba1624eb9e
commit
bdfb79b891
19 files changed
+2413
No files matched your search
@@ -0,0 +1,168 @@
|
||||
// App-wide state: whether the watch is paired, the navigation path, and the latest feed.
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import UserNotifications
|
||||
import WatchKit
|
||||
|
||||
enum Route: Hashable, Sendable {
|
||||
case thread(hostId: String, threadId: String)
|
||||
case newThread
|
||||
}
|
||||
|
||||
@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
|
||||
var path: [Route] = []
|
||||
private(set) var feed: Feed?
|
||||
private(set) var feedError: String?
|
||||
private var apnsToken: String?
|
||||
private var refreshing = false
|
||||
|
||||
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]) {
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
feed = try await api.feed()
|
||||
feedError = nil
|
||||
} catch {
|
||||
feedError = message(for: error)
|
||||
}
|
||||
}
|
||||
|
||||
func open(_ route: Route) {
|
||||
path = [route]
|
||||
}
|
||||
|
||||
// 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() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user