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,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)
|
||||
}
|
||||
Reference in new issue
Block a user