diff --git a/example/screens/testing-grounds/weather/WeatherTestingScreen.tsx b/example/screens/testing-grounds/weather/WeatherTestingScreen.tsx index 983bb3f0..7828455a 100644 --- a/example/screens/testing-grounds/weather/WeatherTestingScreen.tsx +++ b/example/screens/testing-grounds/weather/WeatherTestingScreen.tsx @@ -6,7 +6,7 @@ import { reloadWidgets, scheduleWidget, updateWidget, VoltraWidgetPreview, Widge import { Button } from '~/components/Button' import { Card } from '~/components/Card' -import { WeatherWidget } from '~/widgets/ios/IosWeatherWidget' +import { IosWeatherWidget } from '~/widgets/ios/IosWeatherWidget' import { SAMPLE_WEATHER_DATA, type WeatherCondition, type WeatherData } from '~/widgets/weather-types' const WIDGET_FAMILIES: { id: WidgetFamily; title: string; description: string }[] = [ @@ -74,9 +74,9 @@ export default function WeatherTestingScreen() { setIsUpdating(true) try { await updateWidget('weather', { - systemSmall: , - systemMedium: , - systemLarge: , + systemSmall: , + systemMedium: , + systemLarge: , }) await reloadWidgets(['weather']) } catch (error) { @@ -107,9 +107,9 @@ export default function WeatherTestingScreen() { setIsUpdating(true) try { await updateWidget('weather', { - systemSmall: , - systemMedium: , - systemLarge: , + systemSmall: , + systemMedium: , + systemLarge: , }) await reloadWidgets(['weather']) } catch (error) { @@ -237,9 +237,9 @@ export default function WeatherTestingScreen() { try { await updateWidget('weather', { - systemSmall: , - systemMedium: , - systemLarge: , + systemSmall: , + systemMedium: , + systemLarge: , }) // Don't call reloadWidgets here to avoid resetting scheduled timelines } catch (error) { @@ -348,7 +348,7 @@ export default function WeatherTestingScreen() { - + diff --git a/packages/voltra/ios/shared/VoltraNode.swift b/packages/voltra/ios/shared/VoltraNode.swift index 93d5fa1d..e69e2422 100644 --- a/packages/voltra/ios/shared/VoltraNode.swift +++ b/packages/voltra/ios/shared/VoltraNode.swift @@ -185,8 +185,6 @@ struct VoltraElementView: View { case "Chart": if #available(iOS 16.0, macOS 13.0, *) { VoltraChart(element) - } else { - EmptyView() } default: diff --git a/packages/voltra/ios/target/VoltraHomeWidget.swift b/packages/voltra/ios/target/VoltraHomeWidget.swift index 8cb6cf78..91acd1f5 100644 --- a/packages/voltra/ios/target/VoltraHomeWidget.swift +++ b/packages/voltra/ios/target/VoltraHomeWidget.swift @@ -276,6 +276,9 @@ public struct VoltraHomeWidgetProvider: TimelineProvider { public struct VoltraHomeWidgetView: View { public var entry: VoltraHomeWidgetEntry + @Environment(\.showsWidgetContainerBackground) private var showsWidgetContainerBackground + @Environment(\.widgetRenderingMode) private var widgetRenderingMode + public init(entry: VoltraHomeWidgetEntry) { self.entry = entry } @@ -285,12 +288,22 @@ public struct VoltraHomeWidgetView: View { } public var body: some View { + let mappedRenderingMode = mapWidgetRenderingMode(widgetRenderingMode) + Group { if let root = entry.rootNode { // No parsing here - just render the pre-parsed AST - let content = Voltra(root: root, activityId: "widget") - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .widgetURL(resolveDeepLinkURL(entry)) + let content = Voltra( + root: root, + activityId: "widget", + widget: VoltraWidgetEnvironment( + isHomeScreenWidget: true, + renderingMode: mappedRenderingMode, + showsContainerBackground: showsWidgetContainerBackground + ) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .widgetURL(resolveDeepLinkURL(entry)) if showRefreshButton { content.overlay(alignment: .topTrailing) { @@ -306,6 +319,19 @@ public struct VoltraHomeWidgetView: View { .disableWidgetMarginsIfAvailable() } + private func mapWidgetRenderingMode(_ mode: WidgetRenderingMode) -> VoltraWidgetRenderingMode { + switch mode { + case .fullColor: + return .fullColor + case .accented: + return .accented + case .vibrant: + return .vibrant + default: + return .unknown + } + } + @ViewBuilder private var refreshButton: some View { if #available(iOSApplicationExtension 17.0, *) { diff --git a/packages/voltra/ios/tests/JSGradientParserTests.swift b/packages/voltra/ios/tests/JSGradientParserTests.swift index 3b8b3fd5..1d1b1549 100644 --- a/packages/voltra/ios/tests/JSGradientParserTests.swift +++ b/packages/voltra/ios/tests/JSGradientParserTests.swift @@ -186,4 +186,16 @@ final class JSColorParserTests: XCTestCase { XCTAssertNil(JSColorParser.parse("rgb(255,0)")) XCTAssertNil(JSColorParser.parse("hsl(120, 100, 50%)")) } + + func testReducedPresentationPrimaryColorDetectionTreatsNeutralColorsAsAdaptive() { + XCTAssertTrue(JSColorParser.shouldUsePrimaryColorInReducedPresentation("#F9FAFB")) + XCTAssertTrue(JSColorParser.shouldUsePrimaryColorInReducedPresentation("#6B7280")) + XCTAssertTrue(JSColorParser.shouldUsePrimaryColorInReducedPresentation("white")) + } + + func testReducedPresentationPrimaryColorDetectionPreservesSemanticAccents() { + XCTAssertFalse(JSColorParser.shouldUsePrimaryColorInReducedPresentation("#34D399")) + XCTAssertFalse(JSColorParser.shouldUsePrimaryColorInReducedPresentation("#F87171")) + XCTAssertFalse(JSColorParser.shouldUsePrimaryColorInReducedPresentation("green")) + } } diff --git a/packages/voltra/ios/ui/Layout/FlexContainerHelper.swift b/packages/voltra/ios/ui/Layout/FlexContainerHelper.swift index 258ea2d9..7882b7c1 100644 --- a/packages/voltra/ios/ui/Layout/FlexContainerHelper.swift +++ b/packages/voltra/ios/ui/Layout/FlexContainerHelper.swift @@ -81,7 +81,7 @@ struct FlexContainerStyleModifier: ViewModifier { content .modifier(LayoutModifier(style: layoutWithoutPadding)) - .modifier(DecorationModifier(style: values.decoration)) + .modifier(DecorationModifier(style: values.decoration, layout: layout)) .modifier(RenderingModifier(style: values.rendering)) .voltraIfLet(layout.margin) { c, margin in c.background(.clear).padding(margin) diff --git a/packages/voltra/ios/ui/Style/CompositeStyle.swift b/packages/voltra/ios/ui/Style/CompositeStyle.swift index bc329919..fcd0e966 100644 --- a/packages/voltra/ios/ui/Style/CompositeStyle.swift +++ b/packages/voltra/ios/ui/Style/CompositeStyle.swift @@ -20,7 +20,7 @@ struct CompositeStyleModifier: ViewModifier { content .voltraIfLet(layout.padding) { c, p in c.padding(p) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: contentAlignment) - .modifier(DecorationModifier(style: decoration)) + .modifier(DecorationModifier(style: decoration, layout: layout)) .modifier(RenderingModifier(style: rendering)) .layoutValue(key: FlexItemLayoutKey.self, value: FlexItemValues( flexGrow: layout.flexGrow, @@ -53,7 +53,7 @@ struct CompositeStyleModifier: ViewModifier { alignment: alignment ) } - .modifier(DecorationModifier(style: decoration)) + .modifier(DecorationModifier(style: decoration, layout: layout)) .modifier(RenderingModifier(style: rendering)) } } diff --git a/packages/voltra/ios/ui/Style/DecorationStyle.swift b/packages/voltra/ios/ui/Style/DecorationStyle.swift index e7e345dd..6b06b8d1 100644 --- a/packages/voltra/ios/ui/Style/DecorationStyle.swift +++ b/packages/voltra/ios/ui/Style/DecorationStyle.swift @@ -11,6 +11,8 @@ struct DecorationStyle { struct DecorationModifier: ViewModifier { let style: DecorationStyle + let layout: LayoutStyle? + @Environment(\.voltraEnvironment) private var voltraEnvironment private func point(from unitPoint: UnitPoint, in size: CGSize) -> CGPoint { CGPoint(x: unitPoint.x * size.width, y: unitPoint.y * size.height) @@ -105,9 +107,43 @@ struct DecorationModifier: ViewModifier { .allowsHitTesting(false) } + private var suppressesDecorativeContainerEffects: Bool { + voltraEnvironment.widget?.suppressesDecorativeContainerEffects == true + } + + private var isFullBleedBackgroundCandidate: Bool { + guard let layout else { return false } + + if let flex = layout.flex, flex > 0 { + return true + } + + if layout.flexGrow > 0 { + return true + } + + return layout.width == .fill && layout.height == .fill + } + + private var resolvedBackgroundColor: BackgroundValue? { + guard suppressesDecorativeContainerEffects, isFullBleedBackgroundCandidate else { + return style.backgroundColor + } + + return nil + } + + private var resolvedGlassEffect: GlassEffect? { + guard suppressesDecorativeContainerEffects else { + return style.glassEffect + } + + return nil + } + func body(content: Content) -> some View { content - .voltraIfLet(style.backgroundColor) { content, bg in + .voltraIfLet(resolvedBackgroundColor) { content, bg in switch bg { case let .color(color): content.background(color) @@ -151,7 +187,7 @@ struct DecorationModifier: ViewModifier { y: shadow.offset.height ) } - .voltraIfLet(style.glassEffect) { content, glassEffect in + .voltraIfLet(resolvedGlassEffect) { content, glassEffect in if #available(iOS 26.0, *) { switch glassEffect { case .clear: diff --git a/packages/voltra/ios/ui/Style/JSColorParser.swift b/packages/voltra/ios/ui/Style/JSColorParser.swift index 7a62c439..7407d769 100644 --- a/packages/voltra/ios/ui/Style/JSColorParser.swift +++ b/packages/voltra/ios/ui/Style/JSColorParser.swift @@ -38,6 +38,13 @@ enum JSColorParser { return nil } + /// Returns true for neutral foreground colors that should follow WidgetKit's + /// reduced-presentation text color instead of preserving a hard-coded shade. + static func shouldUsePrimaryColorInReducedPresentation(_ value: Any?) -> Bool { + guard let components = parseColorComponents(value) else { return false } + return isNeutralColor(components.red, components.green, components.blue) + } + /// Check if a string is a valid hex color (6 or 8 hex digits) private static func isHexColor(_ string: String) -> Bool { guard string.count == 6 || string.count == 8 else { return false } @@ -46,6 +53,40 @@ enum JSColorParser { return string.unicodeScalars.allSatisfy { hexChars.contains($0) } } + private static func parseColorComponents(_ value: Any?) -> (red: Double, green: Double, blue: Double, alpha: Double)? { + guard let string = value as? String else { return nil } + + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.isEmpty { return nil } + + if trimmed.hasPrefix("#") { + return parseHexComponents(trimmed) + } + + if isHexColor(trimmed) { + return parseHexComponents("#" + trimmed) + } + + if trimmed.hasPrefix("rgb") { + return parseRGBComponents(trimmed) + } + + if trimmed.hasPrefix("hsl") { + return parseHSLComponents(trimmed) + } + + return parseNamedColorComponents(trimmed) + } + + private static func isNeutralColor(_ red: Double, _ green: Double, _ blue: Double) -> Bool { + let maxComponent = max(red, green, blue) + let minComponent = min(red, green, blue) + guard maxComponent > 0 else { return true } + + let saturation = (maxComponent - minComponent) / maxComponent + return saturation <= 0.2 + } + /// Parse named color strings private static func parseNamedColor(_ name: String) -> Color? { switch name { @@ -105,10 +146,56 @@ enum JSColorParser { } } + private static func parseNamedColorComponents(_ name: String) -> (red: Double, green: Double, blue: Double, alpha: Double)? { + switch name { + case "red": + return (1, 0, 0, 1) + case "orange": + return (1, 0.5, 0, 1) + case "yellow": + return (1, 1, 0, 1) + case "green": + return (0, 1, 0, 1) + case "mint": + return (0.62, 0.98, 0.84, 1) + case "teal": + return (0, 0.5, 0.5, 1) + case "cyan": + return (0, 1, 1, 1) + case "blue": + return (0, 0, 1, 1) + case "indigo": + return (0.29, 0, 0.51, 1) + case "purple": + return (0.5, 0, 0.5, 1) + case "pink": + return (1, 0.75, 0.8, 1) + case "brown": + return (0.6, 0.4, 0.2, 1) + case "white": + return (1, 1, 1, 1) + case "gray": + return (0.5, 0.5, 0.5, 1) + case "black": + return (0, 0, 0, 1) + case "clear", "transparent": + return (0, 0, 0, 0) + case "primary", "secondary": + return (0.5, 0.5, 0.5, 1) + default: + return nil + } + } + // MARK: - Hex Parser /// Supports #RGB, #RGBA, #RRGGBB, #RRGGBBAA private static func parseHex(_ hex: String) -> Color? { + guard let parsed = parseHexComponents(hex) else { return nil } + return Color(.sRGB, red: parsed.red, green: parsed.green, blue: parsed.blue, opacity: parsed.alpha) + } + + private static func parseHexComponents(_ hex: String) -> (red: Double, green: Double, blue: Double, alpha: Double)? { let hexSanitized = hex.replacingOccurrences(of: "#", with: "") var rgb: UInt64 = 0 @@ -142,7 +229,7 @@ enum JSColorParser { return nil } - return Color(.sRGB, red: r, green: g, blue: b, opacity: a) + return (r, g, b, a) } // MARK: - RGB Parser @@ -151,6 +238,12 @@ enum JSColorParser { /// - rgb(255, 0, 0), rgba(255, 0, 0, 0.5) /// - rgb(255 0 0 / 80%), rgba(255 0 0 / 0.8) private static func parseRGB(_ string: String) -> Color? { + guard let parsed = parseRGBComponents(string) else { return nil } + + return Color(.sRGB, red: parsed.red, green: parsed.green, blue: parsed.blue, opacity: parsed.alpha) + } + + private static func parseRGBComponents(_ string: String) -> (red: Double, green: Double, blue: Double, alpha: Double)? { guard let function = parseFunctionCall(string, allowedNames: ["rgb", "rgba"]) else { return nil } let isRgba = function.name == "rgba" @@ -162,7 +255,7 @@ enum JSColorParser { } guard let parsed else { return nil } - return Color(.sRGB, red: parsed.r, green: parsed.g, blue: parsed.b, opacity: parsed.a) + return (parsed.r, parsed.g, parsed.b, parsed.a) } // MARK: - HSL Parser @@ -171,6 +264,11 @@ enum JSColorParser { /// - hsl(120, 100%, 50%), hsla(120, 100%, 50%, 0.5) /// - hsl(120 100% 50% / 30%), hsla(120 100% 50% / 0.3) private static func parseHSL(_ string: String) -> Color? { + guard let parsed = parseHSLComponents(string) else { return nil } + return Color(.sRGB, red: parsed.red, green: parsed.green, blue: parsed.blue, opacity: parsed.alpha) + } + + private static func parseHSLComponents(_ string: String) -> (red: Double, green: Double, blue: Double, alpha: Double)? { guard let function = parseFunctionCall(string, allowedNames: ["hsl", "hsla"]) else { return nil } let isHsla = function.name == "hsla" @@ -182,14 +280,8 @@ enum JSColorParser { } guard let parsed else { return nil } - let h = parsed.h - let s = parsed.s - let l = parsed.l - let a = parsed.a - - // Convert HSL to RGB (HSL != HSB/HSV) - let (r, g, b) = hslToRgb(h: h, s: s, l: l) - return Color(.sRGB, red: r, green: g, blue: b, opacity: a) + let (r, g, b) = hslToRgb(h: parsed.h, s: parsed.s, l: parsed.l) + return (r, g, b, parsed.a) } private struct FunctionCall { diff --git a/packages/voltra/ios/ui/Style/StyleConverter.swift b/packages/voltra/ios/ui/Style/StyleConverter.swift index 9cd5538b..cabacce6 100644 --- a/packages/voltra/ios/ui/Style/StyleConverter.swift +++ b/packages/voltra/ios/ui/Style/StyleConverter.swift @@ -155,6 +155,7 @@ enum StyleConverter { if let color = JSColorParser.parse(js["color"]) { style.color = color + style.usesPrimaryColorInReducedPresentation = JSColorParser.shouldUsePrimaryColorInReducedPresentation(js["color"]) } if let size = JSStyleParser.number(js["fontSize"]) { diff --git a/packages/voltra/ios/ui/Style/TextStyle.swift b/packages/voltra/ios/ui/Style/TextStyle.swift index 2bf3389d..c00091e7 100644 --- a/packages/voltra/ios/ui/Style/TextStyle.swift +++ b/packages/voltra/ios/ui/Style/TextStyle.swift @@ -2,6 +2,7 @@ import SwiftUI struct TextStyle { var color: Color = .primary + var usesPrimaryColorInReducedPresentation = false var fontSize: CGFloat = 17 var fontWeight: Font.Weight = .regular var fontFamily: String? @@ -16,6 +17,18 @@ struct TextStyle { struct TextStyleModifier: ViewModifier { let style: TextStyle + @Environment(\.voltraEnvironment) private var voltraEnvironment + + private var resolvedColor: Color { + if let widget = voltraEnvironment.widget, + widget.usesReducedBackgroundPresentation, + style.usesPrimaryColorInReducedPresentation + { + return .primary + } + + return style.color + } func body(content: Content) -> some View { content @@ -28,7 +41,7 @@ struct TextStyleModifier: ViewModifier { : .system(size: style.fontSize, weight: style.fontWeight) ) // 2. Color - .foregroundColor(style.color) + .foregroundColor(resolvedColor) // 3. Layout / Spacing .multilineTextAlignment(style.alignment) .lineLimit(style.lineLimit) diff --git a/packages/voltra/ios/ui/Views/VoltraGlassContainer.swift b/packages/voltra/ios/ui/Views/VoltraGlassContainer.swift index 2e45380c..2544de64 100644 --- a/packages/voltra/ios/ui/Views/VoltraGlassContainer.swift +++ b/packages/voltra/ios/ui/Views/VoltraGlassContainer.swift @@ -4,6 +4,7 @@ public struct VoltraGlassContainer: VoltraView { public typealias Parameters = GlassContainerParameters public let element: VoltraElement + @Environment(\.voltraEnvironment) private var voltraEnvironment public init(_ element: VoltraElement) { self.element = element @@ -11,7 +12,9 @@ public struct VoltraGlassContainer: VoltraView { public var body: some View { if let children = element.children { - if #available(iOS 26.0, *) { + if voltraEnvironment.widget?.suppressesDecorativeContainerEffects == true { + children.applyStyle(element.style) + } else if #available(iOS 26.0, *) { let spacing = params.spacing ?? 0.0 GlassEffectContainer(spacing: CGFloat(spacing)) { children @@ -21,8 +24,6 @@ public struct VoltraGlassContainer: VoltraView { children }.applyStyle(element.style) } - } else { - EmptyView() } } } diff --git a/packages/voltra/ios/ui/Views/VoltraLinearGradient.swift b/packages/voltra/ios/ui/Views/VoltraLinearGradient.swift index 1ae8fd05..53edcdd4 100644 --- a/packages/voltra/ios/ui/Views/VoltraLinearGradient.swift +++ b/packages/voltra/ios/ui/Views/VoltraLinearGradient.swift @@ -4,6 +4,7 @@ public struct VoltraLinearGradient: VoltraView { public typealias Parameters = LinearGradientParameters public let element: VoltraElement + @Environment(\.voltraEnvironment) private var voltraEnvironment public init(_ element: VoltraElement) { self.element = element @@ -67,20 +68,48 @@ public struct VoltraLinearGradient: VoltraView { return Gradient(colors: [Color.black.opacity(0.25), Color.black.opacity(0.05)]) } + private func isFullBleedWidgetBackgroundCandidate() -> Bool { + guard let style = element.style, element.children != nil else { + return false + } + if let flex = style["flex"]?.doubleValue, flex > 0 { + return true + } + if let flexGrow = style["flexGrow"]?.doubleValue, flexGrow > 0 { + return true + } + let width = style["width"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let height = style["height"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + return width == "100%" && height == "100%" + } + public var body: some View { let gradient = buildGradient(params: params) let start = parsePoint(params.startPoint) let end = parsePoint(params.endPoint) + let anyStyle = element.style?.mapValues { $0.toAny() } ?? [:] + let (layout, baseDecoration, rendering, text) = StyleConverter.convert(anyStyle) + + var decoration = baseDecoration + decoration.backgroundColor = .linearGradient(gradient: gradient, startPoint: start, endPoint: end) - // Note: dither parameter is available in node.parameters["dither"] but SwiftUI's LinearGradient - // doesn't expose dithering control directly. This is handled automatically by the system. - let lg = LinearGradient(gradient: gradient, startPoint: start, endPoint: end) + if let widget = voltraEnvironment.widget, + widget.isHomeScreenWidget, + widget.usesReducedBackgroundPresentation, + isFullBleedWidgetBackgroundCandidate(), + let children = element.children + { + return AnyView(children.applyStyle(element.style)) + } - // Use ZStack with a Rectangle that fills and is tinted by the gradient, then overlay children. - return ZStack { - Rectangle().fill(lg) - element.children ?? .empty + if let children = element.children { + return AnyView( + children.applyStyle((layout, decoration, rendering, text)) + ) } - .applyStyle(element.style) + + return AnyView( + Color.clear.applyStyle((layout, decoration, rendering, text)) + ) } } diff --git a/packages/voltra/ios/ui/Views/VoltraText.swift b/packages/voltra/ios/ui/Views/VoltraText.swift index fb3aad76..a532b393 100644 --- a/packages/voltra/ios/ui/Views/VoltraText.swift +++ b/packages/voltra/ios/ui/Views/VoltraText.swift @@ -4,6 +4,7 @@ public struct VoltraText: VoltraView { public typealias Parameters = TextParameters public let element: VoltraElement + @Environment(\.voltraEnvironment) private var voltraEnvironment public init(_ element: VoltraElement) { self.element = element @@ -58,13 +59,24 @@ public struct VoltraText: VoltraView { return textStyle.alignment }() + let resolvedColor: Color = { + if let widget = voltraEnvironment.widget, + widget.usesReducedBackgroundPresentation, + textStyle.usesPrimaryColorInReducedPresentation + { + return .primary + } + + return textStyle.color + }() + Text(.init(textContent)) .kerning(textStyle.letterSpacing) .underline(textStyle.decoration == .underline || textStyle.decoration == .underlineLineThrough) .strikethrough(textStyle.decoration == .lineThrough || textStyle.decoration == .underlineLineThrough) // These technically work on View, but good to keep close .font(font) - .foregroundColor(textStyle.color) + .foregroundColor(resolvedColor) .multilineTextAlignment(alignment) .lineSpacing(textStyle.lineSpacing) .voltraIfLet(params.numberOfLines) { view, numberOfLines in diff --git a/packages/voltra/ios/ui/Voltra.swift b/packages/voltra/ios/ui/Voltra.swift index 22672831..cf063a80 100644 --- a/packages/voltra/ios/ui/Voltra.swift +++ b/packages/voltra/ios/ui/Voltra.swift @@ -1,8 +1,42 @@ import SwiftUI +public enum VoltraWidgetRenderingMode { + case fullColor + case accented + case vibrant + case unknown +} + +public struct VoltraWidgetEnvironment { + public let isHomeScreenWidget: Bool + public let renderingMode: VoltraWidgetRenderingMode + public let showsContainerBackground: Bool + + var usesReducedBackgroundPresentation: Bool { + renderingMode != .fullColor || !showsContainerBackground + } + + var suppressesDecorativeContainerEffects: Bool { + isHomeScreenWidget && usesReducedBackgroundPresentation + } + + public init( + isHomeScreenWidget: Bool, + renderingMode: VoltraWidgetRenderingMode, + showsContainerBackground: Bool + ) { + self.isHomeScreenWidget = isHomeScreenWidget + self.renderingMode = renderingMode + self.showsContainerBackground = showsContainerBackground + } +} + struct VoltraEnvironment { /// Activity ID for Live Activity interactions let activityId: String + + /// Widget-specific presentation context, when rendering inside WidgetKit. + let widget: VoltraWidgetEnvironment? } public struct Voltra: View { @@ -12,28 +46,35 @@ public struct Voltra: View { /// Activity ID for Live Activity interactions public var activityId: String + /// Widget-specific presentation context, when rendering inside WidgetKit. + var widget: VoltraWidgetEnvironment? + /// Initialize Voltra /// /// - Parameter root: Pre-parsed root VoltraNode /// - Parameter callback: Handler for element interactions /// - Parameter activityId: Activity ID for Live Activity interactions - public init(root: VoltraNode, activityId: String) { + /// - Parameter widget: Widget rendering context used to adapt Voltra output for WidgetKit surfaces + public init(root: VoltraNode, activityId: String, widget: VoltraWidgetEnvironment? = nil) { self.root = root self.activityId = activityId + self.widget = widget } /// Generated body for SwiftUI public var body: some View { root .environment(\.voltraEnvironment, VoltraEnvironment( - activityId: activityId + activityId: activityId, + widget: widget )) } } private struct VoltraEnvironmentKey: EnvironmentKey { static let defaultValue: VoltraEnvironment = .init( - activityId: "" + activityId: "", + widget: nil ) }