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/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/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/TextStyle.swift b/packages/voltra/ios/ui/Style/TextStyle.swift index 2bf3389d..c168fd02 100644 --- a/packages/voltra/ios/ui/Style/TextStyle.swift +++ b/packages/voltra/ios/ui/Style/TextStyle.swift @@ -16,6 +16,15 @@ struct TextStyle { struct TextStyleModifier: ViewModifier { let style: TextStyle + @Environment(\.voltraEnvironment) private var voltraEnvironment + + private var resolvedColor: Color { + if let widget = voltraEnvironment.widget, widget.usesReducedBackgroundPresentation { + return .primary + } + + return style.color + } func body(content: Content) -> some View { content @@ -28,7 +37,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..852952b5 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 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..416050c7 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,21 @@ public struct VoltraText: VoltraView { return textStyle.alignment }() + let resolvedColor: Color = { + if let widget = voltraEnvironment.widget, widget.usesReducedBackgroundPresentation { + 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 ) }