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]>
213 lines
8.0 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|