Files
wristcode/watch/WristCodeWidget/WaitingView.swift
T
gibandClaude Opus 5.5 67992dbf43 Watch: snapshot tests render the complication and multi-select screenshots
ImageRenderer draws the real views with demo data, since the watch simulator
is not drivable here. README covers the widget target and how to add it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:06:38 -05:00

213 lines
8.0 KiB
Swift

// 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.
//
// The app target compiles this file too, so the snapshot tests can render the complication.
import SwiftUI
import WidgetKit
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 WaitingView: View {
@Environment(\.widgetFamily) private var widgetFamily
var entry: WaitingEntry
/// Overrides the environment's family, for rendering outside a widget.
var familyOverride: WidgetFamily?
private var family: WidgetFamily { familyOverride ?? widgetFamily }
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"
}
}
}