diff --git a/StrandiOSWidgets/NOOPWidget.swift b/StrandiOSWidgets/NOOPWidget.swift index 5e55dc7ebf..50907d89ef 100644 --- a/StrandiOSWidgets/NOOPWidget.swift +++ b/StrandiOSWidgets/NOOPWidget.swift @@ -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 } @@ -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