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:
1 parent
d4d67295c6
commit
0994fd7d5a
18 files changed
+800
-23
No files matched your search
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>WristCode</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,217 @@
|
||||
// One view per complication family, in mock B's colors: amber when something needs you, teal for
|
||||
// work in progress, and nothing that animates. Tinted faces use the accented parts only.
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct WaitingView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
var entry: WaitingEntry
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.widgetURL(next?.route.url)
|
||||
.containerBackground(for: .widget) {
|
||||
if waiting > 0 {
|
||||
LinearGradient(colors: [Theme.you.opacity(0.3), Theme.card], startPoint: .top, endPoint: .bottom)
|
||||
} else {
|
||||
Theme.card
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch family {
|
||||
case .accessoryCircular: circular
|
||||
case .accessoryRectangular: rectangular
|
||||
case .accessoryInline: inline
|
||||
case .accessoryCorner: corner
|
||||
default: circular
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Families
|
||||
|
||||
private var circular: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
if let summary, summary.needsYou > 0 {
|
||||
VStack(spacing: -3) {
|
||||
Text("\(summary.needsYou)")
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.you)
|
||||
.minimumScaleFactor(0.6)
|
||||
.widgetAccentable()
|
||||
Text("waiting")
|
||||
.font(.system(size: 9, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else if summary != nil {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Image(systemName: unpairedSymbol)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(spoken)
|
||||
}
|
||||
|
||||
private var rectangular: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let summary {
|
||||
if let next = summary.next, summary.needsYou > 0 {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(summary.needsYou)")
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
Text("waiting")
|
||||
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
||||
Spacer(minLength: 0)
|
||||
staleMark
|
||||
}
|
||||
.foregroundStyle(Theme.you)
|
||||
.widgetAccentable()
|
||||
Text(next.title)
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
.lineLimit(1)
|
||||
Text(next.hostLabel)
|
||||
.font(.system(size: 13, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark")
|
||||
Text("Nothing waiting")
|
||||
Spacer(minLength: 0)
|
||||
staleMark
|
||||
}
|
||||
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(summary.working) working")
|
||||
.font(.system(size: 17, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(summary.working > 0 ? Theme.work : .secondary)
|
||||
.widgetAccentable()
|
||||
if summary.hostsOffline > 0 {
|
||||
Text(summary.hostsOffline == 1 ? "1 host offline" : "\(summary.hostsOffline) hosts offline")
|
||||
.font(.system(size: 13, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("WristCode")
|
||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.work)
|
||||
.widgetAccentable()
|
||||
Text(entry.content == .notPaired ? "Open the app to pair" : "Can't reach the gateway")
|
||||
.font(.system(size: 13, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var inline: some View {
|
||||
ViewThatFits {
|
||||
Text(spoken)
|
||||
if let summary { Text("\(summary.needsYou) waiting") }
|
||||
}
|
||||
}
|
||||
|
||||
private var corner: some View {
|
||||
Group {
|
||||
if let summary {
|
||||
Text("\(summary.needsYou)")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(summary.needsYou > 0 ? Theme.you : Color.secondary)
|
||||
.widgetAccentable()
|
||||
} else {
|
||||
Image(systemName: unpairedSymbol)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.widgetLabel {
|
||||
if let summary {
|
||||
Text(summary.needsYou > 0 ? "waiting" : "\(summary.working) working")
|
||||
} else {
|
||||
Text("WristCode")
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(spoken)
|
||||
}
|
||||
|
||||
// MARK: - Pieces
|
||||
|
||||
private var summary: Summary? {
|
||||
if case let .counts(summary, _) = entry.content { return summary }
|
||||
return nil
|
||||
}
|
||||
|
||||
private var waiting: Int { summary?.needsYou ?? 0 }
|
||||
|
||||
private var next: Summary.Next? { waiting > 0 ? summary?.next : nil }
|
||||
|
||||
private var unpairedSymbol: String {
|
||||
entry.content == .notPaired ? "applewatch.slash" : "wifi.slash"
|
||||
}
|
||||
|
||||
/// Shown beside the count when it is the last one seen, not a fresh one.
|
||||
@ViewBuilder
|
||||
private var staleMark: some View {
|
||||
if case .counts(_, stale: true) = entry.content {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// "2 waiting · 3 working", the inline text and the VoiceOver label.
|
||||
private var spoken: String {
|
||||
switch entry.content {
|
||||
case let .counts(summary, _): "\(summary.needsYou) waiting · \(summary.working) working"
|
||||
case .notPaired: "WristCode not paired"
|
||||
case .unavailable: "WristCode offline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func entry(_ needsYou: Int, _ working: Int, next: Bool = true) -> WaitingEntry {
|
||||
let thread = Summary.Next(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini")
|
||||
return WaitingEntry(date: .now, content: .counts(
|
||||
Summary(needsYou: needsYou, working: working, next: needsYou > 0 && next ? thread : nil, hostsOffline: 1),
|
||||
stale: false))
|
||||
}
|
||||
|
||||
#Preview("Circular", as: .accessoryCircular) {
|
||||
WaitingWidget()
|
||||
} timeline: {
|
||||
entry(2, 3)
|
||||
entry(0, 3)
|
||||
WaitingEntry(date: .now, content: .notPaired)
|
||||
}
|
||||
|
||||
#Preview("Rectangular", as: .accessoryRectangular) {
|
||||
WaitingWidget()
|
||||
} timeline: {
|
||||
entry(2, 3)
|
||||
entry(0, 3)
|
||||
WaitingEntry(date: .now, content: .notPaired)
|
||||
}
|
||||
|
||||
#Preview("Inline", as: .accessoryInline) {
|
||||
WaitingWidget()
|
||||
} timeline: {
|
||||
entry(2, 3)
|
||||
}
|
||||
|
||||
#Preview("Corner", as: .accessoryCorner) {
|
||||
WaitingWidget()
|
||||
} timeline: {
|
||||
entry(2, 3)
|
||||
entry(0, 3)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// The watch face complication: how many threads are waiting on you, from `GET /v1/summary`. It
|
||||
// reads the device token from the shared Keychain group and the demo flag from the App Group, and
|
||||
// the app reloads it whenever its own feed or a notification changes the counts.
|
||||
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct WristCodeWidgets: WidgetBundle {
|
||||
var body: some Widget {
|
||||
WaitingWidget()
|
||||
}
|
||||
}
|
||||
|
||||
struct WaitingWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "WaitingCount", provider: WaitingProvider()) { entry in
|
||||
WaitingView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Waiting on you")
|
||||
.description("Threads waiting for your approval or answer.")
|
||||
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner])
|
||||
}
|
||||
}
|
||||
|
||||
struct WaitingEntry: TimelineEntry {
|
||||
enum Content: Equatable {
|
||||
/// Counts from the gateway. `stale` when they are the last ones seen because the gateway
|
||||
/// could not be reached.
|
||||
case counts(Summary, stale: Bool)
|
||||
case notPaired
|
||||
/// The gateway could not be reached and there is nothing saved to show.
|
||||
case unavailable
|
||||
}
|
||||
|
||||
var date: Date
|
||||
var content: Content
|
||||
|
||||
/// Ranks the complication higher in the Smart Stack while something waits.
|
||||
var relevance: TimelineEntryRelevance? {
|
||||
guard case let .counts(summary, _) = content, summary.needsYou > 0 else { return nil }
|
||||
return TimelineEntryRelevance(score: Float(summary.needsYou))
|
||||
}
|
||||
|
||||
static let sample = WaitingEntry(date: .now, content: .counts(Summary(
|
||||
needsYou: 2, working: 3,
|
||||
next: .init(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini"),
|
||||
hostsOffline: 0), stale: false))
|
||||
}
|
||||
|
||||
struct WaitingProvider: TimelineProvider {
|
||||
/// How long until WidgetKit asks again. The app reloads sooner when it learns something.
|
||||
static let refreshInterval: TimeInterval = 15 * 60
|
||||
|
||||
func placeholder(in context: Context) -> WaitingEntry {
|
||||
.sample
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping @Sendable (WaitingEntry) -> Void) {
|
||||
if context.isPreview {
|
||||
completion(.sample)
|
||||
return
|
||||
}
|
||||
Task { completion(await Self.load()) }
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline<WaitingEntry>) -> Void) {
|
||||
Task {
|
||||
let entry = await Self.load()
|
||||
completion(Timeline(entries: [entry], policy: .after(entry.date.addingTimeInterval(Self.refreshInterval))))
|
||||
}
|
||||
}
|
||||
|
||||
static func load() async -> WaitingEntry {
|
||||
let api: any GatewayAPI
|
||||
if SharedState.demo {
|
||||
api = DemoGateway.shared
|
||||
} else if let credentials = DeviceStore.load() {
|
||||
api = GatewayClient(baseURL: credentials.gatewayURL, token: credentials.token)
|
||||
} else {
|
||||
return WaitingEntry(date: .now, content: .notPaired)
|
||||
}
|
||||
do {
|
||||
let summary = try await api.summary()
|
||||
SharedState.lastSummary = summary
|
||||
return WaitingEntry(date: .now, content: .counts(summary, stale: false))
|
||||
} catch GatewayError.unauthorized {
|
||||
return WaitingEntry(date: .now, content: .notPaired)
|
||||
} catch {
|
||||
guard let last = SharedState.lastSummary else { return WaitingEntry(date: .now, content: .unavailable) }
|
||||
return WaitingEntry(date: .now, content: .counts(last, stale: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.org.gbrown.wristcode</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in new issue
Block a user