Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 66 additions & 24 deletions StrandiOSWidgets/NOOPWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,14 @@ struct NOOPProvider: TimelineProvider {
}

/// The glanceable widget — the iOS analogue of the macOS menu-bar extra.
/// Home Screen families mirror Today's hero trio (Charge · Effort · Rest) as score rings; Lock Screen
/// accessories stay compact single-line / gauge layouts.
/// Home Screen families mirror Today's hero trio (Charge · Effort · Rest) as score rings. Lock Screen
/// accessories are compact: a single line, a gauge, or the rectangular glyph-over-value trio.
struct NOOPWidgetView: View {
@Environment(\.widgetFamily) private var family
/// `.fullColor` on the home screen and in the gallery; `.vibrant` or `.accented` on the lock screen,
/// where the system desaturates any colour it is handed. The accessory cells check this rather than
/// tinting unconditionally — see `accessoryScore`.
@Environment(\.widgetRenderingMode) private var renderingMode
let entry: NOOPEntry

private var snap: WidgetSnapshot { entry.snapshot }
Expand Down Expand Up @@ -97,37 +101,75 @@ struct NOOPWidgetView: View {

/// Lock-Screen rectangular accessory: Charge · Effort · Rest, same trio as the Home Screen rings.
private var rectangular: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text("NOOP")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(StrandPalette.textSecondary)
Spacer(minLength: 0)
if let bpm = snap.bpm {
Text("\(bpm) bpm")
.font(.caption2)
.foregroundStyle(StrandPalette.textTertiary)
}
// The lock screen gives this family roughly 72pt of height for everything. A "NOOP" title spent
// a whole row of that restating which widget the user chose to add, leaving the three scores —
// the only reason to add it — squeezed underneath. The title is gone and the heart-rate line is
// now conditional, so with no live HR the scores get the entire area.
VStack(spacing: 2) {
if let bpm = snap.bpm {
Text("\(bpm) bpm")
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .trailing)
}
HStack(alignment: .top, spacing: 0) {
accessoryScore("Charge", text: snap.recovery.map { "\($0)%" }, tint: chargeColor)
accessoryScore("Effort", text: effortText, tint: effortColor)
accessoryScore("Rest", text: snap.rest.map { "\($0)%" }, tint: restColor)
accessoryScore("Charge", symbol: "figure.mind.and.body",
text: snap.recovery.map { "\($0)%" }, tint: chargeColor)
accessoryScore("Effort", symbol: "figure.strengthtraining.traditional",
text: effortText, tint: effortColor)
accessoryScore("Rest", symbol: "moon.fill",
text: snap.rest.map { "\($0)%" }, tint: restColor)
}
}
}

private func accessoryScore(_ label: String, text: String?, tint: Color) -> some View {
VStack(alignment: .leading, spacing: 0) {
/// One score cell. On the LOCK SCREEN the domain tint is deliberately dropped; it survives only
/// where the system actually renders full colour, which for this family means a gallery preview.
///
/// Lock-screen widgets render in `.vibrant` (or `.accented`), where the system desaturates whatever
/// colour it is given and maps it onto the wallpaper. A domain colour handed to it does not survive
/// as that colour — it lands as an arbitrary grey whose luminance nobody chose, so Charge, Effort and
/// Rest stopped being distinguishable AND stopped being legible. `.primary`/`.secondary` are the two
/// levels the system is designed to map, so the value reads at full strength and the glyph above it
/// recedes, which is the hierarchy the tint was there to express in the first place.
///
/// The `.fullColor` branch is defensive rather than hot: this family renders `.vibrant` on the lock
/// screen and in StandBy, so the tint realistically only reaches a gallery preview.
private func accessoryScore(_ label: String, symbol: String, text: String?, tint: Color) -> some View {
VStack(spacing: 1) {
// Glyph over value, the shape the request asked for. A 9pt word under each number was
// spending scarce height on text nobody needs twice — the icons carry the metric identity.
Image(systemName: symbol)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(renderingMode == .fullColor
? AnyShapeStyle(StrandPalette.textTertiary)
: AnyShapeStyle(HierarchicalShapeStyle.secondary))
Text(text ?? "–")
.font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundStyle(text == nil ? StrandPalette.textTertiary : tint)
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundStyle(scoreStyle(hasValue: text != nil, tint: tint))
.minimumScaleFactor(0.7)
Text(label)
.font(.system(size: 9))
.foregroundStyle(StrandPalette.textTertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(maxWidth: .infinity)
// An icon says nothing to VoiceOver, and the word it replaced was the only thing naming this
// metric. Collapse the cell to one element that still speaks "Charge, 68 percent".
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(label))
// Plain literal, not String(localized:). This extension's sources are StrandiOSWidgets +
// StrandiOSShared only — Strand/Resources/Localizable.xcstrings is NOT in the target, and
// String(localized:) resolves against Bundle.main, which for an app extension is the extension's
// own bundle. It would compile, look localized, and render English in every locale. Every other
// string in this file is a bare literal for the same reason: the widget is not localized yet.
.accessibilityValue(Text(text ?? "No data"))
}

private func scoreStyle(hasValue: Bool, tint: Color) -> AnyShapeStyle {
// Spelled out rather than leaning on leading-dot inference through AnyShapeStyle's generic
// init, which is the kind of expression that type-checks in a playground and not in a build.
guard renderingMode == .fullColor else {
return hasValue ? AnyShapeStyle(HierarchicalShapeStyle.primary)
: AnyShapeStyle(HierarchicalShapeStyle.secondary)
}
return hasValue ? AnyShapeStyle(tint) : AnyShapeStyle(StrandPalette.textTertiary)
}

// MARK: - Home Screen: systemSmall
Expand Down
Loading