diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..c5ab90d16 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +This document summarizes the initial implementation of the A2UI protocol in Swift, covering the development of the SwiftUI renderer and the sample client application. + +## [0.9.0] - Initial Swift Implementation + +### SwiftUI Renderer +- **Protocol Support**: Implemented a native SwiftUI renderer for the A2UI v0.9 specification. +- **Basic Catalog Components**: Full support for standard UI components: + - **Layout**: `Row`, `Column`, `List`, `Card`, `Tabs`, `Divider`. + - **Content**: `Text`, `Heading`, `Image`, `Icon`, `Video`, `AudioPlayer`. + - **Input**: `TextField`, `Button`, `CheckBox`, `Slider`, `DateTimeInput`, `MultipleChoice`. + - **Overlays**: `Modal`. +- **Standard Functions**: Implemented a function evaluation engine for catalog functions: + - **Validation**: `required`, `email`, `numeric`, `regex`. + - **Formatting**: `formatDate`, `formatCurrency`, `pluralize`. +- **Data Binding**: Integrated `A2UIDataStore` for managing reactive, JSON-based data models and template resolution. +- **Media Integration**: Native `AVPlayer` integration for audio and video components with custom playback controls. +- **SF Symbols Mapping**: Automatic mapping of Material/Google Font icon names to native iOS/macOS SF Symbols. + +### Sample Client Application +- **Interactive Gallery**: A comprehensive demonstration app showcasing every component in the basic catalog. +- **JSON Exploration**: Tools to visualize the bidirectional relationship between A2UI JSON definitions and rendered SwiftUI views. +- **Live Data Model Editing**: Real-time editors to modify the underlying data model and observe reactive UI updates. +- **Function Demos**: Interactive examples for testing A2UI standard functions and input validation rules. +- **Action Logging**: Integrated log view to monitor `UserAction` events emitted by the renderer. + +### Architecture & Quality +- **Modular Design**: Structured the library into specialized modules for Components, Models, Functions, and Data Management. +- **Comprehensive Testing**: Established a robust test suite using the Swift Testing framework, achieving high coverage across core rendering and logic files. +- **Cross-Platform**: Designed for compatibility across iOS and macOS platforms. +- **Documentation**: Updated READMEs and provided implementation guides for Swift-based agent and client development. diff --git a/renderers/swift/.gitignore b/renderers/swift/.gitignore new file mode 100644 index 000000000..ce9b514e0 --- /dev/null +++ b/renderers/swift/.gitignore @@ -0,0 +1,15 @@ +# Build artifacts +.build/ + +# Swift Package Manager +.swiftpm/ + +# Xcode +.DS_Store +*.playground/ +DerivedData/ + +# User-specific workspace/project files +**/*.xcodeproj/project.xcworkspace/ +**/*.xcodeproj/xcuserdata/ +**/*.xcworkspace/xcuserdata/ diff --git a/renderers/swift/Package.swift b/renderers/swift/Package.swift new file mode 100644 index 000000000..2d486cf0e --- /dev/null +++ b/renderers/swift/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "A2UI", + platforms: [ + .iOS(.v18), + .macOS(.v15) + ], + products: [ + .library( + name: "A2UI", + targets: ["A2UI"]), + ], + targets: [ + .target( + name: "A2UI", + dependencies: []), + .testTarget( + name: "A2UITests", + dependencies: ["A2UI"]), + ] +) diff --git a/renderers/swift/README.md b/renderers/swift/README.md new file mode 100644 index 000000000..4e67f926d --- /dev/null +++ b/renderers/swift/README.md @@ -0,0 +1,77 @@ +# A2UI SwiftUI Renderer + +This directory contains the source code for the A2UI Swift Renderer. + +It is a native Swift package that provides the necessary components to parse and render A2UI protocol messages within a SwiftUI application. + +## Architecture Overview + +The Swift renderer follows a reactive, data-driven UI paradigm tailored for SwiftUI: + +1. **Parsing (`A2UIParser`)**: Raw A2UI JSON messages from the server are decoded into strongly-typed Swift models. +2. **State Management (`A2UIDataStore`)**: The `A2UIDataStore` acts as the single source of truth for a given surface. It holds the parsed component hierarchy and current data model state. It evaluates bindings, function calls, and data updates triggered by user interactions. +3. **Rendering (`A2UISurfaceView` & `A2UIComponentRenderer`)**: The `A2UISurfaceView` observes the `A2UIDataStore`. It traverses the component tree starting from the root, recursively calling `A2UIComponentRenderer` for each node. The component renderer acts as a factory, translating A2UI component definitions (e.g., a `Text` or `Row` node) into their corresponding native SwiftUI equivalents. +4. **Interaction & Data Flow**: User inputs (like typing in a text field or tapping a button) are captured by the specific SwiftUI views. These views update the `A2UIDataStore`, which automatically propagates changes to bound variables, re-evaluates rules, and potentially dispatches `UserAction` messages back to the server. + +## Key Components + +- **A2UIParser**: Deserializes A2UI JSON messages into Swift data models. +- **A2UIDataStore**: Manages the state of the UI surface, data models, and component state. +- **A2UISurfaceView**: A SwiftUI view that orchestrates the rendering of the entire A2UI surface. +- **A2UIComponentRenderer**: A view responsible for dynamically rendering individual A2UI components as native SwiftUI views. + +## Data Store Injection and Precedence + +`A2UISurfaceView` supports two dependency injection styles for `A2UIDataStore`: + +1. Pass a store directly in the initializer (`A2UISurfaceView(surfaceId:dataStore:)`). +2. Provide a store through SwiftUI environment (`.environment(dataStore)`). + +When both are present, initializer injection takes precedence. The effective store is resolved as: + +```swift +dataStore ?? dataStoreEnv +``` + +This is intentional and not a second source of truth. `A2UIDataStore` remains the single source of truth for surface state; the two properties represent two wiring paths so the view can be used ergonomically in different contexts (for example, explicit injection in previews/tests and environment injection in app-level composition). + +### Implemented UI Components +- **Layout**: `Column`, `Row`, `List`, `Card` +- **Content**: `Text`, `Image`, `Icon`, `Video`, `AudioPlayer` +- **Input**: `TextField`, `CheckBox`, `ChoicePicker`, `Slider`, `DateTimeInput` +- **Navigation & Interaction**: `Button`, `Tabs`, `Modal` +- **Decoration**: `Divider` + +### Implemented Functions +- **Formatting**: `FormatString`, `FormatDate`, `FormatCurrency`, `FormatNumber`, `Pluralize`, `OpenUrl` +- **Validation**: `IsRequired`, `IsEmail`, `MatchesRegex`, `CheckLength`, `CheckNumeric` +- **Logical**: `PerformAnd`, `PerformOr`, `PerformNot` + +For an example of how to use this renderer, please see the sample application in `samples/client/swift`. + +## Usage + +To use this package in your Xcode project: + +1. Go to **File > Add Packages...** +2. In the "Add Package" dialog, click **Add Local...** +3. Navigate to this directory (`renderers/swift`) and click **Add Package**. +4. Select the `A2UI` library to be added to your application target. + +## Running Tests + +You can run the included unit tests using either Xcode or the command line. + +### Xcode + +1. Open the `Package.swift` file in this directory with Xcode. +2. Go to the **Test Navigator** (Cmd+6). +3. Click the play button to run all tests. + +### Command Line + +Navigate to this directory in your terminal and run: + +```bash +swift test +``` diff --git a/renderers/swift/Sources/A2UI/A2UILogger.swift b/renderers/swift/Sources/A2UI/A2UILogger.swift new file mode 100644 index 000000000..3cf8bbf62 --- /dev/null +++ b/renderers/swift/Sources/A2UI/A2UILogger.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Internal logger for A2UI. +/// By default, all logging is disabled to avoid spamming the console of host applications. +/// To enable logging during development of the library, add 'A2UI_DEBUG' to your active compilation conditions. +enum A2UILogger { + @inline(__always) + static func debug(_ message: @autoclosure () -> String) { + #if A2UI_DEBUG + print("[A2UI DEBUG] \(message())") + #endif + } + + @inline(__always) + static func info(_ message: @autoclosure () -> String) { + #if A2UI_DEBUG + print("[A2UI INFO] \(message())") + #endif + } + + @inline(__always) + static func warning(_ message: @autoclosure () -> String) { + #if A2UI_DEBUG + print("[A2UI WARNING] \(message())") + #endif + } + + @inline(__always) + static func error(_ message: @autoclosure () -> String) { + #if A2UI_DEBUG + print("[A2UI ERROR] \(message())") + #endif + } +} diff --git a/renderers/swift/Sources/A2UI/Components/AudioPlayer/A2UIAudioPlayerView.swift b/renderers/swift/Sources/A2UI/Components/AudioPlayer/A2UIAudioPlayerView.swift new file mode 100644 index 000000000..02568ad42 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/AudioPlayer/A2UIAudioPlayerView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import AVKit + +struct A2UIAudioPlayerView: View { + let properties: AudioPlayerProperties + @Environment(SurfaceState.self) var surface + @State private var player: AVPlayer? + @State private var isPlaying: Bool = false + @State private var volume: Double = 1.0 + @State private var currentTime: Double = 0 + @State private var duration: Double = 0 + @State private var isEditing: Bool = false + @State private var timeObserverToken: Any? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button(action: { + togglePlay() + }) { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + .font(.title) + } + + VStack(alignment: .leading) { + Text(surface.resolve(properties.description) ?? "Audio Player") + .font(.caption) + + Slider(value: $currentTime, in: 0...max(duration, 0.01)) { editing in + isEditing = editing + if !editing { + player?.seek(to: CMTime(seconds: currentTime, preferredTimescale: 600)) + } + } + + HStack { + Text(formatTime(currentTime)) + Spacer() + Text(formatTime(duration)) + } + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary) + } + } + + HStack { + Image(systemName: "speaker.fill") + .foregroundColor(.secondary) + Slider(value: $volume, in: 0...1) + .onChange(of: volume) { _, newValue in + player?.volume = Float(newValue) + } + Image(systemName: "speaker.wave.3.fill") + .foregroundColor(.secondary) + } + } + .padding() + .background(Color.secondary.opacity(0.1)) + .cornerRadius(8) + .onAppear { + setupPlayer() + } + .onDisappear { + if let token = timeObserverToken { + player?.removeTimeObserver(token) + timeObserverToken = nil + } + player?.pause() + player = nil + } + } + + private func setupPlayer() { + if let urlString = surface.resolve(properties.url), let url = URL(string: urlString) { + let avPlayer = AVPlayer(url: url) + player = avPlayer + volume = Double(avPlayer.volume) + isPlaying = false + currentTime = 0 + duration = 0 + + // Observe time + timeObserverToken = avPlayer.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main) { time in + Task { @MainActor in + if !isEditing { + currentTime = time.seconds + } + } + } + + // Observe duration + Task { + if let duration = try? await avPlayer.currentItem?.asset.load(.duration) { + self.duration = duration.seconds + } + } + } + } + + private func togglePlay() { + if isPlaying { + player?.pause() + } else { + player?.play() + } + isPlaying.toggle() + } + + private func formatTime(_ seconds: Double) -> String { + let minutes = Int(seconds) / 60 + let seconds = Int(seconds) % 60 + return String(format: "%d:%02d", minutes, seconds) + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + A2UIAudioPlayerView(properties: AudioPlayerProperties( + url: .init(literal: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"), + description: .init(literal: "Sample Audio") + )) + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/AudioPlayer/AudioPlayerProperties.swift b/renderers/swift/Sources/A2UI/Components/AudioPlayer/AudioPlayerProperties.swift new file mode 100644 index 000000000..c7b9addfb --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/AudioPlayer/AudioPlayerProperties.swift @@ -0,0 +1,6 @@ +import Foundation + +public struct AudioPlayerProperties: Codable, Sendable { + public let url: BoundValue + public let description: BoundValue? +} diff --git a/renderers/swift/Sources/A2UI/Components/Button/A2UIButtonView.swift b/renderers/swift/Sources/A2UI/Components/Button/A2UIButtonView.swift new file mode 100644 index 000000000..ea9c7689e --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Button/A2UIButtonView.swift @@ -0,0 +1,93 @@ +import SwiftUI + +struct A2UIButtonView: View { + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + let id: String + let properties: ButtonProperties + let checks: [CheckRule]? + + private var activeSurface: SurfaceState? { surface ?? surfaceEnv } + + init(id: String, properties: ButtonProperties, checks: [CheckRule]? = nil, surface: SurfaceState? = nil) { + self.id = id + self.properties = properties + self.checks = checks + self.surface = surface + } + + var body: some View { + let variant = properties.variant ?? .primary + let error: String? = if let checks = checks { + errorMessage(surface: activeSurface, checks: checks) + } else { + nil + } + let isDisabled = error != nil + + VStack(alignment: .leading, spacing: 0) { + Button(action: { + performAction() + }) { + A2UIComponentRenderer(componentId: properties.child) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + .disabled(isDisabled) + .applyButtonStyle(variant: variant) + #if os(iOS) + .tint(variant == .primary ? .blue : .gray) + #endif + + if let error = error { + Text(error) + .font(.caption) + .foregroundColor(.red) + .padding(.top, 2) + } + } + .onAppear { + activeSurface?.runChecks(for: id) + } + } + + private func performAction() { + activeSurface?.trigger(action: properties.action, sourceComponentId: id) + } +} + +extension View { + @ViewBuilder + func applyButtonStyle(variant: ButtonVariant) -> some View { + if variant == .borderless { + self.buttonStyle(.borderless) + } else { + self.buttonStyle(.bordered) + } + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + // Add a text component for the button child + surface.components["t1"] = ComponentInstance(id: "t1", component: .text(TextProperties(text: .init(literal: "Click Me"), variant: nil))) + + return VStack(spacing: 20) { + A2UIButtonView(id: "b1", properties: ButtonProperties( + child: "t1", + action: .event(name: "primary_action", context: nil), + variant: .primary + )) + + A2UIButtonView(id: "b2", properties: ButtonProperties( + child: "t1", + action: .event(name: "borderless_action", context: nil), + variant: .borderless + )) + } + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/Button/ButtonProperties.swift b/renderers/swift/Sources/A2UI/Components/Button/ButtonProperties.swift new file mode 100644 index 000000000..b2cc48ec2 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Button/ButtonProperties.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct ButtonProperties: Codable, Sendable { + public let child: String + public let action: Action + public let variant: ButtonVariant? + + public init(child: String, action: Action, variant: ButtonVariant? = nil) { + self.child = child + self.action = action + self.variant = variant + } +} + +public enum ButtonVariant: String, Codable, Sendable, CaseIterable, Identifiable { + public var id: String { self.rawValue } + case primary + case borderless +} diff --git a/renderers/swift/Sources/A2UI/Components/Card/A2UICardView.swift b/renderers/swift/Sources/A2UI/Components/Card/A2UICardView.swift new file mode 100644 index 000000000..12af347a0 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Card/A2UICardView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct A2UICardView: View { + let properties: CardProperties + + var body: some View { + A2UIComponentRenderer(componentId: properties.child) + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(white: 0.95)) + ) + .shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2) + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + surface.components["t1"] = ComponentInstance(id: "t1", component: .text(TextProperties(text: .init(literal: "Card Content"), variant: .h3))) + + return A2UICardView(properties: CardProperties(child: "t1")) + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/Card/CardProperties.swift b/renderers/swift/Sources/A2UI/Components/Card/CardProperties.swift new file mode 100644 index 000000000..8a2ca723c --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Card/CardProperties.swift @@ -0,0 +1,5 @@ +import Foundation + +public struct CardProperties: Codable, Sendable { + public let child: String +} diff --git a/renderers/swift/Sources/A2UI/Components/CheckBox/A2UICheckBoxView.swift b/renderers/swift/Sources/A2UI/Components/CheckBox/A2UICheckBoxView.swift new file mode 100644 index 000000000..2648e98b4 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/CheckBox/A2UICheckBoxView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct A2UICheckBoxView: View { + let id: String + let properties: CheckBoxProperties + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + + private var activeSurface: SurfaceState? { surface ?? surfaceEnv } + + init(id: String, properties: CheckBoxProperties, surface: SurfaceState? = nil) { + self.id = id + self.properties = properties + self.surface = surface + } + + var body: some View { + let isOnBinding = Binding( + get: { + resolveValue(activeSurface, binding: properties.value) ?? false + }, + set: { newValue in + updateBinding(surface: activeSurface, binding: properties.value, newValue: newValue) + activeSurface?.runChecks(for: id) + } + ) + + VStack(alignment: .leading, spacing: 0) { + Toggle(isOn: isOnBinding) { + Text(resolveValue(activeSurface, binding: properties.label) ?? "") + } + ValidationErrorMessageView(id: id, surface: activeSurface) + } + .onAppear { + activeSurface?.runChecks(for: id) + } + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + A2UICheckBoxView(id: "cb1", properties: CheckBoxProperties( + label: .init(literal: "Check this box"), + value: .init(literal: true) + )) + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/CheckBox/CheckBoxProperties.swift b/renderers/swift/Sources/A2UI/Components/CheckBox/CheckBoxProperties.swift new file mode 100644 index 000000000..fc55b68ae --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/CheckBox/CheckBoxProperties.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct CheckBoxProperties: Codable, Sendable { + public let label: BoundValue + public let value: BoundValue + + public init(label: BoundValue, value: BoundValue) { + self.label = label + self.value = value + } +} diff --git a/renderers/swift/Sources/A2UI/Components/ChoicePicker/A2UIChoicePickerView.swift b/renderers/swift/Sources/A2UI/Components/ChoicePicker/A2UIChoicePickerView.swift new file mode 100644 index 000000000..7f4dcc614 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/ChoicePicker/A2UIChoicePickerView.swift @@ -0,0 +1,134 @@ +import SwiftUI + +struct A2UIChoicePickerView: View { + let id: String + let properties: ChoicePickerProperties + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + + private var activeSurface: SurfaceState? { surface ?? surfaceEnv } + + init(id: String, properties: ChoicePickerProperties, surface: SurfaceState? = nil) { + self.id = id + self.properties = properties + self.surface = surface + } + + var body: some View { + let variant = properties.variant ?? .mutuallyExclusive + + let selectionsBinding = Binding>( + get: { + if let initial: [String] = activeSurface?.resolve(properties.value) { + return Set(initial) + } + return [] + }, + set: { newValue in + updateBinding(surface: activeSurface, binding: properties.value, newValue: Array(newValue)) + activeSurface?.runChecks(for: id) + } + ) + + VStack(alignment: .leading) { + if let label = properties.label, let labelText = activeSurface?.resolve(label) { + Text(labelText) + .font(.caption) + } + + if variant == .mutuallyExclusive { + Picker("", selection: Binding( + get: { selectionsBinding.wrappedValue.first ?? "" }, + set: { newValue in + selectionsBinding.wrappedValue = newValue.isEmpty ? [] : [newValue] + } + )) { + ForEach(properties.options, id: \.value) { option in + Text(activeSurface?.resolve(option.label) ?? option.value).tag(option.value) + } + } + .pickerStyle(.menu) + } else { + Menu { + ForEach(properties.options, id: \.value) { option in + Toggle(activeSurface?.resolve(option.label) ?? option.value, isOn: Binding( + get: { selectionsBinding.wrappedValue.contains(option.value) }, + set: { isOn in + if isOn { + selectionsBinding.wrappedValue.insert(option.value) + } else { + selectionsBinding.wrappedValue.remove(option.value) + } + } + )) + } + } label: { + HStack { + let selectedLabels = properties.options + .filter { selectionsBinding.wrappedValue.contains($0.value) } + .compactMap { activeSurface?.resolve($0.label) } + + let labelText = if selectedLabels.isEmpty { + "Select..." + } else if selectedLabels.count > 2 { + "\(selectedLabels.count) items" + } else { + selectedLabels.joined(separator: ", ") + } + + Text(labelText) + .lineLimit(1) + .foregroundStyle(.primary) + + Spacer() + + Image(systemName: "chevron.up.chevron.down") + .imageScale(.small) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +#if os(iOS) + .menuActionDismissBehavior(.disabled) +#endif + } + ValidationErrorMessageView(id: id, surface: activeSurface) + } + .onAppear { + activeSurface?.runChecks(for: id) + } + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + let options = [ + SelectionOption(label: .init(literal: "Option 1"), value: "opt1"), + SelectionOption(label: .init(literal: "Option 2"), value: "opt2"), + SelectionOption(label: .init(literal: "Option 3"), value: "opt3") + ] + + VStack(spacing: 20) { + A2UIChoicePickerView(id: "cp1", properties: ChoicePickerProperties( + label: .init(literal: "Mutually Exclusive"), + options: options, + variant: .mutuallyExclusive, + value: .init(literal: ["opt1"]) + )) + + A2UIChoicePickerView(id: "cp2", properties: ChoicePickerProperties( + label: .init(literal: "Multiple Selection"), + options: options, + variant: .multipleSelection, + value: .init(literal: ["opt1", "opt2"]) + )) + } + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/ChoicePicker/ChoicePickerProperties.swift b/renderers/swift/Sources/A2UI/Components/ChoicePicker/ChoicePickerProperties.swift new file mode 100644 index 000000000..caa1194e2 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/ChoicePicker/ChoicePickerProperties.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct ChoicePickerProperties: Codable, Sendable { + public let label: BoundValue? + public let options: [SelectionOption] + public let variant: ChoicePickerVariant? + public let value: BoundValue<[String]> + + public init(label: BoundValue? = nil, options: [SelectionOption], variant: ChoicePickerVariant? = nil, value: BoundValue<[String]>) { + self.label = label + self.options = options + self.variant = variant + self.value = value + } +} + +public struct SelectionOption: Codable, Sendable { + public let label: BoundValue + public let value: String +} + +public enum ChoicePickerVariant: String, Codable, Sendable, CaseIterable, Identifiable { + public var id: String { self.rawValue } + case multipleSelection = "multipleSelection" + case mutuallyExclusive = "mutuallyExclusive" +} diff --git a/renderers/swift/Sources/A2UI/Components/Column/A2UIColumnView.swift b/renderers/swift/Sources/A2UI/Components/Column/A2UIColumnView.swift new file mode 100644 index 000000000..99e88380d --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Column/A2UIColumnView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct A2UIColumnView: View { + let properties: ContainerProperties + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + + private var activeSurface: SurfaceState? { surface ?? surfaceEnv } + + var body: some View { + let childIds: [String] = { + switch properties.children { + case .list(let list): return list + case .template(let template): return activeSurface?.expandTemplate(template: template) ?? [] + } + }() + + VStack(alignment: horizontalAlignment, spacing: 0) { + A2UIJustifiedContainer(childIds: childIds, justify: properties.resolvedJustify) + .environment(activeSurface) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: Alignment(horizontal: horizontalAlignment, vertical: .center)) + } + + private var horizontalAlignment: HorizontalAlignment { + switch properties.resolvedAlign { + case .start: return .leading + case .center: return .center + case .end: return .trailing + default: return .leading + } + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + surface.components["t1"] = ComponentInstance(id: "t1", component: .text(TextProperties(text: .init(literal: "Top"), variant: nil))) + surface.components["t2"] = ComponentInstance(id: "t2", component: .text(TextProperties(text: .init(literal: "Bottom"), variant: nil))) + + return A2UIColumnView(properties: ContainerProperties( + children: .list(["t1", "t2"]), + justify: .start, + align: .center + )) + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/DateTimeInput/A2UIDateTimeInputView.swift b/renderers/swift/Sources/A2UI/Components/DateTimeInput/A2UIDateTimeInputView.swift new file mode 100644 index 000000000..4d8db522f --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/DateTimeInput/A2UIDateTimeInputView.swift @@ -0,0 +1,107 @@ +import SwiftUI + +struct A2UIDateTimeInputView: View { + let id: String + let properties: DateTimeInputProperties + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + + private var activeSurface: SurfaceState? { surface ?? surfaceEnv } + + init(id: String, properties: DateTimeInputProperties, surface: SurfaceState? = nil) { + self.id = id + self.properties = properties + self.surface = surface + } + + var body: some View { + let dateBinding = Binding( + get: { + resolvedValue() ?? Date() + }, + set: { newValue in + updateDate(newValue) + activeSurface?.runChecks(for: id) + } + ) + + VStack(alignment: .leading, spacing: 0) { + DatePicker( + resolveValue(activeSurface, binding: properties.label) ?? "", + selection: dateBinding, + in: dateRange, + displayedComponents: dateComponents + ) + ValidationErrorMessageView(id: id, surface: activeSurface) + } + .onAppear { + activeSurface?.runChecks(for: id) + } + } + + private var dateComponents: DatePickerComponents { + var components: DatePickerComponents = [] + if properties.enableDate ?? true { + components.insert(.date) + } + if properties.enableTime ?? true { + components.insert(.hourAndMinute) + } + return components.isEmpty ? [.date, .hourAndMinute] : components + } + + private var dateRange: ClosedRange { + let minDate = resolvedDate(from: resolveValue(activeSurface, binding: properties.min)) ?? Date.distantPast + let maxDate = resolvedDate(from: resolveValue(activeSurface, binding: properties.max)) ?? Date.distantFuture + return minDate...maxDate + } + + private func resolvedValue() -> Date? { + let formatter = ISO8601DateFormatter() + if let value = activeSurface?.resolve(properties.value) { + return formatter.date(from: value) + } + return nil + } + + private func resolvedDate(from string: String?) -> Date? { + guard let str = string else { return nil } + let formatter = ISO8601DateFormatter() + return formatter.date(from: str) + } + + private func updateDate(_ newValue: Date) { + guard let path = properties.value.path else { return } + let formatter = ISO8601DateFormatter() + let dateString = formatter.string(from: newValue) + activeSurface?.triggerDataUpdate(path: path, value: dateString) + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + VStack(spacing: 20) { + A2UIDateTimeInputView(id: "dt1", properties: DateTimeInputProperties( + label: .init(literal: "Date and Time"), + value: .init(literal: "2024-01-01T12:00:00Z"), + enableDate: true, + enableTime: true, + min: nil, + max: nil + )) + + A2UIDateTimeInputView(id: "dt2", properties: DateTimeInputProperties( + label: .init(literal: "Date Only"), + value: .init(literal: "2024-01-01T12:00:00Z"), + enableDate: true, + enableTime: false, + min: nil, + max: nil + )) + } + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/DateTimeInput/DateTimeInputProperties.swift b/renderers/swift/Sources/A2UI/Components/DateTimeInput/DateTimeInputProperties.swift new file mode 100644 index 000000000..e6990dbc0 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/DateTimeInput/DateTimeInputProperties.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct DateTimeInputProperties: Codable, Sendable { + public let label: BoundValue? + public let value: BoundValue + public let enableDate: Bool? + public let enableTime: Bool? + public let min: BoundValue? + public let max: BoundValue? + + public init(label: BoundValue? = nil, value: BoundValue, enableDate: Bool? = nil, enableTime: Bool? = nil, min: BoundValue? = nil, max: BoundValue? = nil) { + self.label = label + self.value = value + self.enableDate = enableDate + self.enableTime = enableTime + self.min = min + self.max = max + } +} diff --git a/renderers/swift/Sources/A2UI/Components/Divider/A2UIDividerView.swift b/renderers/swift/Sources/A2UI/Components/Divider/A2UIDividerView.swift new file mode 100644 index 000000000..493a5273a --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Divider/A2UIDividerView.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct A2UIDividerView: View { + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + let properties: DividerProperties + var body: some View { + Divider() + .padding(.vertical, 4) + } +} + +#Preview { + VStack { + VStack { + Text("Above") + A2UIDividerView(properties: .init(axis: .horizontal)) + Text("Below") + } + .padding() + + HStack { + Text("Left") + A2UIDividerView(properties: .init(axis: .horizontal)) + Text("Right") + } + .padding() + } +} diff --git a/renderers/swift/Sources/A2UI/Components/Divider/DividerProperties.swift b/renderers/swift/Sources/A2UI/Components/Divider/DividerProperties.swift new file mode 100644 index 000000000..aedb3a259 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Divider/DividerProperties.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct DividerProperties: Codable, Sendable { + public let axis: DividerAxis? +} + +public enum DividerAxis: String, Codable, Sendable, CaseIterable, Identifiable { + public var id: String { self.rawValue } + case horizontal + case vertical +} diff --git a/renderers/swift/Sources/A2UI/Components/Icon/A2UIIconName.swift b/renderers/swift/Sources/A2UI/Components/Icon/A2UIIconName.swift new file mode 100644 index 000000000..b519bfba5 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Icon/A2UIIconName.swift @@ -0,0 +1,130 @@ +import Foundation + +/// Supported Google Font / Material icon names. +enum A2UIIconName: String, CaseIterable, Identifiable { + public var id: String { self.rawValue } + case accountCircle + case add + case arrowBack + case arrowForward + case attachFile + case calendarToday + case call + case camera + case check + case close + case delete + case download + case edit + case event + case error + case fastForward + case favorite + case favoriteOff + case folder + case help + case home + case info + case locationOn + case lock + case lockOpen + case mail + case menu + case moreVert + case moreHoriz + case notificationsOff + case notifications + case pause + case payment + case person + case phone + case photo + case play + case print + case refresh + case rewind + case search + case send + case settings + case share + case shoppingCart + case skipNext + case skipPrevious + case star + case starHalf + case starOff + case stop + case upload + case visibility + case visibilityOff + case volumeDown + case volumeMute + case volumeOff + case volumeUp + case warning + + /// The SF Symbol equivalent for this Material icon name. + var sfSymbolName: String { + switch self { + case .accountCircle: return "person.circle" + case .add: return "plus" + case .arrowBack: return "arrow.left" + case .arrowForward: return "arrow.right" + case .attachFile: return "paperclip" + case .calendarToday: return "calendar" + case .call: return "phone" + case .camera: return "camera" + case .check: return "checkmark" + case .close: return "xmark" + case .delete: return "trash" + case .download: return "square.and.arrow.down" + case .edit: return "pencil" + case .event: return "calendar" + case .error: return "exclamationmark.circle" + case .fastForward: return "forward.fill" + case .favorite: return "heart.fill" + case .favoriteOff: return "heart" + case .folder: return "folder" + case .help: return "questionmark.circle" + case .home: return "house" + case .info: return "info.circle" + case .locationOn: return "mappin.and.ellipse" + case .lock: return "lock" + case .lockOpen: return "lock.open" + case .mail: return "envelope" + case .menu: return "line.3.horizontal" + case .moreVert: return "ellipsis.vertical" + case .moreHoriz: return "ellipsis" + case .notificationsOff: return "bell.slash" + case .notifications: return "bell" + case .pause: return "pause" + case .payment: return "creditcard" + case .person: return "person" + case .phone: return "phone" + case .photo: return "photo" + case .play: return "play" + case .print: return "printer" + case .refresh: return "arrow.clockwise" + case .rewind: return "backward.fill" + case .search: return "magnifyingglass" + case .send: return "paperplane" + case .settings: return "gear" + case .share: return "square.and.arrow.up" + case .shoppingCart: return "cart" + case .skipNext: return "forward.end" + case .skipPrevious: return "backward.end" + case .star: return "star" + case .starHalf: return "star.leadinghalf.filled" + case .starOff: return "star.slash" + case .stop: return "stop" + case .upload: return "square.and.arrow.up" + case .visibility: return "eye" + case .visibilityOff: return "eye.slash" + case .volumeDown: return "speaker.wave.1" + case .volumeMute: return "speaker.slash" + case .volumeOff: return "speaker.slash" + case .volumeUp: return "speaker.wave.3" + case .warning: return "exclamationmark.triangle" + } + } +} diff --git a/renderers/swift/Sources/A2UI/Components/Icon/A2UIIconView.swift b/renderers/swift/Sources/A2UI/Components/Icon/A2UIIconView.swift new file mode 100644 index 000000000..109e87a90 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Icon/A2UIIconView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct A2UIIconView: View { + let properties: IconProperties + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + + private var activeSurface: SurfaceState? { surface ?? surfaceEnv } + + init(properties: IconProperties, surface: SurfaceState? = nil) { + self.properties = properties + self.surface = surface + } + + var body: some View { + if let name = activeSurface?.resolve(properties.name) { + Image(systemName: A2UIIconName(rawValue: name)?.sfSymbolName ?? "questionmark.square.dashed") + .font(.system(size: 24)) + .foregroundColor(.primary) + } + } + + private func mapToSFSymbol(_ name: String) -> String { + return name + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + HStack(spacing: 20) { + A2UIIconView(properties: IconProperties(name: .init(literal: "star"))) + A2UIIconView(properties: IconProperties(name: .init(literal: "heart"))) + A2UIIconView(properties: IconProperties(name: .init(literal: "person"))) + } + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/Icon/IconProperties.swift b/renderers/swift/Sources/A2UI/Components/Icon/IconProperties.swift new file mode 100644 index 000000000..9878b0658 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Icon/IconProperties.swift @@ -0,0 +1,5 @@ +import Foundation + +public struct IconProperties: Codable, Sendable { + public let name: BoundValue // v0.9: String or path object, we'll keep it simple for now +} diff --git a/renderers/swift/Sources/A2UI/Components/Image/A2UIImageView.swift b/renderers/swift/Sources/A2UI/Components/Image/A2UIImageView.swift new file mode 100644 index 000000000..c432d5c2c --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Image/A2UIImageView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct A2UIImageView: View { + let properties: ImageProperties + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + + private var activeSurface: SurfaceState? { surface ?? surfaceEnv } + + var body: some View { + let variant = properties.variant ?? .icon + if let urlString = activeSurface?.resolve(properties.url), let url = URL(string: urlString) { + AsyncImage(url: url) { phase in + + switch phase { + case .empty: + ProgressView() + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: contentMode) + case .failure: + Image(systemName: "photo") + .foregroundColor(.gray) + @unknown default: + EmptyView() + } + } + .accessibilityLabel(properties.variant?.rawValue ?? "Image") + .mask({ + if variant == .avatar { + Circle() + } else { + Rectangle() + } + }) + } + } + + private var contentMode: ContentMode { + switch properties.fit { + case .cover, .fill: return .fill + default: return .fit + } + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + A2UIImageView(properties: ImageProperties( + url: .init(literal: "https://picsum.photos/200/300"), + fit: .cover, + variant: .avatar + )) + .frame(width: 100, height: 100) + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/Image/ImageProperties.swift b/renderers/swift/Sources/A2UI/Components/Image/ImageProperties.swift new file mode 100644 index 000000000..a453d8280 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Image/ImageProperties.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct ImageProperties: Codable, Sendable { + public let url: BoundValue + public let fit: A2UIImageFit? // contain, cover, fill, none, scaleDown + public let variant: A2UIImageVariant? // icon, avatar, smallFeature, mediumFeature, largeFeature, header +} + +public enum A2UIImageVariant: String, Codable, Sendable, CaseIterable, Identifiable { + public var id: String { self.rawValue } + case icon = "icon" + case avatar = "avatar" + case smallFeature = "smallFeature" + case mediumFeature = "mediumFeature" + case largeFeature = "largeFeature" + case header = "header" +} + +public enum A2UIImageFit: String, Codable, Sendable, CaseIterable, Identifiable { + public var id: String { self.rawValue } + case contain = "contain" + case cover = "cover" + case fill = "fill" + case none = "none" + case scaleDown = "scaleDown" +} diff --git a/renderers/swift/Sources/A2UI/Components/List/A2UIListView.swift b/renderers/swift/Sources/A2UI/Components/List/A2UIListView.swift new file mode 100644 index 000000000..23b598958 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/List/A2UIListView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct A2UIListView: View { + let properties: ListProperties + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + + private var activeSurface: SurfaceState? { surface ?? surfaceEnv } + + private var axis: Axis.Set { + properties.direction == "horizontal" ? .horizontal : .vertical + } + + var body: some View { + ScrollView(axis, showsIndicators: true) { + if axis == .horizontal { + HStack(spacing: 0) { + renderChildren() + } + } else { + VStack(spacing: 0) { + renderChildren() + } + } + } + } + + @ViewBuilder + private func renderChildren() -> some View { + switch properties.children { + case .list(let list): + ForEach(list, id: \.self) { id in + A2UIComponentRenderer(componentId: id) + .environment(activeSurface) + } + case .template(let template): + renderTemplate(template) + } + } + + @ViewBuilder + private func renderTemplate(_ template: Template) -> some View { + let ids = activeSurface?.expandTemplate(template: template) ?? [] + ForEach(ids, id: \.self) { id in + A2UIComponentRenderer(componentId: id) + .environment(activeSurface) + } + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + surface.components["t1"] = ComponentInstance(id: "t1", component: .text(TextProperties(text: .init(literal: "Item 1"), variant: nil))) + surface.components["t2"] = ComponentInstance(id: "t2", component: .text(TextProperties(text: .init(literal: "Item 2"), variant: nil))) + surface.components["t3"] = ComponentInstance(id: "t3", component: .text(TextProperties(text: .init(literal: "Item 3"), variant: nil))) + + return A2UIListView(properties: ListProperties( + children: .list(["t1", "t2", "t3"]), + direction: "vertical", + align: "start" + )) + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/List/ListProperties.swift b/renderers/swift/Sources/A2UI/Components/List/ListProperties.swift new file mode 100644 index 000000000..a2837bb0f --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/List/ListProperties.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct ListProperties: Codable, Sendable { + public let children: Children + public let direction: String? // vertical, horizontal + public let align: String? +} diff --git a/renderers/swift/Sources/A2UI/Components/Modal/A2UIModalView.swift b/renderers/swift/Sources/A2UI/Components/Modal/A2UIModalView.swift new file mode 100644 index 000000000..98df424e3 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Modal/A2UIModalView.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct A2UIModalView: View { + let properties: ModalProperties + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + + @State private var isPresented = false + + var body: some View { + VStack { + A2UIComponentRenderer(componentId: properties.trigger) + .simultaneousGesture(TapGesture().onEnded({ _ in + isPresented = true + })) + } + .sheet(isPresented: $isPresented) { + VStack { + HStack { + Spacer() + Button("Done") { + isPresented = false + } + .padding() + } + A2UIComponentRenderer(componentId: properties.content) + Spacer() + } + } + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + surface.components["trigger"] = ComponentInstance(id: "trigger", component: .button(ButtonProperties(child: "btn_text", action: .event(name: "open", context: nil), variant: .primary))) + surface.components["btn_text"] = ComponentInstance(id: "btn_text", component: .text(TextProperties(text: .init(literal: "Open Modal"), variant: nil))) + surface.components["content"] = ComponentInstance(id: "content", component: .text(TextProperties(text: .init(literal: "This is the modal content"), variant: .h2))) + + return A2UIModalView(properties: ModalProperties( + trigger: "trigger", + content: "content" + )) + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/Modal/ModalProperties.swift b/renderers/swift/Sources/A2UI/Components/Modal/ModalProperties.swift new file mode 100644 index 000000000..f980fa5fa --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Modal/ModalProperties.swift @@ -0,0 +1,6 @@ +import Foundation + +public struct ModalProperties: Codable, Sendable { + public let trigger: String + public let content: String +} diff --git a/renderers/swift/Sources/A2UI/Components/Row/A2UIRowView.swift b/renderers/swift/Sources/A2UI/Components/Row/A2UIRowView.swift new file mode 100644 index 000000000..f074502c6 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Row/A2UIRowView.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct A2UIRowView: View { + let properties: ContainerProperties + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + + private var activeSurface: SurfaceState? { surface ?? surfaceEnv } + + private var justify: A2UIJustify { + properties.justify ?? .spaceBetween + } + + var body: some View { + let childIds: [String] = { + switch properties.children { + case .list(let list): return list + case .template(let template): return activeSurface?.expandTemplate(template: template) ?? [] + } + }() + + HStack(alignment: verticalAlignment, spacing: 0) { + A2UIJustifiedContainer(childIds: childIds, justify: justify) + .environment(activeSurface) + } + .frame(maxWidth: .infinity) + } + + private var verticalAlignment: VerticalAlignment { + switch properties.resolvedAlign { + case .start: return .top + case .center: return .center + case .end: return .bottom + default: return .center + } + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + surface.components["t1"] = ComponentInstance(id: "t1", component: .text(TextProperties(text: .init(literal: "Left"), variant: nil))) + surface.components["t2"] = ComponentInstance(id: "t2", component: .text(TextProperties(text: .init(literal: "Right"), variant: nil))) + + return A2UIRowView(properties: ContainerProperties( + children: .list(["t1", "t2"]), + justify: .spaceBetween, + align: .center + )) + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/Shared/A2UIInputHelpers.swift b/renderers/swift/Sources/A2UI/Components/Shared/A2UIInputHelpers.swift new file mode 100644 index 000000000..933c3e9c2 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Shared/A2UIInputHelpers.swift @@ -0,0 +1,45 @@ +import SwiftUI + +@MainActor func updateBinding(surface: SurfaceState?, binding: BoundValue?, newValue: T) { + guard let surface = surface, let path = binding?.path else { return } + surface.triggerDataUpdate(path: path, value: newValue) +} + +@MainActor func resolveValue(_ surface: SurfaceState?, binding: BoundValue?) -> T? { + guard let surface = surface, let binding = binding else { return nil } + return surface.resolve(binding) +} + +@MainActor func errorMessage(surface: SurfaceState?, checks: [CheckRule]?) -> String? { + guard let surface = surface, let checks = checks, !checks.isEmpty else { return nil } + + A2UILogger.debug("Evaluating \(checks.count) validation checks") + + for check in checks { + let isValid = surface.resolve(check.condition) ?? true + let conditionDesc = String(describing: check.condition) + + if !isValid { + A2UILogger.debug("Check FAILED: \(check.message) (Condition: \(conditionDesc))") + return check.message + } else { + A2UILogger.debug("Check PASSED (Condition: \(conditionDesc))") + } + } + return nil +} + +struct ValidationErrorMessageView: View { + let id: String + let surface: SurfaceState? + + var body: some View { + if let surface = surface, let error = surface.validationErrors[id] { + Text(error) + .font(.caption) + .foregroundColor(.red) + .padding(.top, 2) + .transition(.opacity) + } + } +} diff --git a/renderers/swift/Sources/A2UI/Components/Shared/A2UIJustifiedContainer.swift b/renderers/swift/Sources/A2UI/Components/Shared/A2UIJustifiedContainer.swift new file mode 100644 index 000000000..8d6348f0a --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Shared/A2UIJustifiedContainer.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct A2UIJustifiedContainer: View { + let childIds: [String] + let justify: A2UIJustify + + var body: some View { + if justify == .end || justify == .center || justify == .spaceEvenly || justify == .spaceAround { + Spacer(minLength: 0) + } + + ForEach(Array(childIds.enumerated()), id: \.offset) { index, id in + A2UIComponentRenderer(componentId: id) + if index < childIds.count - 1 { + if justify == .spaceBetween || justify == .spaceEvenly || justify == .spaceAround { + Spacer(minLength: 0) + } + } + } + + if justify == .start || justify == .center || justify == .spaceEvenly || justify == .spaceAround { + Spacer(minLength: 0) + } + } +} diff --git a/renderers/swift/Sources/A2UI/Components/Shared/ContainerProperties.swift b/renderers/swift/Sources/A2UI/Components/Shared/ContainerProperties.swift new file mode 100644 index 000000000..0a5f61acc --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Shared/ContainerProperties.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct ContainerProperties: Codable, Sendable { + public let children: Children + public let justify: A2UIJustify? + public let align: A2UIAlign? + + enum CodingKeys: String, CodingKey { + case children, justify, align + } +} + +extension ContainerProperties { + public var resolvedJustify: A2UIJustify { + justify ?? .spaceBetween + } + + public var resolvedAlign: A2UIAlign { + align ?? .center + } +} + +public enum A2UIJustify: String, Codable, Sendable, CaseIterable, Identifiable { + public var id: String { self.rawValue } + case center = "center" + case end = "end" + case spaceAround = "spaceAround" + case spaceBetween = "spaceBetween" + case spaceEvenly = "spaceEvenly" + case start = "start" + case stretch = "stretch" +} + +public enum A2UIAlign: String, Codable, Sendable, CaseIterable, Identifiable { + public var id: String { self.rawValue } + case start = "start" + case center = "center" + case end = "end" + case stretch = "stretch" +} diff --git a/renderers/swift/Sources/A2UI/Components/Slider/A2UISliderView.swift b/renderers/swift/Sources/A2UI/Components/Slider/A2UISliderView.swift new file mode 100644 index 000000000..fccc48d77 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Slider/A2UISliderView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct A2UISliderView: View { + let id: String + let properties: SliderProperties + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + + private var activeSurface: SurfaceState? { surface ?? surfaceEnv } + + init(id: String, properties: SliderProperties, surface: SurfaceState? = nil) { + self.id = id + self.properties = properties + self.surface = surface + } + + var body: some View { + let valueBinding = Binding( + get: { + resolveValue(activeSurface, binding: properties.value) ?? properties.min + }, + set: { newValue in + updateBinding(surface: activeSurface, binding: properties.value, newValue: newValue) + activeSurface?.runChecks(for: id) + } + ) + + VStack(alignment: .leading) { + if let label = properties.label, let labelText = activeSurface?.resolve(label) { + Text(labelText) + .font(.caption) + } + + Slider(value: valueBinding, in: properties.min...properties.max, step: 1) { + Text("Slider") + } minimumValueLabel: { + Text("\(Int(properties.min))") + } maximumValueLabel: { + Text("\(Int(properties.max))") + } + ValidationErrorMessageView(id: id, surface: activeSurface) + } + .onAppear { + activeSurface?.runChecks(for: id) + } + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + A2UISliderView(id: "sl1", properties: SliderProperties( + label: .init(literal: "Adjust Value"), + min: 0, + max: 100, + value: .init(literal: 50.0) + )) + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/Slider/SliderProperties.swift b/renderers/swift/Sources/A2UI/Components/Slider/SliderProperties.swift new file mode 100644 index 000000000..d9cd57243 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Slider/SliderProperties.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct SliderProperties: Codable, Sendable { + public let label: BoundValue? + public let min: Double + public let max: Double + public let value: BoundValue + + public init(label: BoundValue? = nil, min: Double, max: Double, value: BoundValue) { + self.label = label + self.min = min + self.max = max + self.value = value + } +} diff --git a/renderers/swift/Sources/A2UI/Components/Tabs/A2UITabsView.swift b/renderers/swift/Sources/A2UI/Components/Tabs/A2UITabsView.swift new file mode 100644 index 000000000..3bc15b6ff --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Tabs/A2UITabsView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct A2UITabsView: View { + let properties: TabsProperties + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + + private var activeSurface: SurfaceState? { surface ?? surfaceEnv } + + @State private var selectedTab: Int = 0 + + var body: some View { + let tabs = properties.tabs + VStack { + Picker("", selection: $selectedTab) { + ForEach(0.. + public let child: String +} diff --git a/renderers/swift/Sources/A2UI/Components/Text/A2UITextView.swift b/renderers/swift/Sources/A2UI/Components/Text/A2UITextView.swift new file mode 100644 index 000000000..fafd1d839 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Text/A2UITextView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct A2UITextView: View { + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + let properties: TextProperties + + private var activeSurface: SurfaceState? { surface ?? surfaceEnv } + + private var variant: A2UITextVariant { properties.variant ?? .body } + + var body: some View { + let content = activeSurface?.resolve(properties.text) ?? "" + + Text(content) + .font(fontFor(variant: variant)) + .fixedSize(horizontal: false, vertical: true) + } + + private func fontFor(variant: A2UITextVariant) -> Font { + switch variant { + case .h1: return .system(size: 34, weight: .bold) + case .h2: return .system(size: 28, weight: .bold) + case .h3: return .system(size: 22, weight: .bold) + case .h4: return .system(size: 20, weight: .semibold) + case .h5: return .system(size: 18, weight: .semibold) + case .caption: return .caption + default: return .body + } + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + ScrollView { + VStack(alignment: .leading, spacing: 10) { + A2UITextView(properties: TextProperties(text: .init(literal: "Heading 1"), variant: .h1)) + A2UITextView(properties: TextProperties(text: .init(literal: "Heading 2"), variant: .h2)) + A2UITextView(properties: TextProperties(text: .init(literal: "Heading 3"), variant: .h3)) + A2UITextView(properties: TextProperties(text: .init(literal: "Body Text"), variant: .body)) + A2UITextView(properties: TextProperties(text: .init(literal: "Caption Text"), variant: .caption)) + } + .padding() + } + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/Text/TextProperties.swift b/renderers/swift/Sources/A2UI/Components/Text/TextProperties.swift new file mode 100644 index 000000000..0c6617bac --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Text/TextProperties.swift @@ -0,0 +1,38 @@ +import Foundation + +public struct TextProperties: Codable, Sendable { + public let text: BoundValue + public let variant: A2UITextVariant? // h1, h2, h3, h4, h5, caption, body + + public init(text: BoundValue, variant: A2UITextVariant?) { + self.text = text + self.variant = variant + } + + enum CodingKeys: String, CodingKey { + case text, variant + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.text = try container.decode(BoundValue.self, forKey: .text) + self.variant = try container.decodeIfPresent(A2UITextVariant.self, forKey: .variant) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(text, forKey: .text) + try container.encodeIfPresent(variant, forKey: .variant) + } +} + +public enum A2UITextVariant: String, Codable, Sendable, CaseIterable, Identifiable { + public var id: String { self.rawValue } + case h1 = "h1" + case h2 = "h2" + case h3 = "h3" + case h4 = "h4" + case h5 = "h5" + case caption = "caption" + case body = "body" +} diff --git a/renderers/swift/Sources/A2UI/Components/TextField/A2UITextFieldView.swift b/renderers/swift/Sources/A2UI/Components/TextField/A2UITextFieldView.swift new file mode 100644 index 000000000..5ec3e7263 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/TextField/A2UITextFieldView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct A2UITextFieldView: View { + let id: String + let properties: TextFieldProperties + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + + private var activeSurface: SurfaceState? { surface ?? surfaceEnv } + + init(id: String, properties: TextFieldProperties, surface: SurfaceState? = nil) { + self.id = id + self.properties = properties + self.surface = surface + } + + var body: some View { + let label = resolveValue(activeSurface, binding: properties.label) ?? "" + let variant = properties.variant ?? .shortText + + let textBinding = Binding( + get: { resolveValue(activeSurface, binding: properties.value) ?? "" }, + set: { newValue in + updateBinding(surface: activeSurface, binding: properties.value, newValue: newValue) + activeSurface?.runChecks(for: id) + } + ) + + VStack(alignment: .leading, spacing: 4) { + if variant == .obscured { + SecureField(label, text: textBinding) + } else if variant == .longText { + Text(label) + .font(.caption) + .foregroundColor(.secondary) + TextEditor(text: textBinding) + } else { + TextField(label, text: textBinding) + #if os(iOS) + .keyboardType(variant == .number ? .decimalPad : .default) + #endif + + } + ValidationErrorMessageView(id: id, surface: activeSurface) + } + .textFieldStyle(.roundedBorder) + .onAppear { + activeSurface?.runChecks(for: id) + } + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + return VStack(spacing: 20) { + A2UITextFieldView(id: "tf1", properties: TextFieldProperties( + label: .init(literal: "Short Text"), + value: .init(literal: ""), + variant: .shortText + )) + + A2UITextFieldView(id: "tf2", properties: TextFieldProperties( + label: .init(literal: "Number Input"), + value: .init(literal: ""), + variant: .number + )) + + A2UITextFieldView(id: "tf3", properties: TextFieldProperties( + label: .init(literal: "Obscured Input"), + value: .init(literal: ""), + variant: .obscured + )) + } + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/TextField/TextFieldProperties.swift b/renderers/swift/Sources/A2UI/Components/TextField/TextFieldProperties.swift new file mode 100644 index 000000000..7e41b3fce --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/TextField/TextFieldProperties.swift @@ -0,0 +1,21 @@ +import Foundation + +public struct TextFieldProperties: Codable, Sendable { + public let label: BoundValue + public let value: BoundValue? + public let variant: TextFieldVariant? // longText, number, shortText, obscured + + public init(label: BoundValue, value: BoundValue? = nil, variant: TextFieldVariant? = nil) { + self.label = label + self.value = value + self.variant = variant + } +} + +public enum TextFieldVariant: String, Codable, Sendable, CaseIterable, Identifiable { + public var id: String { self.rawValue } + case longText = "longText" + case number = "number" + case shortText = "shortText" + case obscured = "obscured" +} diff --git a/renderers/swift/Sources/A2UI/Components/Video/A2UIVideoView.swift b/renderers/swift/Sources/A2UI/Components/Video/A2UIVideoView.swift new file mode 100644 index 000000000..5862f5c1e --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Video/A2UIVideoView.swift @@ -0,0 +1,62 @@ +import SwiftUI +import AVKit + +struct A2UIVideoView: View { + let properties: VideoProperties + @Environment(SurfaceState.self) var surface + @State private var player: AVPlayer? + @State private var showFullscreen: Bool = false + + var body: some View { + videoView + .frame(minHeight: 200) + .cornerRadius(8) + .onAppear { + if let urlString = surface.resolve(properties.url), let url = URL(string: urlString) { + player = AVPlayer(url: url) + } + } + .onDisappear { + player?.pause() + player = nil + } + #if os(iOS) + .fullScreenCover(isPresented: $showFullscreen) { + videoView + } + #endif + + } + + @ViewBuilder + private var videoView: some View { + VideoPlayer(player: player) { + VStack { + HStack { + Image(systemName: showFullscreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") + .padding(16) + .foregroundStyle(.white) + .tint(.white) + .onTapGesture { + showFullscreen.toggle() + } + + Spacer() + } + Spacer() + } + } + } +} + +#Preview { + let surface = SurfaceState(id: "test") + let dataStore = A2UIDataStore() + + A2UIVideoView(properties: VideoProperties( + url: .init(literal: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4") + )) + .padding() + .environment(surface) + .environment(dataStore) +} diff --git a/renderers/swift/Sources/A2UI/Components/Video/VideoProperties.swift b/renderers/swift/Sources/A2UI/Components/Video/VideoProperties.swift new file mode 100644 index 000000000..e6c84ba81 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Components/Video/VideoProperties.swift @@ -0,0 +1,5 @@ +import Foundation + +public struct VideoProperties: Codable, Sendable { + public let url: BoundValue +} diff --git a/renderers/swift/Sources/A2UI/DataStore/A2UIDataStore.swift b/renderers/swift/Sources/A2UI/DataStore/A2UIDataStore.swift new file mode 100644 index 000000000..8a4eb757b --- /dev/null +++ b/renderers/swift/Sources/A2UI/DataStore/A2UIDataStore.swift @@ -0,0 +1,113 @@ +import Foundation +import SwiftUI + +/// The central store for all A2UI surfaces and their data. +@MainActor @Observable public class A2UIDataStore: NSObject, URLSessionDataDelegate, Sendable { + /// A collection of active surfaces, keyed by their unique surfaceId. + public var surfaces: [String: SurfaceState] = [:] + + private let parser = A2UIParser() + private var streamRemainder = "" + + /// A callback for components to trigger actions that need to be sent back to the server. + public var actionHandler: ((UserAction) -> Void)? + + /// A callback for the app layer to handle incoming messages (e.g. for chat history). + public var messageHandler: ((A2UIMessage) -> Void)? + + /// A callback for the app layer to handle non-core application messages (e.g. "javascript", "text"). + public var appMessageHandler: ((String, [String: AnyCodable]) -> Void)? + + /// A callback for when the orchestrator sends a plain text message. + public var onTextMessageReceived: ((String) -> Void)? + + /// A registry for custom component renderers. + public var customRenderers: [String: @MainActor (ComponentInstance) -> AnyView] = [:] + + /// A registry for custom functions. + public var customFunctions: [String: @MainActor ([String: Any], SurfaceState) -> Any?] = [:] + + public override init() { + super.init() + } + + /// Processes a single A2UIMessage and updates the relevant surface. + public func process(message: A2UIMessage) { + // First, notify the message handler + messageHandler?(message) + + switch message { + case .createSurface(let create): + A2UILogger.info("Create surface: \(create.surfaceId)") + let _ = getOrCreateSurface(id: create.surfaceId) + + + case .surfaceUpdate(let update): + let surface = getOrCreateSurface(id: update.surfaceId) + A2UILogger.debug("Surface update: \(update.surfaceId) (\(update.components.count) components)") + surface.isReady = true + A2UILogger.info("Surface \(update.surfaceId) is now READY") + for component in update.components { + surface.components[component.id] = component + } + // If no root set yet, try to determine it + if surface.rootComponentId == nil { + if update.components.contains(where: { $0.id == "root" }) { + surface.rootComponentId = "root" + } else if let first = update.components.first { + // Fallback: use the first component as root if "root" isn't found + surface.rootComponentId = first.id + A2UILogger.info("No 'root' component found, defaulting to first component: \(first.id)") + } + } + + case .dataModelUpdate(let update): + let surfaceId = update.surfaceId + let surface = getOrCreateSurface(id: surfaceId) + A2UILogger.debug("Data model update: \(surfaceId)") + + let path = update.path ?? "/" + if let value = update.value?.value { + surface.setValue(at: path, value: value) + } + + case .deleteSurface(let delete): + A2UILogger.info("Delete surface: \(delete.surfaceId)") + surfaces.removeValue(forKey: delete.surfaceId) + + case .appMessage(let name, let data): + A2UILogger.info("Received application message: \(name)") + if name == "text", let text = data["text"]?.value as? String { + onTextMessageReceived?(text) + } + appMessageHandler?(name, data) + } + } + + public func process(chunk: String) { + let messages = parser.parse(chunk: chunk, remainder: &streamRemainder) + for message in messages { + process(message: message) + } + } + + public func flush() { + guard !streamRemainder.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + process(chunk: "\n") + } + + private func getOrCreateSurface(id: String) -> SurfaceState { + if let existing = surfaces[id] { + return existing + } + let newSurface = SurfaceState(id: id) + newSurface.customRenderers = self.customRenderers + newSurface.customFunctions = self.customFunctions + newSurface.actionHandler = { [weak self] userAction in + // Forward the action (event) to the application's action handler. + self?.actionHandler?(userAction) + } + surfaces[id] = newSurface + return newSurface + } +} diff --git a/renderers/swift/Sources/A2UI/DataStore/A2UIParser.swift b/renderers/swift/Sources/A2UI/DataStore/A2UIParser.swift new file mode 100644 index 000000000..6c9c5e9b3 --- /dev/null +++ b/renderers/swift/Sources/A2UI/DataStore/A2UIParser.swift @@ -0,0 +1,79 @@ +import Foundation + +/// A parser that handles the JSONL stream and emits A2UIMessages. +public class A2UIParser { + private let decoder = JSONDecoder() + + public init() {} + + /// Parses a single line of JSON from the stream. + /// - Parameter line: A single JSON string representing one or more A2UIMessages (comma-separated). + /// - Returns: A list of decoded A2UIMessages. + public func parse(line: String) throws -> [A2UIMessage] { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + guard let data = trimmed.data(using: .utf8) else { + throw A2UIParserError.invalidEncoding + } + + // Try decoding as a single message first + do { + let message = try decoder.decode(A2UIMessage.self, from: data) + return [message] + } catch { + // If that fails, try wrapping in [] to see if it's a comma-separated list of objects + // or if it's already an array. + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + return try decoder.decode([A2UIMessage].self, from: data) + } + + let wrappedJson = "[\(trimmed)]" + guard let wrappedData = wrappedJson.data(using: .utf8) else { + throw error + } + + do { + return try decoder.decode([A2UIMessage].self, from: wrappedData) + } catch { + // If both fail, throw the original error + throw error + } + } + } + + /// Helper to process a chunk of text that may contain multiple lines. + /// Useful for partial data received over a network stream. + public func parse(chunk: String, remainder: inout String) -> [A2UIMessage] { + let start = DispatchTime.now() + + let fullContent = remainder + chunk + var lines = fullContent.components(separatedBy: .newlines) + + // The last element is either empty (if chunk ended in newline) + // or a partial line (the new remainder). + remainder = lines.removeLast() + + var messages: [A2UIMessage] = [] + for line in lines { + do { + let parsedMessages = try parse(line: line) + messages.append(contentsOf: parsedMessages) + } catch { + A2UILogger.error("A2UI Parser Error: \(error) on line: \(line)") + } + } + + let end = DispatchTime.now() + let diff = Double(end.uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000 + if !messages.isEmpty { + A2UILogger.debug("Parsed \(messages.count) messages in \(String(format: "%.3f", diff))ms") + } + + return messages + } +} + +public enum A2UIParserError: Error { + case invalidEncoding +} diff --git a/renderers/swift/Sources/A2UI/Functions/A2UIStandardFunctions.swift b/renderers/swift/Sources/A2UI/Functions/A2UIStandardFunctions.swift new file mode 100644 index 000000000..41b62a7a8 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/A2UIStandardFunctions.swift @@ -0,0 +1,161 @@ +import Foundation + +@MainActor +public enum A2UIStandardFunctions { + + public static func evaluate(call: FunctionCall, surface: SurfaceState) -> Any? { + // First, resolve all arguments + var resolvedArgs: [String: Any] = [:] + for (key, value) in call.args { + resolvedArgs[key] = resolveDynamicValue(value.value, surface: surface) + } + + // Check for custom function implementations first + if let customHandler = surface.customFunctions[call.call] { + return customHandler(resolvedArgs, surface) + } + + switch call.call { + case "required": + guard let val = resolvedArgs["value"] else { return false } + return isRequired(value: val) + case "regex": + guard let val = resolvedArgs["value"] as? String, + let pattern = resolvedArgs["pattern"] as? String else { return false } + return matchesRegex(value: val, pattern: pattern) + case "length": + guard let val = resolvedArgs["value"] as? String, + (asInt(resolvedArgs["min"]) != nil || asInt(resolvedArgs["max"]) != nil) else { return false } + return checkLength( + value: val, + min: asInt(resolvedArgs["min"]), + max: asInt(resolvedArgs["max"]) + ) + case "numeric": + guard let val = asDouble(resolvedArgs["value"]), + (asDouble(resolvedArgs["min"]) != nil || asDouble(resolvedArgs["max"]) != nil) else { return false } + return checkNumeric( + value: val, + min: asDouble(resolvedArgs["min"]), + max: asDouble(resolvedArgs["max"]) + ) + case "email": + guard let val = resolvedArgs["value"] as? String else { return false } + return isEmail(value: val) + case "formatString": + guard let format = resolvedArgs["value"] as? String else { return "" } + return formatString(format: format, surface: surface) + case "formatNumber": + guard let val = asDouble(resolvedArgs["value"]) else { return "" } + return formatNumber( + value: val, + decimals: asInt(resolvedArgs["decimals"]), + grouping: resolvedArgs["grouping"] as? Bool + ) + case "formatCurrency": + guard let val = asDouble(resolvedArgs["value"]), + let currency = resolvedArgs["currency"] as? String else { return "" } + return formatCurrency( + value: val, + currency: currency, + decimals: asInt(resolvedArgs["decimals"]), + grouping: resolvedArgs["grouping"] as? Bool + ) + case "formatDate": + guard let val = resolvedArgs["value"], + let format = resolvedArgs["format"] as? String else { return "" } + return formatDate(value: val, format: format) + case "pluralize": + guard let val = asDouble(resolvedArgs["value"]), + let other = resolvedArgs["other"] as? String else { return "" } + return pluralize( + value: val, + zero: resolvedArgs["zero"] as? String, + one: resolvedArgs["one"] as? String, + two: resolvedArgs["two"] as? String, + few: resolvedArgs["few"] as? String, + many: resolvedArgs["many"] as? String, + other: other + ) + case "openUrl": + guard let url = resolvedArgs["url"] as? String else { return nil } + openUrl(url: url) + return nil + case "and": + guard let values = resolvedArgs["values"] as? [Bool], values.count >= 2 else { return false } + return performAnd(values: values) + case "or": + guard let values = resolvedArgs["values"] as? [Bool], values.count >= 2 else { return false } + return performOr(values: values) + case "not": + guard let value = resolvedArgs["value"] as? Bool else { return false } + return performNot(value: value) + default: + A2UILogger.error("Unknown function call: \(call.call)") + return nil + } + } + + private static func asInt(_ value: Any?) -> Int? { + if let i = value as? Int { return i } + if let d = value as? Double { return Int(d) } + if let s = value as? String { return Int(s) } + return nil + } + + private static func asDouble(_ value: Any?) -> Double? { + if let d = value as? Double { return d } + if let i = value as? Int { return Double(i) } + if let s = value as? String { return Double(s) } + return nil + } + + static func resolveDynamicValue(_ value: Any?, surface: SurfaceState) -> Any? { + guard let value = value else { return nil } + + // If it's a dictionary, it might be a DataBinding or a FunctionCall + if let dict = value as? [String: Any] { + if let path = dict["path"] as? String { + // It's a DataBinding + return surface.getValue(at: path) + } else if let callName = dict["call"] as? String { + // It's a FunctionCall + // We need to reconstruct the FunctionCall object or evaluate it directly + let args = dict["args"] as? [String: Any] ?? [:] + let anyCodableArgs = args.mapValues { AnyCodable(makeSendable($0)) } + let returnType = dict["returnType"] as? String + let nestedCall = FunctionCall(call: callName, args: anyCodableArgs, returnType: returnType) + return evaluate(call: nestedCall, surface: surface) + } + } else if let array = value as? [Any] { + // Handle lists of DynamicValues (like in 'and'/'or' functions) + return array.map { resolveDynamicValue($0, surface: surface) } + } + + // Otherwise, it's a literal + return value + } + + /// Recursively converts Any values (like [String: Any] or [Any]) into Sendable existentials. + static func makeSendable(_ value: Any) -> Sendable { + if let dict = value as? [String: Any] { + return dict.mapValues { makeSendable($0) } + } + if let array = value as? [Any] { + return array.map { makeSendable($0) } + } + + // Marker protocols like Sendable cannot be used with 'as?'. + // We handle common JSON-compatible Sendable types explicitly. + if let s = value as? String { return s } + if let i = value as? Int { return i } + if let d = value as? Double { return d } + if let b = value as? Bool { return b } + if let date = value as? Date { return date } + if let null = value as? JSONNull { return null } + if value is NSNull { return JSONNull() } + + // Default fallback: if we can't guarantee Sendability for a type, we use JSONNull. + return JSONNull() + } +} diff --git a/renderers/swift/Sources/A2UI/Functions/Formatting/FormatCurrency.swift b/renderers/swift/Sources/A2UI/Functions/Formatting/FormatCurrency.swift new file mode 100644 index 000000000..957839dbe --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/Formatting/FormatCurrency.swift @@ -0,0 +1,20 @@ +import Foundation + +extension A2UIStandardFunctions { + static func formatCurrency(value: Double, currency: String, decimals: Int?, grouping: Bool?) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currency + + if let decimals = decimals { + formatter.minimumFractionDigits = decimals + formatter.maximumFractionDigits = decimals + } + + if let grouping = grouping { + formatter.usesGroupingSeparator = grouping + } + + return formatter.string(from: NSNumber(value: value)) ?? "\(currency) \(value)" + } +} diff --git a/renderers/swift/Sources/A2UI/Functions/Formatting/FormatDate.swift b/renderers/swift/Sources/A2UI/Functions/Formatting/FormatDate.swift new file mode 100644 index 000000000..f9b361f8f --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/Formatting/FormatDate.swift @@ -0,0 +1,42 @@ +import Foundation +import DataDetection + +extension A2UIStandardFunctions { + static func formatDate(value: Any, format: String, timeZone: TimeZone = .autoupdatingCurrent, locale: Locale = .autoupdatingCurrent) -> String { + let date: Date + if let d = value as? Date { + date = d + } else if let s = value as? String { + // Try ISO 8601 + let isoFormatter = ISO8601DateFormatter() + if let d = isoFormatter.date(from: s) { + date = d + } else { + if let detector = try? NSDataDetector(types: NSTextCheckingAllSystemTypes) { + let matches = detector.matches(in: s, range: NSRange(location: 0, length: s.count)) + let dateMatches = matches.filter { $0.resultType == .date } + if let firstDate = dateMatches.first?.date { + date = firstDate + } else { + return s + } + } else { + return s + } + } + } else if let d = value as? Double { + // Assume seconds since 1970 + date = Date(timeIntervalSince1970: d) + } else { + return "\(value)" + } + + let formatter = DateFormatter() + print(format, timeZone, locale) + formatter.timeZone = timeZone + formatter.setLocalizedDateFormatFromTemplate(format) + let str = formatter.string(from: date) + print(str) + return str + } +} diff --git a/renderers/swift/Sources/A2UI/Functions/Formatting/FormatNumber.swift b/renderers/swift/Sources/A2UI/Functions/Formatting/FormatNumber.swift new file mode 100644 index 000000000..3b8b04d87 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/Formatting/FormatNumber.swift @@ -0,0 +1,21 @@ +import Foundation + +extension A2UIStandardFunctions { + static func formatNumber(value: Double, decimals: Int?, grouping: Bool?) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + + if let decimals = decimals { + formatter.minimumFractionDigits = decimals + formatter.maximumFractionDigits = decimals + } + + if let grouping = grouping { + formatter.usesGroupingSeparator = grouping + } else { + formatter.usesGroupingSeparator = true + } + + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + } +} diff --git a/renderers/swift/Sources/A2UI/Functions/Formatting/FormatString.swift b/renderers/swift/Sources/A2UI/Functions/Formatting/FormatString.swift new file mode 100644 index 000000000..bd0e39016 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/Formatting/FormatString.swift @@ -0,0 +1,40 @@ +import Foundation + +extension A2UIStandardFunctions { + static func formatString(format: String, surface: SurfaceState) -> String { + // Simple interpolation for ${/path} or ${expression} + // This is a basic implementation of the description in basic_catalog.json + var result = format + let pattern = #"\$\{([^}]+)\}"# + let regex = try? NSRegularExpression(pattern: pattern) + let matches = regex?.matches(in: format, options: [], range: NSRange(location: 0, length: format.utf16.count)) + + for match in (matches ?? []).reversed() { + let fullRange = match.range + let expressionRange = match.range(at: 1) + if let r = Range(expressionRange, in: format) { + let expression = String(format[r]) + let replacement: String + + if expression.hasPrefix("/") { + // It's a path + if let val = surface.getValue(at: expression) { + replacement = "\(val)" + } else { + replacement = "" + } + } else { + // For now, only simple paths are supported in formatString interpolation + // In a full implementation, we'd parse and evaluate expressions here + replacement = "${\(expression)}" + } + + if let fullR = Range(fullRange, in: result) { + result.replaceSubrange(fullR, with: replacement) + } + } + } + + return result + } +} diff --git a/renderers/swift/Sources/A2UI/Functions/Formatting/OpenUrl.swift b/renderers/swift/Sources/A2UI/Functions/Formatting/OpenUrl.swift new file mode 100644 index 000000000..a71e5f39f --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/Formatting/OpenUrl.swift @@ -0,0 +1,39 @@ +import Foundation + +// Define a common interface for all platforms +@MainActor +protocol URLOpener: NSObject { + func open(_ url: URL) +} + +extension A2UIStandardFunctions { + static func openUrl(url: String) { + guard let url = URL(string: url) else { return } + sharedURLOpener.open(url) + } +} + +// Implement open URL functionality for each platform +#if os(iOS) +import UIKit +extension A2UIStandardFunctions { + static var sharedURLOpener: URLOpener = UIApplication.shared +} + +extension UIApplication: URLOpener { + func open(_ url: URL) { + self.open(url, options: [:], completionHandler: nil) + } +} +#elseif os(macOS) +import AppKit +extension A2UIStandardFunctions { + static var sharedURLOpener: URLOpener = NSWorkspace.shared +} +@MainActor +extension NSWorkspace: URLOpener { + func open(_ url: URL) { + self.open(url, configuration: .init(), completionHandler: nil) + } +} +#endif diff --git a/renderers/swift/Sources/A2UI/Functions/Formatting/Pluralize.swift b/renderers/swift/Sources/A2UI/Functions/Formatting/Pluralize.swift new file mode 100644 index 000000000..8e05a3f82 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/Formatting/Pluralize.swift @@ -0,0 +1,26 @@ +import Foundation + +extension A2UIStandardFunctions { + static func pluralize( + value: Double, + zero: String?, + one: String?, + two: String?, + few: String?, + many: String?, + other: String + ) -> String { + + // This is a simplified version of CLDR pluralization + // For English: 1 -> one, everything else -> other + if value == 1 { + return one ?? other + } else if value == 0 { + return zero ?? other + } else if value == 2 { + return two ?? other + } else { + return other + } + } +} diff --git a/renderers/swift/Sources/A2UI/Functions/Logical/PerformAnd.swift b/renderers/swift/Sources/A2UI/Functions/Logical/PerformAnd.swift new file mode 100644 index 000000000..ca5937db4 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/Logical/PerformAnd.swift @@ -0,0 +1,7 @@ +import Foundation + +extension A2UIStandardFunctions { + static func performAnd(values: [Bool]) -> Bool { + return values.allSatisfy { $0 } + } +} diff --git a/renderers/swift/Sources/A2UI/Functions/Logical/PerformNot.swift b/renderers/swift/Sources/A2UI/Functions/Logical/PerformNot.swift new file mode 100644 index 000000000..4e819477a --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/Logical/PerformNot.swift @@ -0,0 +1,7 @@ +import Foundation + +extension A2UIStandardFunctions { + static func performNot(value: Bool) -> Bool { + return !value + } +} diff --git a/renderers/swift/Sources/A2UI/Functions/Logical/PerformOr.swift b/renderers/swift/Sources/A2UI/Functions/Logical/PerformOr.swift new file mode 100644 index 000000000..0ac8388d7 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/Logical/PerformOr.swift @@ -0,0 +1,7 @@ +import Foundation + +extension A2UIStandardFunctions { + static func performOr(values: [Bool]) -> Bool { + return values.contains { $0 } + } +} diff --git a/renderers/swift/Sources/A2UI/Functions/Validation/CheckLength.swift b/renderers/swift/Sources/A2UI/Functions/Validation/CheckLength.swift new file mode 100644 index 000000000..c27ec5c19 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/Validation/CheckLength.swift @@ -0,0 +1,15 @@ +import Foundation + +extension A2UIStandardFunctions { + static func checkLength(value: String, min: Int?, max: Int?) -> Bool { + let length = value.count + + if let min = min { + if length < min { return false } + } + if let max = max { + if length > max { return false } + } + return true + } +} diff --git a/renderers/swift/Sources/A2UI/Functions/Validation/CheckNumeric.swift b/renderers/swift/Sources/A2UI/Functions/Validation/CheckNumeric.swift new file mode 100644 index 000000000..160d6c4c6 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/Validation/CheckNumeric.swift @@ -0,0 +1,13 @@ +import Foundation + +extension A2UIStandardFunctions { + static func checkNumeric(value: Double, min: Double?, max: Double?) -> Bool { + if let min = min { + if value < min { return false } + } + if let max = max { + if value > max { return false } + } + return true + } +} diff --git a/renderers/swift/Sources/A2UI/Functions/Validation/IsEmail.swift b/renderers/swift/Sources/A2UI/Functions/Validation/IsEmail.swift new file mode 100644 index 000000000..b2e07892b --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/Validation/IsEmail.swift @@ -0,0 +1,10 @@ +import Foundation + +extension A2UIStandardFunctions { + static func isEmail(value: String) -> Bool { + let pattern = #"[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,64}"# + let regex = try? NSRegularExpression(pattern: pattern) + let range = NSRange(location: 0, length: value.utf16.count) + return regex?.firstMatch(in: value, options: [], range: range) != nil + } +} diff --git a/renderers/swift/Sources/A2UI/Functions/Validation/IsRequired.swift b/renderers/swift/Sources/A2UI/Functions/Validation/IsRequired.swift new file mode 100644 index 000000000..9edf326e8 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/Validation/IsRequired.swift @@ -0,0 +1,13 @@ +import Foundation + +extension A2UIStandardFunctions { + static func isRequired(value: Any) -> Bool { + if let s = value as? String { + return !s.isEmpty + } + if value is NSNull || value is JSONNull { + return false + } + return true + } +} diff --git a/renderers/swift/Sources/A2UI/Functions/Validation/MatchesRegex.swift b/renderers/swift/Sources/A2UI/Functions/Validation/MatchesRegex.swift new file mode 100644 index 000000000..292cafe7e --- /dev/null +++ b/renderers/swift/Sources/A2UI/Functions/Validation/MatchesRegex.swift @@ -0,0 +1,14 @@ +import Foundation + +extension A2UIStandardFunctions { + static func matchesRegex(value: String, pattern: String) -> Bool { + do { + let regex = try NSRegularExpression(pattern: pattern, options: []) + let range = NSRange(location: 0, length: value.utf16.count) + return regex.firstMatch(in: value, options: [], range: range) != nil + } catch { + A2UILogger.error("Invalid regex pattern: \(pattern)") + return false + } + } +} diff --git a/renderers/swift/Sources/A2UI/Models/A2UIMessage.swift b/renderers/swift/Sources/A2UI/Models/A2UIMessage.swift new file mode 100644 index 000000000..bf8d42e6f --- /dev/null +++ b/renderers/swift/Sources/A2UI/Models/A2UIMessage.swift @@ -0,0 +1,92 @@ +import Foundation + +/// The root message received from the A2UI stream. +/// Each line in the JSONL stream should decode into this enum. +/// Strictly supports A2UI v0.9 specification. +public enum A2UIMessage: Codable { + case createSurface(CreateSurfaceMessage) + case surfaceUpdate(SurfaceUpdate) + case dataModelUpdate(DataModelUpdate) + case deleteSurface(DeleteSurface) + case appMessage(name: String, data: [String: AnyCodable]) + + enum CodingKeys: String, CodingKey { + case version + case createSurface + case updateComponents + case updateDataModel + case deleteSurface + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Strictly validate version if present + if let version = try? container.decode(String.self, forKey: .version), version != "v0.9" { + throw DecodingError.dataCorruptedError(forKey: .version, in: container, debugDescription: "Unsupported A2UI version: \(version). Only v0.9 is supported.") + } + + if container.contains(.createSurface) { + self = .createSurface(try container.decode(CreateSurfaceMessage.self, forKey: .createSurface)) + } else if container.contains(.updateComponents) { + self = .surfaceUpdate(try container.decode(SurfaceUpdate.self, forKey: .updateComponents)) + } else if container.contains(.updateDataModel) { + self = .dataModelUpdate(try container.decode(DataModelUpdate.self, forKey: .updateDataModel)) + } else if container.contains(.deleteSurface) { + self = .deleteSurface(try container.decode(DeleteSurface.self, forKey: .deleteSurface)) + } else { + // App Message handling: catch any other top-level key that isn't an A2UI core message + let anyContainer = try decoder.container(keyedBy: AnyCodingKey.self) + let knownKeys = Set(CodingKeys.allCases.map { $0.stringValue }) + let unknownKeys = anyContainer.allKeys.filter { !knownKeys.contains($0.stringValue) && $0.stringValue != "version" } + + if !unknownKeys.isEmpty { + var allData: [String: AnyCodable] = [:] + for key in unknownKeys { + let dataValue = try anyContainer.decode(AnyCodable.self, forKey: key) + allData[key.stringValue] = dataValue + } + if unknownKeys.count > 1 { + A2UILogger.warning("A2UI message contains multiple unknown keys (\(unknownKeys.map { $0.stringValue }.joined(separator: ", "))). All keys will be included in the data dictionary, but only the first will be used as the message name.") + } + let primaryName = unknownKeys.first!.stringValue + self = .appMessage(name: primaryName, data: allData) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Missing or unknown A2UI v0.9 Message") + ) + } + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("v0.9", forKey: .version) + switch self { + case .createSurface(let value): + try container.encode(value, forKey: .createSurface) + case .surfaceUpdate(let value): + try container.encode(value, forKey: .updateComponents) + case .dataModelUpdate(let update): + try container.encode(update, forKey: .updateDataModel) + case .deleteSurface(let value): + try container.encode(value, forKey: .deleteSurface) + case .appMessage(_, let data): + var anyContainer = encoder.container(keyedBy: AnyCodingKey.self) + for (keyStr, val) in data { + if let key = AnyCodingKey(stringValue: keyStr) { + try anyContainer.encode(val, forKey: key) + } + } + } + } +} + +struct AnyCodingKey: CodingKey { + var stringValue: String + var intValue: Int? + init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil } + init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } +} + +extension A2UIMessage.CodingKeys: CaseIterable {} diff --git a/renderers/swift/Sources/A2UI/Models/Action.swift b/renderers/swift/Sources/A2UI/Models/Action.swift new file mode 100644 index 000000000..76a17a27f --- /dev/null +++ b/renderers/swift/Sources/A2UI/Models/Action.swift @@ -0,0 +1,53 @@ +import Foundation + +public enum Action: Codable, Sendable { + case event(name: String, context: [String: AnyCodable]?) + case functionCall(FunctionCall) + + enum CodingKeys: String, CodingKey { + case event, functionCall + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let eventPayload = try? container.decode(EventPayload.self, forKey: .event) { + self = .event(name: eventPayload.name, context: eventPayload.context) + } else if let functionCall = try? container.decode(FunctionCall.self, forKey: .functionCall) { + self = .functionCall(functionCall) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unknown Action type or missing v0.9 structure (event or functionCall)") + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .event(let name, let context): + try container.encode(EventPayload(name: name, context: context), forKey: .event) + case .functionCall(let fc): + try container.encode(fc, forKey: .functionCall) + } + } +} + +public struct EventPayload: Codable, Sendable { + public let name: String + public let context: [String: AnyCodable]? + + public init(name: String, context: [String: AnyCodable]? = nil) { + self.name = name + self.context = context + } +} + +public struct DataUpdateAction: Sendable { + public let path: String + public let contents: AnyCodable + + public init(path: String, contents: AnyCodable) { + self.path = path + self.contents = contents + } +} diff --git a/renderers/swift/Sources/A2UI/Models/AnyCodable.swift b/renderers/swift/Sources/A2UI/Models/AnyCodable.swift new file mode 100644 index 000000000..d13a8984d --- /dev/null +++ b/renderers/swift/Sources/A2UI/Models/AnyCodable.swift @@ -0,0 +1,50 @@ +import Foundation + +public struct JSONNull: Codable, Sendable, Hashable { + public init() {} + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if !container.decodeNil() { throw DecodingError.typeMismatch(JSONNull.self, .init(codingPath: decoder.codingPath, debugDescription: "")) } + } + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer(); try container.encodeNil() + } +} + +public struct AnyCodable: Codable, Sendable, Equatable { + public let value: Sendable + public init(_ value: Sendable) { self.value = value } + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { value = JSONNull() } + else if let x = try? container.decode(String.self) { value = x } + else if let x = try? container.decode(Bool.self) { value = x } + else if let x = try? container.decode(Double.self) { value = x } + else if let x = try? container.decode([String: AnyCodable].self) { value = x.mapValues { $0.value } } + else if let x = try? container.decode([AnyCodable].self) { value = x.map { $0.value } } + else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Wrong type") } + } + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + if value is JSONNull { try container.encodeNil() } + else if let x = value as? String { try container.encode(x) } + else if let x = value as? Bool { try container.encode(x) } + else if let x = value as? Double { try container.encode(x) } + else if let x = value as? [String: Sendable] { try container.encode(x.mapValues { AnyCodable($0) }) } + else if let x = value as? [Sendable] { try container.encode(x.map { AnyCodable($0) }) } + } + + public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { + switch (lhs.value, rhs.value) { + case is (JSONNull, JSONNull): return true + case let (l as String, r as String): return l == r + case let (l as Bool, r as Bool): return l == r + case let (l as Double, r as Double): return l == r + case let (l as [String: Sendable], r as [String: Sendable]): + return (l as NSDictionary).isEqual(to: r) + case let (l as [Sendable], r as [Sendable]): + return (l as NSArray).isEqual(to: r) + default: return false + } + } +} diff --git a/renderers/swift/Sources/A2UI/Models/BoundValue.swift b/renderers/swift/Sources/A2UI/Models/BoundValue.swift new file mode 100644 index 000000000..fa11d6d86 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Models/BoundValue.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct BoundValue: Codable, Sendable, Equatable { + public let literal: T? + public let path: String? + public let functionCall: FunctionCall? + + enum CodingKeys: String, CodingKey { + case path + case call + case args + case returnType + } + + public init(literal: T? = nil, path: String? = nil, functionCall: FunctionCall? = nil) { + self.literal = literal + self.path = path + self.functionCall = functionCall + } + + public init(from decoder: Decoder) throws { + if let container = try? decoder.singleValueContainer(), let val = try? container.decode(T.self) { + self.literal = val + self.path = nil + self.functionCall = nil + } else { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.path = try container.decodeIfPresent(String.self, forKey: .path) + + if container.contains(.call) { + // Direct function call properties + self.functionCall = try FunctionCall(from: decoder) + } else { + self.functionCall = nil + } + + self.literal = nil + } + } + + public func encode(to encoder: Encoder) throws { + if let functionCall = functionCall { + try functionCall.encode(to: encoder) + } else if let path = path { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(path, forKey: .path) + } else if let literal = literal { + var container = encoder.singleValueContainer() + try container.encode(literal) + } + } +} diff --git a/renderers/swift/Sources/A2UI/Models/CheckRule.swift b/renderers/swift/Sources/A2UI/Models/CheckRule.swift new file mode 100644 index 000000000..7e532fd8b --- /dev/null +++ b/renderers/swift/Sources/A2UI/Models/CheckRule.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct CheckRule: Codable, Sendable, Equatable { + public let condition: BoundValue + public let message: String + + public init(condition: BoundValue, message: String) { + self.condition = condition + self.message = message + } +} diff --git a/renderers/swift/Sources/A2UI/Models/Children.swift b/renderers/swift/Sources/A2UI/Models/Children.swift new file mode 100644 index 000000000..9bb8b247d --- /dev/null +++ b/renderers/swift/Sources/A2UI/Models/Children.swift @@ -0,0 +1,40 @@ +import Foundation + +public enum Children: Codable, Sendable { + case list([String]) + case template(Template) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let list = try? container.decode([String].self) { + self = .list(list) + } else if let template = try? container.decode(Template.self) { + self = .template(template) + } else { + // Support legacy v0.8 explicitList wrapper for compatibility + let keyedContainer = try decoder.container(keyedBy: RawCodingKey.self) + if let explicitList = try? keyedContainer.decode([String].self, forKey: RawCodingKey(stringValue: "explicitList")!) { + self = .list(explicitList) + } else if let template = try? keyedContainer.decode(Template.self, forKey: RawCodingKey(stringValue: "template")!) { + self = .template(template) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Children must be an array of strings, a template object, or a legacy explicitList/template wrapper.") + } + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .list(let list): + try container.encode(list) + case .template(let template): + try container.encode(template) + } + } +} + +public struct Template: Codable, Sendable { + public let componentId: String + public let path: String +} diff --git a/renderers/swift/Sources/A2UI/Models/ComponentInstance.swift b/renderers/swift/Sources/A2UI/Models/ComponentInstance.swift new file mode 100644 index 000000000..c1567d648 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Models/ComponentInstance.swift @@ -0,0 +1,48 @@ +import Foundation + +public struct ComponentInstance: Codable { + public let id: String + public let weight: Double? + public let checks: [CheckRule]? + public let component: ComponentType + + public init(id: String, weight: Double? = nil, checks: [CheckRule]? = nil, component: ComponentType) { + self.id = id + self.weight = weight + self.checks = checks + self.component = component + } + + enum CodingKeys: String, CodingKey { + case id, weight, checks, component + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.weight = try container.decodeIfPresent(Double.self, forKey: .weight) + self.checks = try container.decodeIfPresent([CheckRule].self, forKey: .checks) + + // Try two formats: + // Format 1: component is a string (type name) with properties at same level + if let typeName = try? container.decode(String.self, forKey: .component) { + self.component = try ComponentType(typeName: typeName, from: decoder) + } else { + // Format 2: component is an object like {"Text": {...}} + self.component = try container.decode(ComponentType.self, forKey: .component) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(weight, forKey: .weight) + try container.encode(component, forKey: .component) + } +} + +extension ComponentInstance { + public var componentTypeName: String { + component.typeName + } +} diff --git a/renderers/swift/Sources/A2UI/Models/ComponentType.swift b/renderers/swift/Sources/A2UI/Models/ComponentType.swift new file mode 100644 index 000000000..c26a1d46d --- /dev/null +++ b/renderers/swift/Sources/A2UI/Models/ComponentType.swift @@ -0,0 +1,126 @@ +import Foundation + +public enum ComponentType: Codable { + public init(typeName: String, from decoder: Decoder) throws { + switch typeName { + case "Text": self = .text(try TextProperties(from: decoder)) + case "Button": self = .button(try ButtonProperties(from: decoder)) + case "Row": self = .row(try ContainerProperties(from: decoder)) + case "Column": self = .column(try ContainerProperties(from: decoder)) + case "Card": self = .card(try CardProperties(from: decoder)) + case "Image": self = .image(try ImageProperties(from: decoder)) + case "Icon": self = .icon(try IconProperties(from: decoder)) + case "Video": self = .video(try VideoProperties(from: decoder)) + case "AudioPlayer": self = .audioPlayer(try AudioPlayerProperties(from: decoder)) + case "Divider": self = .divider(try DividerProperties(from: decoder)) + case "List": self = .list(try ListProperties(from: decoder)) + case "Tabs": self = .tabs(try TabsProperties(from: decoder)) + case "Modal": self = .modal(try ModalProperties(from: decoder)) + case "TextField": self = .textField(try TextFieldProperties(from: decoder)) + case "CheckBox": self = .checkBox(try CheckBoxProperties(from: decoder)) + case "ChoicePicker": self = .choicePicker(try ChoicePickerProperties(from: decoder)) + case "Slider": self = .slider(try SliderProperties(from: decoder)) + case "DateTimeInput": self = .dateTimeInput(try DateTimeInputProperties(from: decoder)) + default: + let props = try [String: AnyCodable](from: decoder) + self = .custom(typeName, props) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: RawCodingKey.self) + guard let key = container.allKeys.first else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Missing component type") + ) + } + + let nestedDecoder = try container.superDecoder(forKey: key) + self = try ComponentType(typeName: key.stringValue, from: nestedDecoder) + } + case text(TextProperties) + case button(ButtonProperties) + case row(ContainerProperties) + case column(ContainerProperties) + case card(CardProperties) + case image(ImageProperties) + case icon(IconProperties) + case video(VideoProperties) + case audioPlayer(AudioPlayerProperties) + case divider(DividerProperties) + case list(ListProperties) + case tabs(TabsProperties) + case modal(ModalProperties) + case textField(TextFieldProperties) + case checkBox(CheckBoxProperties) + case choicePicker(ChoicePickerProperties) + case slider(SliderProperties) + case dateTimeInput(DateTimeInputProperties) + case custom(String, [String: AnyCodable]) + + enum CodingKeys: String, CodingKey { + case text = "Text", button = "Button", row = "Row", column = "Column", card = "Card" + case image = "Image", icon = "Icon", video = "Video", audioPlayer = "AudioPlayer" + case divider = "Divider", list = "List", tabs = "Tabs", modal = "Modal" + case textField = "TextField", checkBox = "CheckBox", choicePicker = "ChoicePicker" + case slider = "Slider", dateTimeInput = "DateTimeInput" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .text(let p): try container.encode(p, forKey: .text) + case .button(let p): try container.encode(p, forKey: .button) + case .row(let p): try container.encode(p, forKey: .row) + case .column(let p): try container.encode(p, forKey: .column) + case .card(let p): try container.encode(p, forKey: .card) + case .image(let p): try container.encode(p, forKey: .image) + case .icon(let p): try container.encode(p, forKey: .icon) + case .video(let p): try container.encode(p, forKey: .video) + case .audioPlayer(let p): try container.encode(p, forKey: .audioPlayer) + case .divider(let p): try container.encode(p, forKey: .divider) + case .list(let p): try container.encode(p, forKey: .list) + case .tabs(let p): try container.encode(p, forKey: .tabs) + case .modal(let p): try container.encode(p, forKey: .modal) + case .textField(let p): try container.encode(p, forKey: .textField) + case .checkBox(let p): try container.encode(p, forKey: .checkBox) + case .choicePicker(let p): try container.encode(p, forKey: .choicePicker) + case .slider(let p): try container.encode(p, forKey: .slider) + case .dateTimeInput(let p): try container.encode(p, forKey: .dateTimeInput) + case .custom(let name, let props): + var c = encoder.container(keyedBy: RawCodingKey.self) + try c.encode(props, forKey: RawCodingKey(stringValue: name)!) + } + } + + public var typeName: String { + switch self { + case .text: return "Text" + case .button: return "Button" + case .row: return "Row" + case .column: return "Column" + case .card: return "Card" + case .image: return "Image" + case .icon: return "Icon" + case .video: return "Video" + case .audioPlayer: return "AudioPlayer" + case .divider: return "Divider" + case .list: return "List" + case .tabs: return "Tabs" + case .modal: return "Modal" + case .textField: return "TextField" + case .checkBox: return "CheckBox" + case .choicePicker: return "ChoicePicker" + case .slider: return "Slider" + case .dateTimeInput: return "DateTimeInput" + case .custom(let name, _): return name + } + } +} + +struct RawCodingKey: CodingKey { + var stringValue: String + init?(stringValue: String) { self.stringValue = stringValue } + var intValue: Int? + init?(intValue: Int) { return nil } +} diff --git a/renderers/swift/Sources/A2UI/Models/FunctionCall.swift b/renderers/swift/Sources/A2UI/Models/FunctionCall.swift new file mode 100644 index 000000000..2f57f958b --- /dev/null +++ b/renderers/swift/Sources/A2UI/Models/FunctionCall.swift @@ -0,0 +1,37 @@ +import Foundation + +public struct FunctionCall: Codable, Sendable, Equatable { + public let call: String + public let args: [String: AnyCodable] + public let returnType: String? + + enum CodingKeys: String, CodingKey { + case call, args, returnType + } + + public init(call: String, args: [String: AnyCodable] = [:], returnType: String? = nil) { + self.call = call + self.args = args + self.returnType = returnType + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + call = try container.decode(String.self, forKey: .call) + args = try container.decodeIfPresent([String: AnyCodable].self, forKey: .args) ?? [:] + returnType = try container.decodeIfPresent(String.self, forKey: .returnType) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(call, forKey: .call) + if !args.isEmpty { + try container.encode(args, forKey: .args) + } + try container.encodeIfPresent(returnType, forKey: .returnType) + } + + public static func == (lhs: FunctionCall, rhs: FunctionCall) -> Bool { + return lhs.call == rhs.call && lhs.args == rhs.args && lhs.returnType == rhs.returnType + } +} diff --git a/renderers/swift/Sources/A2UI/Models/Messages.swift b/renderers/swift/Sources/A2UI/Models/Messages.swift new file mode 100644 index 000000000..7b57154a0 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Models/Messages.swift @@ -0,0 +1,42 @@ +import Foundation + +public struct CreateSurfaceMessage: Codable { + public let surfaceId: String + public let catalogId: String + public let theme: [String: AnyCodable]? + public let sendDataModel: Bool? + + enum CodingKeys: String, CodingKey { + case surfaceId, catalogId, theme, sendDataModel + } +} + +public struct SurfaceUpdate: Codable { + public let surfaceId: String + public let components: [ComponentInstance] + + enum CodingKeys: String, CodingKey { + case surfaceId, components + } +} + +public struct DataModelUpdate: Codable { + public let surfaceId: String + public let path: String? + public let value: AnyCodable? + + enum CodingKeys: String, CodingKey { + case surfaceId, path, value + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + surfaceId = try container.decode(String.self, forKey: .surfaceId) + path = try container.decodeIfPresent(String.self, forKey: .path) + value = try container.decodeIfPresent(AnyCodable.self, forKey: .value) + } +} + +public struct DeleteSurface: Codable { + public let surfaceId: String +} diff --git a/renderers/swift/Sources/A2UI/Models/UserAction.swift b/renderers/swift/Sources/A2UI/Models/UserAction.swift new file mode 100644 index 000000000..ccd3c5a25 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Models/UserAction.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Represents a user-initiated action sent from the client to the server. +/// Matches the 'action' property in the A2UI v0.9 client-to-server schema. +public struct UserAction: Codable, Sendable { + public let name: String + public let surfaceId: String + public let sourceComponentId: String + public let timestamp: Date + public let context: [String: AnyCodable] + + public init(name: String, surfaceId: String, sourceComponentId: String, timestamp: Date = Date(), context: [String: AnyCodable] = [:]) { + self.name = name + self.surfaceId = surfaceId + self.sourceComponentId = sourceComponentId + self.timestamp = timestamp + self.context = context + } +} diff --git a/renderers/swift/Sources/A2UI/Rendering/A2UIComponentRenderer.swift b/renderers/swift/Sources/A2UI/Rendering/A2UIComponentRenderer.swift new file mode 100644 index 000000000..67a0dc767 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Rendering/A2UIComponentRenderer.swift @@ -0,0 +1,120 @@ +import SwiftUI + +/// A internal view that resolves a component ID and renders the appropriate SwiftUI view. +struct A2UIComponentRenderer: View { + @Environment(SurfaceState.self) var surface: SurfaceState? + let componentId: String + let surfaceOverride: SurfaceState? + + init(componentId: String, surface: SurfaceState? = nil) { + self.componentId = componentId + self.surfaceOverride = surface + } + + private var activeSurface: SurfaceState? { + surfaceOverride ?? surface + } + + var body: some View { + Group { + if let surface = activeSurface { + renderContent(surface: surface) + } else { + Text("Error: No SurfaceState available").foregroundColor(.red) + } + } + } + + @ViewBuilder + private func renderContent(surface: SurfaceState) -> some View { + let (instance, contextSurface) = resolveInstanceAndContext(surface: surface) + let finalSurface = contextSurface ?? surface + + if let instance = instance { + let _ = A2UILogger.debug("Rendering component: \(componentId) (\(instance.componentTypeName))") + render(instance: instance, surface: finalSurface) + .environment(finalSurface) + } else { + let _ = A2UILogger.error("Missing component: \(componentId)") + // Fallback for missing components to help debugging + Text("Missing: \(componentId)") + .foregroundColor(.white) + .padding(4) + .background(Color.red) + .font(.caption) + } + } + + private func resolveInstanceAndContext(surface: SurfaceState) -> (instance: ComponentInstance?, contextSurface: SurfaceState?) { + let virtualIdParts = componentId.split(separator: ":") + + // Check if it's a virtual ID from a template: "templateId:dataBinding:index" + if virtualIdParts.count == 3 { + let baseId = String(virtualIdParts[0]) + let dataBinding = String(virtualIdParts[1]) + let indexStr = String(virtualIdParts[2]) + + guard let instance = surface.components[baseId], let index = Int(indexStr) else { + return (nil, nil) + } + + // The data for the specific item in the array + let itemPath = "\(dataBinding)/\(index)" + if let itemData = surface.getValue(at: itemPath) as? [String: Any] { + // This is a contextual surface state scoped to the item's data. + let contextualSurface = SurfaceState(id: surface.id) + contextualSurface.dataModel = itemData + // Carry over the other essential properties from the main surface. + contextualSurface.components = surface.components + contextualSurface.customRenderers = surface.customRenderers + contextualSurface.actionHandler = surface.actionHandler + + return (instance, contextualSurface) + } + + // Return base instance but no special context if data is missing + return (instance, nil) + + } else { + // This is a regular component, not part of a template. + // Return the component instance and no special context surface. + if let component = surface.components[componentId] { + return (component, nil) + } else { + A2UILogger.error("Component not found in surface: \(componentId)") + return (nil, nil) + } + } + } + + @ViewBuilder + private func render(instance: ComponentInstance, surface: SurfaceState) -> some View { + let content = Group { + // Check for custom registered components first + if let customRenderer = surface.customRenderers[instance.componentTypeName] { + customRenderer(instance) + } else { + A2UIStandardComponentView(surface: surface, instance: instance) + } + } + + let showDebugBorders = ProcessInfo.processInfo.environment["A2UI_DEBUG_BORDERS"] == "true" + if showDebugBorders { + content + .border(debugColor(for: instance.componentTypeName), width: 1) + } else { + content + } + } + + private func debugColor(for typeName: String) -> Color { + switch typeName { + case "Column": return .blue + case "Row": return .green + case "Card": return .purple + case "Text": return .red + case "Button": return .orange + default: return .gray + } + } +} diff --git a/renderers/swift/Sources/A2UI/Rendering/A2UIStandardComponentView.swift b/renderers/swift/Sources/A2UI/Rendering/A2UIStandardComponentView.swift new file mode 100644 index 000000000..70b7a30ce --- /dev/null +++ b/renderers/swift/Sources/A2UI/Rendering/A2UIStandardComponentView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +/// A view that maps a standard A2UI component instance to its SwiftUI implementation. +struct A2UIStandardComponentView: View { + @Environment(SurfaceState.self) var surfaceEnv: SurfaceState? + var surface: SurfaceState? + let instance: ComponentInstance + + private var activeSurface: SurfaceState? { surface ?? surfaceEnv } + + var body: some View { + switch instance.component { + case .text(let props): + A2UITextView(surface: activeSurface, properties: props) + case .button(let props): + A2UIButtonView(id: instance.id, properties: props, checks: instance.checks, surface: activeSurface) + case .row(let props): + A2UIRowView(properties: props, surface: activeSurface) + case .column(let props): + A2UIColumnView(properties: props, surface: activeSurface) + case .card(let props): + A2UICardView(properties: props) + case .image(let props): + A2UIImageView(properties: props) + case .icon(let props): + A2UIIconView(properties: props, surface: activeSurface) + case .video(let props): + A2UIVideoView(properties: props) + case .audioPlayer(let props): + A2UIAudioPlayerView(properties: props) + case .divider(let props): + A2UIDividerView(surface: activeSurface, properties: props) + case .list(let props): + A2UIListView(properties: props, surface: activeSurface) + case .tabs(let props): + A2UITabsView(properties: props) + case .modal(let props): + A2UIModalView(properties: props) + case .textField(let props): + A2UITextFieldView(id: instance.id, properties: props, surface: activeSurface) + case .checkBox(let props): + A2UICheckBoxView(id: instance.id, properties: props) + case .dateTimeInput(let props): + A2UIDateTimeInputView(id: instance.id, properties: props, surface: activeSurface) + case .choicePicker(let props): + A2UIChoicePickerView(id: instance.id, properties: props, surface: activeSurface) + case .slider(let props): + A2UISliderView(id: instance.id, properties: props) + case .custom: + // Custom components should have been handled by the customRenderer check in A2UIComponentRenderer. + // If we're here, no custom renderer was found. + Text("Unknown Custom Component: \(instance.componentTypeName)") + .foregroundColor(.red) + } + } +} diff --git a/renderers/swift/Sources/A2UI/Surface/A2UISurfaceView.swift b/renderers/swift/Sources/A2UI/Surface/A2UISurfaceView.swift new file mode 100644 index 000000000..4b95aa812 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Surface/A2UISurfaceView.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// A view that renders an A2UI surface by its ID. +public struct A2UISurfaceView: View { + @Environment(A2UIDataStore.self) var dataStoreEnv: A2UIDataStore? + var dataStore: A2UIDataStore? + public let surfaceId: String + + private var activeDataStore: A2UIDataStore? { dataStore ?? dataStoreEnv } + + public init(surfaceId: String, dataStore: A2UIDataStore? = nil) { + self.surfaceId = surfaceId + self.dataStore = dataStore + } + + @ViewBuilder + public var body: some View { + if let surface = activeDataStore?.surfaces[surfaceId], surface.isReady { + if let rootId = surface.rootComponentId { + A2UIComponentRenderer(componentId: rootId, surface: surface) + .environment(surface) + } else { + Text("Surface ready but no root component found.") + } + } else { + EmptyView() + } + } +} diff --git a/renderers/swift/Sources/A2UI/Surface/SurfaceState.swift b/renderers/swift/Sources/A2UI/Surface/SurfaceState.swift new file mode 100644 index 000000000..6e970b428 --- /dev/null +++ b/renderers/swift/Sources/A2UI/Surface/SurfaceState.swift @@ -0,0 +1,209 @@ +import Foundation +import SwiftUI + +/// Represents the live state of a single UI surface. +@MainActor @Observable public class SurfaceState: Identifiable, Sendable { + public let id: String + public var isReady: Bool = false + public var rootComponentId: String? + public var components: [String: ComponentInstance] = [:] + public var dataModel: [String: Any] = [:] + public var validationErrors: [String: String] = [:] + + public var customRenderers: [String: @MainActor (ComponentInstance) -> AnyView] = [:] + public var customFunctions: [String: @MainActor ([String: Any], SurfaceState) -> Any?] = [:] + + var actionHandler: ((UserAction) -> Void)? + + public init(id: String) { + self.id = id + } + + public func resolve(_ boundValue: BoundValue?) -> T? { + guard let boundValue = boundValue else { return nil } + return resolve(boundValue) + } + + public func resolve(_ boundValue: BoundValue) -> T? { + if let functionCall = boundValue.functionCall { + let result = A2UIStandardFunctions.evaluate(call: functionCall, surface: self) + return convert(result) + } + + if let path = boundValue.path { + let value = getValue(at: path) + return convert(value) + } + + return boundValue.literal + } + + private func convert(_ value: Any?) -> T? { + if value == nil || value is NSNull { return nil } + + // Special handling for String conversion + if T.self == String.self { + if let stringValue = value as? String { + return stringValue as? T + } else if let intValue = value as? Int { + return String(intValue) as? T + } else if let doubleValue = value as? Double { + // Format appropriately, maybe avoid trailing zeros if it's an integer-like double + return String(format: "%g", doubleValue) as? T + } else if let boolValue = value as? Bool { + return String(boolValue) as? T + } else if value != nil { + return String(describing: value!) as? T + } + } + + if let tValue = value as? T { + return tValue + } + + // Numeric conversions + if T.self == Double.self, let intValue = value as? Int { + return Double(intValue) as? T + } + if T.self == Int.self, let doubleValue = value as? Double { + return Int(doubleValue.rounded()) as? T + } + + return nil + } + + public func getValue(at path: String) -> Any? { + let cleanPath = path.hasPrefix("/") ? String(path.dropFirst()) : path + let normalizedPath = cleanPath.replacingOccurrences(of: ".", with: "/") + let parts = normalizedPath.split(separator: "/").map(String.init) + + var current: Any? = dataModel + for part in parts { + if let dict = current as? [String: Any] { + current = dict[part] + } else if let array = current as? [Any], let index = Int(part), index < array.count { + current = array[index] + } else { + return nil + } + } + return current + } + + public func runChecks(for componentId: String) { + guard let instance = components[componentId], let checks = instance.checks else { + validationErrors.removeValue(forKey: componentId) + return + } + + if let error = errorMessage(surface: self, checks: checks) { + validationErrors[componentId] = error + } else { + validationErrors.removeValue(forKey: componentId) + } + } + + public func setValue(at path: String, value: Any) { + let cleanPath = path.hasPrefix("/") ? String(path.dropFirst()) : path + let normalizedPath = cleanPath.replacingOccurrences(of: ".", with: "/") + let parts = normalizedPath.split(separator: "/").map(String.init) + let normalizedValue = normalize(value: value) + + guard !parts.isEmpty else { + if let dict = normalizedValue as? [String: Any] { + mergeRaw(dict, into: &dataModel) + } + return + } + + func update(dict: [String: Any], parts: [String], newValue: Any) -> [String: Any] { + var newDict = dict + let key = parts[0] + + if parts.count == 1 { + newDict[key] = newValue + } else { + let subDict = (dict[key] as? [String: Any]) ?? [:] + newDict[key] = update(dict: subDict, parts: Array(parts.dropFirst()), newValue: normalize(value: newValue)) + } + return newDict + } + + dataModel = update(dict: dataModel, parts: parts, newValue: normalizedValue) + } + + private func normalize(value: Any) -> Any { + if value is JSONNull { + return NSNull() + } + + if let dict = value as? [String: Sendable] { + var result: [String: Any] = [:] + for (key, entry) in dict { + result[key] = normalize(value: entry) + } + return result + } + + if let array = value as? [Sendable] { + return array.map { normalize(value: $0) } + } + + return value + } + + public func mergeRaw(_ source: [String: Any], into destination: inout [String: Any]) { + for (key, value) in source { + if let sourceDict = value as? [String: Any], + let destDict = destination[key] as? [String: Any] { + var newDest = destDict + mergeRaw(sourceDict, into: &newDest) + destination[key] = newDest + } else { + destination[key] = value + } + } + } + + public func trigger(action: Action, sourceComponentId: String) { + switch action { + case .event(let name, let context): + var resolvedContext: [String: AnyCodable] = [:] + if let context = context { + for (key, value) in context { + let resolvedValue = A2UIStandardFunctions.resolveDynamicValue(value.value, surface: self) + resolvedContext[key] = AnyCodable(A2UIStandardFunctions.makeSendable(resolvedValue ?? NSNull())) + } + } + let userAction = UserAction( + name: name, + surfaceId: id, + sourceComponentId: sourceComponentId, + timestamp: Date(), + context: resolvedContext + ) + actionHandler?(userAction) + + case .functionCall(let call): + _ = A2UIStandardFunctions.evaluate(call: call, surface: self) + } + } + + /// Internal trigger for data updates that don't come from the protocol Action. + public func triggerDataUpdate(path: String, value: Any) { + setValue(at: path, value: value) + } + + public func expandTemplate(template: Template) -> [String] { + guard let data = getValue(at: template.path) as? [Any] else { + return [] + } + + var generatedIds: [String] = [] + for (index, _) in data.enumerated() { + let virtualId = "\(template.componentId):\(template.path):\(index)" + generatedIds.append(virtualId) + } + return generatedIds + } +} diff --git a/renderers/swift/Tests/A2UITests/Components/Button/A2UIButtonPropertiesTests.swift b/renderers/swift/Tests/A2UITests/Components/Button/A2UIButtonPropertiesTests.swift new file mode 100644 index 000000000..63e639df5 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Components/Button/A2UIButtonPropertiesTests.swift @@ -0,0 +1,16 @@ +import Testing +@testable import A2UI + +struct A2UIButtonPropertiesTests { + @Test func buttonVariantId() { + #expect(ButtonVariant.primary.id == "primary") + #expect(ButtonVariant.borderless.id == "borderless") + } + + @Test func buttonPropertiesInit() { + let action = Action.event(name: "test", context: nil) + let props = ButtonProperties(child: "testChild", action: action, variant: .primary) + #expect(props.child == "testChild") + #expect(props.variant == .primary) + } +} diff --git a/renderers/swift/Tests/A2UITests/Components/ChoicePicker/A2UIChoicePickerPropertiesTests.swift b/renderers/swift/Tests/A2UITests/Components/ChoicePicker/A2UIChoicePickerPropertiesTests.swift new file mode 100644 index 000000000..012e6d1be --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Components/ChoicePicker/A2UIChoicePickerPropertiesTests.swift @@ -0,0 +1,23 @@ +import Testing +@testable import A2UI + +struct A2UIChoicePickerPropertiesTests { + @Test func choicePickerVariantId() { + #expect(ChoicePickerVariant.multipleSelection.id == "multipleSelection") + #expect(ChoicePickerVariant.mutuallyExclusive.id == "mutuallyExclusive") + } + + @Test func choicePickerPropertiesInit() { + let label = BoundValue(literal: "Test Label") + let options = [SelectionOption(label: BoundValue(literal: "Opt 1"), value: "opt1")] + let value = BoundValue<[String]>(literal: ["opt1"]) + + let props = ChoicePickerProperties(label: label, options: options, variant: .mutuallyExclusive, value: value) + + #expect(props.label?.literal == "Test Label") + #expect(props.options.count == 1) + #expect(props.options[0].value == "opt1") + #expect(props.variant == .mutuallyExclusive) + #expect(props.value.literal == ["opt1"]) + } +} diff --git a/renderers/swift/Tests/A2UITests/Components/Divider/A2UIDividerPropertiesTests.swift b/renderers/swift/Tests/A2UITests/Components/Divider/A2UIDividerPropertiesTests.swift new file mode 100644 index 000000000..7a20d6a8d --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Components/Divider/A2UIDividerPropertiesTests.swift @@ -0,0 +1,9 @@ +import Testing +@testable import A2UI + +struct A2UIDividerPropertiesTests { + @Test func dividerAxisId() { + #expect(DividerAxis.horizontal.id == "horizontal") + #expect(DividerAxis.vertical.id == "vertical") + } +} diff --git a/renderers/swift/Tests/A2UITests/Components/Image/A2UIImagePropertiesTests.swift b/renderers/swift/Tests/A2UITests/Components/Image/A2UIImagePropertiesTests.swift new file mode 100644 index 000000000..b45e919dd --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Components/Image/A2UIImagePropertiesTests.swift @@ -0,0 +1,14 @@ +import Testing +@testable import A2UI + +struct A2UIImagePropertiesTests { + @Test func imageVariantId() { + #expect(A2UIImageVariant.icon.id == "icon") + #expect(A2UIImageVariant.avatar.id == "avatar") + } + + @Test func imageFitId() { + #expect(A2UIImageFit.contain.id == "contain") + #expect(A2UIImageFit.cover.id == "cover") + } +} diff --git a/renderers/swift/Tests/A2UITests/Components/Shared/A2UIInputHelpersTests.swift b/renderers/swift/Tests/A2UITests/Components/Shared/A2UIInputHelpersTests.swift new file mode 100644 index 000000000..0a664293d --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Components/Shared/A2UIInputHelpersTests.swift @@ -0,0 +1,36 @@ +import Testing +import SwiftUI +@testable import A2UI + +@MainActor +struct A2UIInputHelpersTests { + @Test func resolveValue() { + let surface = SurfaceState(id: "test") + let binding = BoundValue(literal: "hello") + let resolved = A2UI.resolveValue(surface, binding: binding) + #expect(resolved == "hello") + + let nilBinding: BoundValue? = nil + #expect(A2UI.resolveValue(surface, binding: nilBinding) == nil) + } + + @Test func updateBinding() { + let surface = SurfaceState(id: "test") + let binding = BoundValue(path: "testPath") + A2UI.updateBinding(surface: surface, binding: binding, newValue: "newValue") + #expect(surface.dataModel["testPath"] as? String == "newValue") + } + + @Test func errorMessage() { + let surface = SurfaceState(id: "test") + surface.dataModel["val"] = 5 + let check = CheckRule(condition: BoundValue(literal: false), message: "Fail") + + let message = A2UI.errorMessage(surface: surface, checks: [check]) + #expect(message == "Fail") + + let passCheck = CheckRule(condition: BoundValue(literal: true), message: "Pass") + let noMessage = A2UI.errorMessage(surface: surface, checks: [passCheck]) + #expect(noMessage == nil) + } +} diff --git a/renderers/swift/Tests/A2UITests/Components/Shared/ContainerPropertiesTests.swift b/renderers/swift/Tests/A2UITests/Components/Shared/ContainerPropertiesTests.swift new file mode 100644 index 000000000..4e97136f6 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Components/Shared/ContainerPropertiesTests.swift @@ -0,0 +1,30 @@ +import Testing +@testable import A2UI + +struct ContainerPropertiesTests { + @Test func resolvedJustify() { + let props = ContainerProperties(children: .list([]), justify: nil, align: nil) + #expect(props.resolvedJustify == .spaceBetween) + + let props2 = ContainerProperties(children: .list([]), justify: .center, align: nil) + #expect(props2.resolvedJustify == .center) + } + + @Test func resolvedAlign() { + let props = ContainerProperties(children: .list([]), justify: nil, align: nil) + #expect(props.resolvedAlign == .center) + + let props2 = ContainerProperties(children: .list([]), justify: nil, align: .start) + #expect(props2.resolvedAlign == .start) + } + + @Test func justifyId() { + #expect(A2UIJustify.center.id == "center") + #expect(A2UIJustify.spaceBetween.id == "spaceBetween") + } + + @Test func alignId() { + #expect(A2UIAlign.start.id == "start") + #expect(A2UIAlign.stretch.id == "stretch") + } +} diff --git a/renderers/swift/Tests/A2UITests/Components/Text/A2UITextPropertiesTests.swift b/renderers/swift/Tests/A2UITests/Components/Text/A2UITextPropertiesTests.swift new file mode 100644 index 000000000..820efef10 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Components/Text/A2UITextPropertiesTests.swift @@ -0,0 +1,9 @@ +import Testing +@testable import A2UI + +struct A2UITextPropertiesTests { + @Test func textVariantId() { + #expect(A2UITextVariant.h1.id == "h1") + #expect(A2UITextVariant.body.id == "body") + } +} diff --git a/renderers/swift/Tests/A2UITests/Components/TextField/A2UITextFieldPropertiesTests.swift b/renderers/swift/Tests/A2UITests/Components/TextField/A2UITextFieldPropertiesTests.swift new file mode 100644 index 000000000..14e66d614 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Components/TextField/A2UITextFieldPropertiesTests.swift @@ -0,0 +1,21 @@ +import Testing +@testable import A2UI + +struct A2UITextFieldPropertiesTests { + @Test func textFieldVariantId() { + #expect(TextFieldVariant.longText.id == "longText") + #expect(TextFieldVariant.number.id == "number") + #expect(TextFieldVariant.shortText.id == "shortText") + #expect(TextFieldVariant.obscured.id == "obscured") + } + + @Test func textFieldPropertiesInit() { + let label = BoundValue(literal: "Test Label") + let value = BoundValue(literal: "Test Value") + let props = TextFieldProperties(label: label, value: value, variant: .obscured) + + #expect(props.label.literal == "Test Label") + #expect(props.value?.literal == "Test Value") + #expect(props.variant == .obscured) + } +} diff --git a/renderers/swift/Tests/A2UITests/DataStore/A2UIDataStoreTests.swift b/renderers/swift/Tests/A2UITests/DataStore/A2UIDataStoreTests.swift new file mode 100644 index 000000000..ab6fd6531 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/DataStore/A2UIDataStoreTests.swift @@ -0,0 +1,242 @@ +import Testing +import Foundation +@testable import A2UI + +@MainActor +struct A2UIDataStoreTests { + private let store = A2UIDataStore() + + // MARK: - Surface Lifecycle + + @Test func surfaceCreationAndRetrieval() { + store.process(chunk: "{\"createSurface\":{\"surfaceId\":\"s1\",\"catalogId\":\"c1\"}}\n") + #expect(store.surfaces["s1"] != nil) + + let existingSurface = store.surfaces["s1"] + store.process(chunk: "{\"updateComponents\":{\"surfaceId\":\"s1\",\"components\":[]}}\n") + #expect(store.surfaces["s1"] === existingSurface) + } + + @Test func surfaceDeletion() { + store.process(chunk: "{\"createSurface\":{\"surfaceId\":\"s1\",\"catalogId\":\"c1\"}}\n") + #expect(store.surfaces["s1"] != nil) + + store.process(chunk: "{\"deleteSurface\":{\"surfaceId\":\"s1\"}}\n") + #expect(store.surfaces["s1"] == nil) + } + + // MARK: - Message Processing + + @Test func surfaceUpdateProcessing() { + let json = "{\"updateComponents\": {\"surfaceId\": \"s1\", \"components\": [{\"id\": \"c1\", \"component\": {\"Text\": {\"text\": \"Hello\"}}}]}}\n" + store.process(chunk: json) + + let surface = store.surfaces["s1"] + #expect(surface?.components.count == 1) + #expect(surface?.components["c1"] != nil) + } + + @Test func dataModelUpdateMerging() { + let surface = SurfaceState(id: "s1") + surface.dataModel = [ + "name": "initial", + "user": [ "profile": [:] ], + "items": [] + ] + store.surfaces["s1"] = surface + + let json = "{\"updateDataModel\": {\"surfaceId\": \"s1\", \"value\": {\"name\":\"Alice\",\"age\":30,\"isMember\":true}}}\n" + store.process(chunk: json) + + let model = store.surfaces["s1"]?.dataModel + #expect(model?["name"] as? String == "Alice") + #expect(model?["age"] as? Double == 30) + #expect(model?["isMember"] as? Bool == true) + + // Test deep update + let deepUpdateJson = "{\"updateDataModel\": {\"surfaceId\": \"s1\", \"path\": \"/user/profile\", \"value\": {\"name\": \"Bob\"}}}\n" + store.process(chunk: deepUpdateJson) + #expect(surface.getValue(at: "user/profile/name") as? String == "Bob") + + // Test array update + let listJson = "{\"updateDataModel\": {\"surfaceId\": \"s1\", \"path\": \"/items\", \"value\": [\"item1\"]}}\n" + store.process(chunk: listJson) + #expect(surface.getValue(at: "items/0") as? String == "item1") + } + + @Test func userActionTrigger() async { + let surface = SurfaceState(id: "s1") + + await confirmation("Action triggered") { confirmed in + surface.actionHandler = { userAction in + #expect(userAction.name == "submit") + #expect(userAction.sourceComponentId == "b1") + confirmed() + } + + surface.trigger(action: .event(name: "submit", context: nil), sourceComponentId: "b1") + } + } + + @Test func dataStoreProcessChunkWithSplitMessages() { + var chunk = "{\"deleteSurface\":{\"surfaceId\":\"s1\"}}\n{\"createSurface" + store.process(chunk: chunk) + #expect(store.surfaces["s2"] == nil) // Partial message + + chunk = "\":{\"surfaceId\":\"s2\",\"catalogId\":\"c1\"}}\n" + store.process(chunk: chunk) + #expect(store.surfaces["s2"] != nil) + } + + @Test func dataStoreFlushWithCreate() { + let surfaceId = "flush_create" + let chunk = "{\"createSurface\":{\"surfaceId\":\"\(surfaceId)\",\"catalogId\":\"c1\"}}" + + store.process(chunk: chunk) + #expect(store.surfaces[surfaceId] == nil) + + store.flush() + #expect(store.surfaces[surfaceId] != nil) + } + + @Test func dataStoreFlushWithDelete() { + let surfaceId = "flush_delete" + // First create it + store.process(chunk: "{\"createSurface\":{\"surfaceId\":\"\(surfaceId)\",\"catalogId\":\"c1\"}}\n") + #expect(store.surfaces[surfaceId] != nil) + store.flush() + #expect(store.surfaces[surfaceId] != nil) + + // Then send a partial delete + let chunk = "{\"deleteSurface\":{\"surfaceId\":\"\(surfaceId)\"}}" + store.process(chunk: chunk) + #expect(store.surfaces[surfaceId] != nil) // Still there + + store.flush() + #expect(store.surfaces[surfaceId] == nil) // Now deleted + } + + @Test func fallbackRootComponent() { + let json = "{\"updateComponents\": {\"surfaceId\": \"s1\", \"components\": [{\"id\": \"c1\", \"component\": {\"Text\": {\"text\": \"Hello\"}}}]}}\n" + store.process(chunk: json) + #expect(store.surfaces["s1"]?.rootComponentId == "c1") + } + + @Test func explicitRootComponent() { + let json = "{\"updateComponents\": {\"surfaceId\": \"s1\", \"components\": [{\"id\": \"c1\", \"component\": {\"Text\": {\"text\": \"Hello\"}}}, {\"id\": \"root\", \"component\": {\"Row\": {\"children\": [\"c1\"]}}}]}}\n" + store.process(chunk: json) + #expect(store.surfaces["s1"]?.rootComponentId == "root") + } + + @Test func appMessageProcessing() async { + await confirmation("App message handled") { confirmed in + store.appMessageHandler = { name, data in + #expect(name == "my_event") + let payload = data[name]?.value as? [String: Any] + #expect(payload?["foo"] as? String == "bar") + confirmed() + } + let json = "{\"my_event\": {\"foo\": \"bar\"}}\n" + store.process(chunk: json) + } + } + + @Test func textMessageProcessing() async { + await confirmation("Text message received") { confirmed in + store.onTextMessageReceived = { text in + #expect(text == "hello world") + confirmed() + } + // store.process(chunk: "{\"text\": \"hello world\"}\n") + // Wait, I need a valid app message name and a string payload. + let json = "{\"text\": \"hello world\"}\n" + store.process(chunk: json) + } + } + + @Test func dataUpdateActionHandling() { + let surfaceId = "s1" + store.process(chunk: "{\"createSurface\":{\"surfaceId\":\"\(surfaceId)\",\"catalogId\":\"c1\"}}\n") + let surface = store.surfaces[surfaceId]! + + surface.triggerDataUpdate(path: "val", value: "new") + #expect(surface.dataModel["val"] as? String == "new") + } + + @Test func functionCallActionHandling() { + let surfaceId = "s1" + store.process(chunk: "{\"createSurface\":{\"surfaceId\":\"\(surfaceId)\",\"catalogId\":\"c1\"}}\n") + let surface = store.surfaces[surfaceId]! + + // Use a function call that might be handled. + // Even if it doesn't do much, it should exercise the code path. + let call = FunctionCall(call: "formatString", args: ["value": AnyCodable("test")]) + surface.trigger(action: .functionCall(call), sourceComponentId: "b1") + } + + // MARK: - SurfaceState Deep Dive + + @Test func surfaceStateResolve() { + let surface = SurfaceState(id: "s1") + surface.dataModel = [ + "str": "hello", + "int": 42, + "double": 3.14, + "bool": true, + "null": NSNull(), + "nested": ["key": "val"] + ] + + // Literal + #expect(surface.resolve(BoundValue(literal: "lit")) == "lit") + + // Path resolution and conversion + #expect(surface.resolve(BoundValue(path: "str")) == "hello") + #expect(surface.resolve(BoundValue(path: "int")) == "42") + #expect(surface.resolve(BoundValue(path: "double")) == "3.14") + #expect(surface.resolve(BoundValue(path: "bool")) == "true") + + #expect(surface.resolve(BoundValue(path: "int")) == 42) + #expect(surface.resolve(BoundValue(path: "int")) == 42.0) + #expect(surface.resolve(BoundValue(path: "double")) == 3) + #expect(surface.resolve(BoundValue(path: "double")) == 3.14) + + // Optional BoundValue + let optionalBound: BoundValue? = nil + #expect(surface.resolve(optionalBound) == nil) + let presentBound: BoundValue? = BoundValue(literal: "present") + #expect(surface.resolve(presentBound) == "present") + + // Function Call (minimal test here, A2UIFunctionTests covers more) + let call = FunctionCall(call: "pluralize", args: ["value": AnyCodable(1), "one": AnyCodable("1 apple"), "other": AnyCodable("apples")]) + #expect(surface.resolve(BoundValue(functionCall: call)) == "1 apple") + } + + @Test func surfaceStateRunChecks() { + let surface = SurfaceState(id: "s1") + let check = CheckRule(condition: BoundValue(path: "isValid"), message: "Invalid Value") + surface.components["c1"] = ComponentInstance(id: "c1", checks: [check], component: .text(.init(text: .init(literal: ""), variant: nil))) + + surface.dataModel["isValid"] = false + surface.runChecks(for: "c1") + #expect(surface.validationErrors["c1"] == "Invalid Value") + + surface.dataModel["isValid"] = true + surface.runChecks(for: "c1") + #expect(surface.validationErrors["c1"] == nil) + + surface.runChecks(for: "missing") // Should not crash + } + + @Test func surfaceStateExpandTemplate() { + let surface = SurfaceState(id: "s1") + surface.dataModel["items"] = ["a", "b", "c"] + + let template = Template(componentId: "row", path: "items") + let ids = surface.expandTemplate(template: template) + #expect(ids.count == 3) + #expect(ids[0] == "row:items:0") + + #expect(surface.expandTemplate(template: Template(componentId: "row", path: "missing")).isEmpty) + } +} diff --git a/renderers/swift/Tests/A2UITests/DataStore/A2UIExtensibilityTests.swift b/renderers/swift/Tests/A2UITests/DataStore/A2UIExtensibilityTests.swift new file mode 100644 index 000000000..7593d8680 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/DataStore/A2UIExtensibilityTests.swift @@ -0,0 +1,60 @@ +import Testing +import SwiftUI +@testable import A2UI + +@MainActor +struct A2UIExtensibilityTests { + private let store = A2UIDataStore() + + @Test func customComponentDecoding() { + store.process(chunk: "{\"createSurface\":{\"surfaceId\":\"s1\",\"catalogId\":\"c1\"}}\n") + let json = "{\"updateComponents\":{\"surfaceId\":\"s1\",\"components\":[{\"id\":\"c1\",\"component\":{\"ChatSurface\":{\"historyPath\":\"/app/history\"}}}]}}" + + // Process as chunk (with newline for parser) + store.process(chunk: json + "\n") + + let surface = store.surfaces["s1"] + #expect(surface != nil) + + let component = surface?.components["c1"] + #expect(component != nil) + + // Verify it was captured as a custom component + if case .custom(let name, let properties) = component?.component { + #expect(name == "ChatSurface") + #expect(properties["historyPath"]?.value as? String == "/app/history") + } else { + Issue.record("Component should have been decoded as .custom") + } + + // Verify helper property + #expect(component?.component.typeName == "ChatSurface") + } + + @Test func customRendererRegistry() async { + await confirmation("Custom renderer called") { confirmed in + // Register a mock custom renderer + store.customRenderers["ChatSurface"] = { instance in + #expect(instance.id == "c1") + confirmed() + return AnyView(Text("Mock Chat")) + } + + // Simulate a message arriving + store.process(chunk: "{\"createSurface\":{\"surfaceId\":\"s1\",\"catalogId\":\"c1\"}}\n") + let json = "{\"updateComponents\":{\"surfaceId\":\"s1\",\"components\":[{\"id\":\"c1\",\"component\":{\"ChatSurface\":{\"historyPath\":\"/app/history\"}}}]}}" + store.process(chunk: json + "\n") + + // In a real app, A2UIComponentRenderer would call this. + // We can verify the lookup manually here. + let surface = store.surfaces["s1"]! + let component = surface.components["c1"]! + + if let renderer = store.customRenderers[component.component.typeName] { + let _ = renderer(component) + } else { + Issue.record("Custom renderer not found in registry") + } + } + } +} diff --git a/renderers/swift/Tests/A2UITests/DataStore/A2UIParserTests.swift b/renderers/swift/Tests/A2UITests/DataStore/A2UIParserTests.swift new file mode 100644 index 000000000..a77eb8ccc --- /dev/null +++ b/renderers/swift/Tests/A2UITests/DataStore/A2UIParserTests.swift @@ -0,0 +1,369 @@ +import Testing +import Foundation +@testable import A2UI + +struct A2UIParserTests { + private let parser = A2UIParser() + + // MARK: - Root Message Parsing + + /// Verifies that a `createSurface` message is correctly decoded with all optional fields. + @Test func parseCreateSurface() throws { + let json = """ + { + "createSurface": { + "surfaceId": "s1", + "catalogId": "v08", + "theme": { "primaryColor": "#FF0000" } + } + } + """ + let messages = try parser.parse(line: json) + + let firstMessage = try #require(messages.first) + if case .createSurface(let value) = firstMessage { + #expect(value.surfaceId == "s1") + #expect(value.catalogId == "v08") + #expect(value.theme?["primaryColor"]?.value as? String == "#FF0000") + } else { + Issue.record("Failed to decode createSurface") + } + } + + /// Verifies that a `deleteSurface` message is correctly decoded. + @Test func parseDeleteSurface() throws { + let json = "{\"deleteSurface\": {\"surfaceId\": \"s1\"}}" + let messages = try parser.parse(line: json) + + let firstMessage = try #require(messages.first) + if case .deleteSurface(let value) = firstMessage { + #expect(value.surfaceId == "s1") + } else { + Issue.record("Failed to decode deleteSurface") + } + } + + // MARK: - Component Type Parsing + + /// Verifies that all standard component types (Text, Button, Row, Column, Card) + /// are correctly decoded via the polymorphic `ComponentType` enum. + @Test func parseAllComponentTypes() throws { + let componentsJson = """ + { + "updateComponents": { + "surfaceId": "s1", + "components": [ + { "id": "t1", "component": { "Text": { "text": "Hello" } } }, + { "id": "b1", "component": { "Button": { "child": "t1", "action": { "event": { "name": "tap" } } } } }, + { "id": "r1", "component": { "Row": { "children": ["t1"] } } }, + { "id": "c1", "component": { "Column": { "children": ["b1"], "align": "center" } } }, + { "id": "card1", "component": { "Card": { "child": "r1" } } } + ] + } + } + """ + let messages = try parser.parse(line: componentsJson) + + let firstMessage = try #require(messages.first) + guard case .surfaceUpdate(let update) = firstMessage else { + Issue.record("Expected surfaceUpdate") + return + } + + #expect(update.components.count == 5) + + // Check Row + if case .row(let props) = update.components[2].component { + if case .list(let list) = props.children { + #expect(list == ["t1"]) + } else { Issue.record("Expected list children") } + } else { Issue.record("Type mismatch for row") } + + // Check Column Alignment + if case .column(let props) = update.components[3].component { + #expect(props.align == .center) + } else { Issue.record("Type mismatch for column") } + } + + // MARK: - Data Binding & Logic + + /// Verifies that `BoundValue` correctly handles literal strings, literal numbers, + /// literal booleans, and data model paths. + @Test func boundValueVariants() throws { + let json = """ + { + "updateComponents": { + "surfaceId": "s1", + "components": [ + { "id": "t1", "component": { "Text": { "text": { "path": "/user/name" } } } }, + { "id": "t2", "component": { "Text": { "text": "Literal" } } } + ] + } + } + """ + let messages = try parser.parse(line: json) + + let firstMessage = try #require(messages.first) + guard case .surfaceUpdate(let update) = firstMessage else { return } + + if case .text(let props) = update.components[0].component { + #expect(props.text.path == "/user/name") + #expect(props.text.literal == nil) + } + + if case .text(let props) = update.components[1].component { + #expect(props.text.literal == "Literal") + #expect(props.text.path == nil) + } + } + + // MARK: - Error Handling & Edge Cases + + /// Verifies that the parser decodes unknown component types as .custom instead of throwing. + @Test func parseUnknownComponent() throws { + let json = "{\"updateComponents\": {\"surfaceId\": \"s1\", \"components\": [{\"id\": \"1\", \"component\": {\"Unknown\": {\"foo\":\"bar\"}}}]}}" + let messages = try parser.parse(line: json) + + let firstMessage = try #require(messages.first) + if case .surfaceUpdate(let update) = firstMessage, + case .custom(let name, let props) = update.components.first?.component { + #expect(name == "Unknown") + #expect(props["foo"]?.value as? String == "bar") + } else { + Issue.record("Should have decoded as .custom component") + } + } + + /// Verifies that the parser can handle multiple JSON objects on a single line, + /// even if separated by commas (common in some non-standard JSONL producers). + @Test func parseCommaSeparatedObjectsOnOneLine() throws { + let json = """ + {"updateDataModel":{"surfaceId":"s1"}},{"updateComponents":{"surfaceId":"s1","components":[]}} + """ + let messages = try parser.parse(line: json) + #expect(messages.count == 2) + + if case .dataModelUpdate = messages[0] {} else { Issue.record("First message should be dataModelUpdate") } + if case .surfaceUpdate = messages[1] {} else { Issue.record("Second message should be surfaceUpdate") } + } + + /// Verifies that the parser correctly returns an empty array for empty lines in a JSONL stream. + @Test func parseEmptyLine() throws { + #expect(try parser.parse(line: "").isEmpty) + #expect(try parser.parse(line: " ").isEmpty) + #expect(try parser.parse(line: "\n").isEmpty) + } + + @Test func parseArrayDirectly() throws { + let json = "[{\"deleteSurface\":{\"surfaceId\":\"s1\"}},{\"deleteSurface\":{\"surfaceId\":\"s2\"}}]" + let messages = try parser.parse(line: json) + #expect(messages.count == 2) + } + + @Test func parseInvalidJson() throws { + #expect(throws: (any Error).self) { + try parser.parse(line: "not json") + } + } + + @Test func parseChunkWithError() throws { + var remainder = "" + let chunk = "{\"deleteSurface\":{\"surfaceId\":\"1\"}}\ninvalid json\n{\"deleteSurface\":{\"surfaceId\":\"2\"}}\n" + let messages = parser.parse(chunk: chunk, remainder: &remainder) + #expect(messages.count == 2) + #expect(remainder.isEmpty) + } + + @Test func parseMultipleLinesInChunk() throws { + var remainder = "" + let chunk = "{\"deleteSurface\":{\"surfaceId\":\"1\"}}\n{\"deleteSurface\":{\"surfaceId\":\"2\"}}\n" + let messages = parser.parse(chunk: chunk, remainder: &remainder) + #expect(messages.count == 2) + } + + // MARK: - Children Compatibility Tests + + @Test func childrenDirectArray() throws { + let json = """ + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { "id": "r1", "component": { "Row": { "children": ["t1", "t2"] } } } + ] + } + } + """ + let messages = try parser.parse(line: json) + + let firstMessage = try #require(messages.first) + guard case .surfaceUpdate(let update) = firstMessage else { + Issue.record("Expected surfaceUpdate") + return + } + + if case .row(let props) = update.components[0].component { + if case .list(let list) = props.children { + #expect(list == ["t1", "t2"]) + } else { + Issue.record("Expected .list") + } + } else { + Issue.record("Expected .row") + } + } + + @Test func childrenLegacyExplicitList() throws { + let json = """ + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { "id": "r1", "component": { "Row": { "children": { "explicitList": ["t1", "t2"] } } } } + ] + } + } + """ + let messages = try parser.parse(line: json) + + let firstMessage = try #require(messages.first) + guard case .surfaceUpdate(let update) = firstMessage else { + Issue.record("Expected surfaceUpdate") + return + } + + if case .row(let props) = update.components[0].component { + if case .list(let list) = props.children { + #expect(list == ["t1", "t2"]) + } else { + Issue.record("Expected .list") + } + } else { + Issue.record("Expected .row") + } + } + + @Test func childrenTemplate() throws { + let json = """ + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { "id": "r1", "component": { "Row": { "children": { "componentId": "tpl", "path": "/items" } } } } + ] + } + } + """ + let messages = try parser.parse(line: json) + + let firstMessage = try #require(messages.first) + guard case .surfaceUpdate(let update) = firstMessage else { + Issue.record("Expected surfaceUpdate") + return + } + + if case .row(let props) = update.components[0].component { + if case .template(let template) = props.children { + #expect(template.componentId == "tpl") + #expect(template.path == "/items") + } else { + Issue.record("Expected .template") + } + } else { + Issue.record("Expected .row") + } + } + + // MARK: - Helper Utility Tests + + /// Verifies that the `AnyCodable` helper correctly handles various JSON types + /// (String, Double, Bool, Dictionary) without data loss. + @Test func anyCodable() throws { + let dict: [String: Sendable] = ["s": "str", "n": 1.0, "b": true] + let anyCodable = AnyCodable(dict) + + let encoded = try JSONEncoder().encode(anyCodable) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: encoded) + + let decodedDict = decoded.value as? [String: Sendable] + #expect(decodedDict?["s"] as? String == "str") + #expect(decodedDict?["n"] as? Double == 1.0) + #expect(decodedDict?["b"] as? Bool == true) + } + + /// Verifies that an A2UIMessage can be encoded back to JSON and re-decoded + /// without loss of information (Symmetric Serialization). + @Test func symmetricEncoding() throws { + let originalJson = "{\"deleteSurface\":{\"surfaceId\":\"s1\"}}" + let messages = try parser.parse(line: originalJson) + let message = try #require(messages.first) + + let encoder = JSONEncoder() + let encodedData = try encoder.encode(message) + let decodedMessage = try JSONDecoder().decode(A2UIMessage.self, from: encodedData) + + if case .deleteSurface(let value) = decodedMessage { + #expect(value.surfaceId == "s1") + } else { + Issue.record() + } + } + + /// Verifies that all component types can be encoded and decoded without loss. + @Test func symmetricComponentEncoding() throws { + let action = Action.event(name: "testAction", context: nil) + let boundStr = BoundValue(literal: "test") + let boundBool = BoundValue(literal: true) + let boundNum = BoundValue(literal: 42) + let children = Children.list(["c1"]) + + let components: [ComponentType] = [ + .text(.init(text: boundStr, variant: .h1)), + .button(.init(child: "C", action: action, variant: .primary)), + .row(.init(children: children, justify: .stretch, align: .center)), + .column(.init(children: children, justify: .start, align: .start)), + .card(.init(child: "C")), + .image(.init(url: boundStr, fit: .cover, variant: nil)), + .icon(.init(name: boundStr)), + .video(.init(url: boundStr)), + .audioPlayer(.init(url: boundStr, description: nil)), + .divider(.init(axis: .horizontal)), + .list(.init(children: children, direction: "vertical", align: nil)), + .tabs(.init(tabs: [TabItem(title: boundStr, child: "c1")])), + .textField(.init(label: boundStr, value: boundStr, variant: .shortText)), + .checkBox(.init(label: boundStr, value: boundBool)), + .slider(.init(label: boundStr, min: 0, max: 100, value: boundNum)), + .custom("CustomComp", ["key": AnyCodable("val")]) + ] + + for comp in components { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let encoded = try encoder.encode(comp) + + let decoded = try JSONDecoder().decode(ComponentType.self, from: encoded) + #expect(comp.typeName == decoded.typeName) + + // Re-encode decoded to ensure symmetry + let reEncoded = try encoder.encode(decoded) + #expect(encoded == reEncoded) + } + } + + /// Verifies that the streaming logic correctly handles split lines across multiple chunks. + @Test func streamingRemainderLogic() { + var remainder = "" + let chunk = "{\"deleteSurface\":{\"surfaceId\":\"1\"}}\n{\"beginRe" + var messages = parser.parse(chunk: chunk, remainder: &remainder) + + #expect(messages.count == 1) + #expect(remainder == "{\"beginRe") + + messages = parser.parse(chunk: "ndering\":{\"surfaceId\":\"1\",\"root\":\"r\"}}\n", remainder: &remainder) + #expect(messages.count == 1) + #expect(remainder == "") + } +} diff --git a/renderers/swift/Tests/A2UITests/DataStore/A2UIV9Tests.swift b/renderers/swift/Tests/A2UITests/DataStore/A2UIV9Tests.swift new file mode 100644 index 000000000..d30a5216f --- /dev/null +++ b/renderers/swift/Tests/A2UITests/DataStore/A2UIV9Tests.swift @@ -0,0 +1,200 @@ +import Testing +import Foundation +@testable import A2UI + +struct A2UIV9Tests { + private let parser = A2UIParser() + + @Test func parseCreateSurface() throws { + let json = """ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "s1", + "catalogId": "test.catalog", + "theme": { "primaryColor": "#FF0000" }, + "sendDataModel": true + } + } + """ + let messages = try parser.parse(line: json) + + let firstMessage = try #require(messages.first) + guard case .createSurface(let value) = firstMessage else { + Issue.record("Failed to decode createSurface") + return + } + + #expect(value.surfaceId == "s1") + #expect(value.catalogId == "test.catalog") + #expect(value.theme?["primaryColor"]?.value as? String == "#FF0000") + #expect(value.sendDataModel == true) + } + + @Test func parseUpdateComponents() throws { + let json = """ + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "root", + "component": "Text", + "text": "Hello", + "variant": "h1" + } + ] + } + } + """ + let messages = try parser.parse(line: json) + + let firstMessage = try #require(messages.first) + guard case .surfaceUpdate(let update) = firstMessage else { + Issue.record("Expected surfaceUpdate") + return + } + + #expect(update.surfaceId == "s1") + #expect(update.components.count == 1) + if case .text(let props) = update.components[0].component { + #expect(props.variant == .h1) + #expect(props.text.literal == "Hello") + } else { + Issue.record("Component is not Text") + } + } + + @Test func parseUpdateDataModelWithValue() throws { + let json = """ + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "s1", + "path": "/user/name", + "value": "John Doe" + } + } + """ + let messages = try parser.parse(line: json) + + let firstMessage = try #require(messages.first) + guard case .dataModelUpdate(let update) = firstMessage else { + Issue.record("Expected dataModelUpdate") + return + } + + #expect(update.surfaceId == "s1") + #expect(update.path == "/user/name") + #expect(update.value?.value as? String == "John Doe") + } + + @Test func parseUpdateDataModelWithObjectValue() throws { + let json = """ + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "s1", + "path": "/user", + "value": { "firstName": "John", "lastName": "Doe" } + } + } + """ + let messages = try parser.parse(line: json) + + let firstMessage = try #require(messages.first) + guard case .dataModelUpdate(let update) = firstMessage else { + Issue.record("Expected dataModelUpdate") + return + } + + #expect(update.surfaceId == "s1") + #expect(update.path == "/user") + if let valueMap = update.value?.value as? [String: Sendable] { + #expect(valueMap["firstName"] as? String == "John") + #expect(valueMap["lastName"] as? String == "Doe") + } else { + Issue.record("Expected valueMap for object value") + } + } + + @Test func choicePickerParsing() throws { + let json = """ + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "s1", + "components": [ + { + "id": "cp1", + "component": "ChoicePicker", + "label": "Pick one", + "options": [ + { "label": "Option 1", "value": "1" }, + { "label": "Option 2", "value": "2" } + ], + "variant": "mutuallyExclusive", + "value": ["1"] + } + ] + } + } + """ + // Note: BoundValue<[String]> needs to handle array literal + let messages = try parser.parse(line: json) + + let firstMessage = try #require(messages.first) + guard case .surfaceUpdate(let update) = firstMessage else { + Issue.record() + return + } + + if case .choicePicker(let props) = update.components[0].component { + #expect(props.options.count == 2) + #expect(props.variant == .mutuallyExclusive) + } else { + Issue.record("Component is not ChoicePicker") + } + } + + @Test func parseUserReproWithNulls() throws { + // This test verifies that 'null' values in 'theme' (AnyCodable) don't crash the parser. + let json = """ + {"version":"v0.9","createSurface":{"surfaceId":"9EA1C0C3-4FAE-4FD2-BE58-5DD06F4A73F9","catalogId":"https://a2ui.org/specification/v0_9/standard_catalog.json","theme":{"primaryColor":"#F7931A","agentDisplayName":"BTC Tracker","iconUrl":null},"sendDataModel":true}} + """ + let messages = try parser.parse(line: json) + #expect(messages.count == 1) + + let firstMessage = try #require(messages.first) + guard case .createSurface(let value) = firstMessage else { + Issue.record("Failed to decode createSurface") + return + } + + #expect(value.surfaceId == "9EA1C0C3-4FAE-4FD2-BE58-5DD06F4A73F9") + #expect(value.theme?["iconUrl"]?.value is JSONNull) + } + + @Test func parseUserReproFlat() throws { + let json = """ + {"version":"v0.9","updateComponents":{"surfaceId":"63331743-99E8-44E9-8007-CFF5747F6033","components":[{"id":"card_root","component":"Card","child":"col_main","weight":1},{"id":"col_main","component":"Column","children":["header_text","price_display","meta_row","error_msg","refresh_btn"],"align":"center","justify":"start","weight":1},{"id":"header_text","component":"Text","text":"Bitcoin Price","variant":"h3","weight":0},{"id":"price_display","component":"Text","text":{"path":"/btc/currentPrice"},"variant":"h1","weight":0},{"id":"meta_row","component":"Row","children":["meta_label","meta_time"],"justify":"center","weight":0},{"id":"meta_label","component":"Text","text":"Last updated: ","variant":"caption","weight":0},{"id":"meta_time","component":"Text","text":{"path":"/btc/lastUpdated"},"variant":"caption","weight":0},{"id":"error_msg","component":"Text","text":{"path":"/btc/error"},"variant":"body","weight":0},{"id":"refresh_btn","component":"Button","child":"btn_label","action":{"functionCall":{"call":"refreshBTCPrice","args":{}}},"variant":"primary","weight":0},{"id":"btn_label","component":"Text","text":"Refresh","variant":"body","weight":1}]}} + """ + let messages = try parser.parse(line: json) + + let firstMessage = try #require(messages.first) + guard case .surfaceUpdate(let update) = firstMessage else { + Issue.record("Failed to decode surfaceUpdate") + return + } + + #expect(update.components.count == 10) + #expect(update.components[0].id == "card_root") + + if case .card(let props) = update.components[0].component { + #expect(props.child == "col_main") + } else { + Issue.record("First component should be Card") + } + } +} diff --git a/renderers/swift/Tests/A2UITests/Functions/A2UIFunctionEvaluatorTests.swift b/renderers/swift/Tests/A2UITests/Functions/A2UIFunctionEvaluatorTests.swift new file mode 100644 index 000000000..39384bb39 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Functions/A2UIFunctionEvaluatorTests.swift @@ -0,0 +1,148 @@ +import Foundation +import Testing +@testable import A2UI + +@MainActor +struct A2UIFunctionEvaluatorTests { + private let surface = SurfaceState(id: "test") + + @Test func nestedFunctionCall() async { + let innerCall: [String: Sendable] = [ + "call": "required", + "args": ["value": ""] + ] + let outerCall = FunctionCall.not(value: innerCall) + #expect(A2UIStandardFunctions.evaluate(call: outerCall, surface: surface) as? Bool == true) + } + + @Test func dataBindingInFunctionCall() async { + surface.setValue(at: "/test/val", value: "hello") + let binding: [String: Sendable] = ["path": "/test/val"] + let call = FunctionCall.required(value: binding) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == true) + } + + @Test func arrayResolutionInFunctionCall() async { + surface.setValue(at: "/test/bool1", value: true) + surface.setValue(at: "/test/bool2", value: false) + + let binding1: [String: Sendable] = ["path": "/test/bool1"] + let binding2: [String: Sendable] = ["path": "/test/bool2"] + + let call = FunctionCall.and(values: [binding1, binding2]) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + + surface.setValue(at: "/test/bool2", value: true) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == true) + } + + @Test func checkableLogic() async { + surface.setValue(at: "/email", value: "invalid") + let condition = BoundValue(functionCall: FunctionCall.email(value: ["path": "/email"])) + let check = CheckRule(condition: condition, message: "Invalid email") + + let error = errorMessage(surface: surface, checks: [check]) + #expect(error == "Invalid email") + + surface.setValue(at: "/email", value: "test@example.com") + let noError = errorMessage(surface: surface, checks: [check]) + #expect(noError == nil) + } + + @Test func missingOrInvalidFunctionsAndArguments() async { + let unknown = FunctionCall(call: "someRandomFunction") + #expect(A2UIStandardFunctions.evaluate(call: unknown, surface: surface) == nil) + + let reqInvalid = FunctionCall(call: "required") + #expect(A2UIStandardFunctions.evaluate(call: reqInvalid, surface: surface) as? Bool == false) + + let emailInvalid = FunctionCall(call: "email", args: ["value": AnyCodable(123)]) + #expect(A2UIStandardFunctions.evaluate(call: emailInvalid, surface: surface) as? Bool == false) + + let lenInvalid1 = FunctionCall(call: "length", args: ["value": AnyCodable(123), "min": AnyCodable(1)]) + #expect(A2UIStandardFunctions.evaluate(call: lenInvalid1, surface: surface) as? Bool == false) + + let lenInvalid2 = FunctionCall(call: "length", args: ["value": AnyCodable("123"), "min": AnyCodable("J")]) + #expect(A2UIStandardFunctions.evaluate(call: lenInvalid2, surface: surface) as? Bool == false) + + let numInvalid = FunctionCall(call: "numeric", args: ["value": AnyCodable(123)]) + #expect(A2UIStandardFunctions.evaluate(call: numInvalid, surface: surface) as? Bool == false) + + let andInvalid = FunctionCall(call: "and", args: ["values": AnyCodable(123)]) + #expect(A2UIStandardFunctions.evaluate(call: andInvalid, surface: surface) as? Bool == false) + + let orInvalid = FunctionCall(call: "or", args: ["values": AnyCodable([true] as [Sendable])]) + #expect(A2UIStandardFunctions.evaluate(call: orInvalid, surface: surface) as? Bool == false) + + let notInvalid = FunctionCall(call: "not", args: ["value": AnyCodable(123)]) + #expect(A2UIStandardFunctions.evaluate(call: notInvalid, surface: surface) as? Bool == false) + + let formatDateInvalid = FunctionCall(call: "formatDate", args: ["value": AnyCodable(123)]) + #expect(A2UIStandardFunctions.evaluate(call: formatDateInvalid, surface: surface) as? String == "") + } + + @Test func resolveDynamicValueEdgeCases() { + let arrVal: [Sendable] = [["path": "/test/val"] as [String: Sendable]] + surface.setValue(at: "/test/val", value: "resolved") + + let result = A2UIStandardFunctions.resolveDynamicValue(arrVal, surface: surface) as? [Any] + #expect(result?.first as? String == "resolved") + + let nullRes = A2UIStandardFunctions.resolveDynamicValue(NSNull(), surface: surface) as? NSNull + #expect(nullRes != nil) + + let nilRes = A2UIStandardFunctions.resolveDynamicValue(nil, surface: surface) as? NSNull + #expect(nilRes == nil) + + let nonDict = "not a dict" + let nonDictRes = A2UIStandardFunctions.resolveDynamicValue(nonDict, surface: surface) + #expect(nonDictRes as? String == nonDict) + + let arrayWithNonDict = ["string in array"] as [Sendable] + let arrayWithNonDictRes = A2UIStandardFunctions.resolveDynamicValue(arrayWithNonDict, surface: surface) as? [Any] + #expect(arrayWithNonDictRes?.first as? String == "string in array") + #expect(arrayWithNonDictRes?.count == 1) + } + + @Test func makeSendableTests() async { + // Literals + #expect(A2UIStandardFunctions.makeSendable("string") as? String == "string") + #expect(A2UIStandardFunctions.makeSendable(123) as? Int == 123) + #expect(A2UIStandardFunctions.makeSendable(123.45) as? Double == 123.45) + #expect(A2UIStandardFunctions.makeSendable(true) as? Bool == true) + + let date = Date() + #expect(A2UIStandardFunctions.makeSendable(date) as? Date == date) + + // NSNull and JSONNull + #expect(A2UIStandardFunctions.makeSendable(NSNull()) is JSONNull) + #expect(A2UIStandardFunctions.makeSendable(JSONNull()) is JSONNull) + + // Dictionaries + let dict: [String: Any] = ["key": "value", "num": 1] + let sendableDict = A2UIStandardFunctions.makeSendable(dict) as? [String: Sendable] + #expect(sendableDict?["key"] as? String == "value") + #expect(sendableDict?["num"] as? Int == 1) + + // Arrays + let array: [Any] = ["string", 1, true] + let sendableArray = A2UIStandardFunctions.makeSendable(array) as? [Sendable] + #expect(sendableArray?[0] as? String == "string") + #expect(sendableArray?[1] as? Int == 1) + #expect(sendableArray?[2] as? Bool == true) + + // Nested Structures + let nested: [String: Any] = [ + "arr": ["nested", ["inner": "dict"]] as [Any] + ] + let sendableNested = A2UIStandardFunctions.makeSendable(nested) as? [String: Sendable] + let nestedArr = sendableNested?["arr"] as? [Sendable] + #expect(nestedArr?[0] as? String == "nested") + let innerDict = nestedArr?[1] as? [String: Sendable] + #expect(innerDict?["inner"] as? String == "dict") + + // Fallback + struct Unsendable {} + #expect(A2UIStandardFunctions.makeSendable(Unsendable()) is JSONNull) + } +} diff --git a/renderers/swift/Tests/A2UITests/Functions/Formatting/FormatCurrencyTests.swift b/renderers/swift/Tests/A2UITests/Functions/Formatting/FormatCurrencyTests.swift new file mode 100644 index 000000000..275c6a736 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Functions/Formatting/FormatCurrencyTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing +@testable import A2UI + +@MainActor +struct FormatCurrencyTests { + private let surface = SurfaceState(id: "test") + + @Test func formatCurrency() throws { + let call = FunctionCall.formatCurrency(value: 1234.56, currency: "USD") + let result: String! = A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String + try #require(result != nil) + #expect(result.contains("$")) + #expect(result.contains("234")) + #expect(result.contains("56")) + } + + @Test func formatCurrencyEdgeCases() throws { + let call = FunctionCall.formatCurrency(value: 1234.56, currency: "GBP", decimals: 0, grouping: false) + let result: String! = A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String + try #require(result != nil) + #expect(result.contains("1235")) + #expect(result.contains("£")) + #expect(!result.contains("$")) + + let invalid = FunctionCall(call: "formatCurrency", args: ["value": AnyCodable("not-double")]) + #expect(A2UIStandardFunctions.evaluate(call: invalid, surface: surface) as? String == "") + } +} diff --git a/renderers/swift/Tests/A2UITests/Functions/Formatting/FormatDateTests.swift b/renderers/swift/Tests/A2UITests/Functions/Formatting/FormatDateTests.swift new file mode 100644 index 000000000..ae50019fa --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Functions/Formatting/FormatDateTests.swift @@ -0,0 +1,84 @@ +import Foundation +import Testing +@testable import A2UI + +@MainActor +struct FormatDateTests { + private let surface = SurfaceState(id: "test") + + /// Returns a fixed timestamp (2026-02-26 12:00:00) in the LOCAL timezone. + private func getLocalTimestamp() throws -> Double { + var components = DateComponents() + components.year = 2026 + components.month = 2 + components.day = 26 + components.hour = 12 + components.minute = 0 + components.second = 0 + let date: Date! = Calendar.current.date(from: components) + try #require(date != nil, "Failed to create date from components") + return date.timeIntervalSince1970 + } + + @Test func formatDate() throws { + let timestamp = try getLocalTimestamp() + let call = FunctionCall.formatDate(value: timestamp, format: "yyyy-MM-dd") + let result: String! = A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String + try #require(result != nil) + #expect(result == "2026-02-26") + } + + @Test func formatISO8601DateString() throws { + let timestamp = try getLocalTimestamp() + let date = Date(timeIntervalSince1970: timestamp) + let isoFormatter = ISO8601DateFormatter() + isoFormatter.timeZone = .current // Match system + let systemFormatted = isoFormatter.string(from: date) + + let call = FunctionCall.formatDate(value: systemFormatted, format: "yyyy-MM-dd") + let result: String! = A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String + try #require(result != nil) + #expect(result == "2026-02-26") + } + + @Test func formatNonStandardLongDateString() throws { + let timestamp = try getLocalTimestamp() + let date = Date(timeIntervalSince1970: timestamp) + let systemFormatted = date.formatted(date: .long, time: .omitted) + let call = FunctionCall.formatDate(value: systemFormatted, format: "yyyy-MM-dd") + let result: String! = A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String + try #require(result != nil) + #expect(result == "2026-02-26") + } + + @Test func formatNonStandardShortDateString() throws { + let timestamp = try getLocalTimestamp() + let date = Date(timeIntervalSince1970: timestamp) + let systemFormatted = date.formatted(date: .abbreviated, time: .shortened) + let call = FunctionCall.formatDate(value: systemFormatted, format: "yyyy-MM-dd") + let result: String! = A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String + try #require(result != nil) + #expect(result == "2026-02-26") + } + + @Test func formatDateEdgeCases() async { + let date = Date(timeIntervalSince1970: 0) + let call1 = FunctionCall.formatDate(value: date, format: "yyyy") + let res1 = A2UIStandardFunctions.evaluate(call: call1, surface: surface) as? String + #expect(res1 == "1970" || res1 == "1969") + + let call2 = FunctionCall.formatDate(value: "1970-01-01T00:00:00Z", format: "yyyy") + let res2 = A2UIStandardFunctions.evaluate(call: call2, surface: surface) as? String + #expect(res2 == "1970" || res2 == "1969") + + let call3 = FunctionCall.formatDate(value: "bad-date", format: "yyyy") + #expect(A2UIStandardFunctions.evaluate(call: call3, surface: surface) as? String == "bad-date") + + let call4 = FunctionCall(call: "formatDate", args: [ + "value": AnyCodable(["a", "b"] as [Sendable]), + "format": AnyCodable("yyyy") + ]) + let result4 = A2UIStandardFunctions.evaluate(call: call4, surface: surface) as? String + #expect(result4 != nil) + } +} diff --git a/renderers/swift/Tests/A2UITests/Functions/Formatting/FormatNumberTests.swift b/renderers/swift/Tests/A2UITests/Functions/Formatting/FormatNumberTests.swift new file mode 100644 index 000000000..1085045a3 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Functions/Formatting/FormatNumberTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing +@testable import A2UI + +@MainActor +struct FormatNumberTests { + private let surface = SurfaceState(id: "test") + + @Test func formatNumber() throws { + let call = FunctionCall.formatNumber(value: 1234.567, decimals: 2, grouping: true) + let result: String! = A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String + try #require(result != nil ) + // Locale dependent, but should contain 1,234.57 or 1 234.57 + #expect(result.contains("1")) + #expect(result.contains("234")) + #expect(result.contains("57")) + } + + @Test func formatNumberNoDecimals() throws { + let call = FunctionCall.formatNumber(value: 1234.567, decimals: 0, grouping: true) + let result: String! = A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String + try #require(result != nil) + #expect(result.contains("1")) + #expect(result.contains("235")) + #expect(result.contains("00") == false) + } + + @Test func formatNumberNoDecimalsNoGrouping() throws { + let call = FunctionCall.formatNumber(value: 1234.567, decimals: 0, grouping: false) + let result: String! = A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String + try #require(result != nil) + #expect(result.contains("1235")) + } + + @Test func formatNumberExtraDecimals() throws { + let call = FunctionCall.formatNumber(value: 1234.567, decimals: 4, grouping: false) + let result: String! = A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String + try #require(result != nil) + #expect(result.contains("1234")) + #expect(result.contains("5670")) + } + + @Test func formatNumberEdgeCases() throws { + let call1 = FunctionCall.formatNumber(value: 1234.56, decimals: nil, grouping: false) + let result1 = A2UIStandardFunctions.evaluate(call: call1, surface: surface) as? String + #expect(result1?.contains("1234.56") == true || result1?.contains("1234,56") == true) + + let invalid = FunctionCall(call: "formatNumber", args: ["value": AnyCodable("not-double")]) + #expect(A2UIStandardFunctions.evaluate(call: invalid, surface: surface) as? String == "") + + let callGrouping = FunctionCall(call: "formatNumber", args: [ + "value": AnyCodable(1234.56) + ]) + let resGrouping = A2UIStandardFunctions.evaluate(call: callGrouping, surface: surface) as? String + #expect(resGrouping?.contains("1") == true) + } +} diff --git a/renderers/swift/Tests/A2UITests/Functions/Formatting/FormatStringTests.swift b/renderers/swift/Tests/A2UITests/Functions/Formatting/FormatStringTests.swift new file mode 100644 index 000000000..5a0691141 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Functions/Formatting/FormatStringTests.swift @@ -0,0 +1,23 @@ +import Foundation +import Testing +@testable import A2UI + +@MainActor +struct FormatStringTests { + private let surface = SurfaceState(id: "test") + + @Test func formatString() async { + surface.setValue(at: "/user/name", value: "Alice") + let call = FunctionCall.formatString(value: "Hello, ${/user/name}!") + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String == "Hello, Alice!") + } + + @Test func formatStringEdgeCases() async { + let call1 = FunctionCall.formatString(value: "Value is ${/does/not/exist} or ${direct_expr}") + let result1 = A2UIStandardFunctions.evaluate(call: call1, surface: surface) as? String + #expect(result1 == "Value is or ${direct_expr}") + + let invalid = FunctionCall.formatString(value: 123) + #expect(A2UIStandardFunctions.evaluate(call: invalid, surface: surface) as? String == "") + } +} diff --git a/renderers/swift/Tests/A2UITests/Functions/Formatting/OpenUrlTests.swift b/renderers/swift/Tests/A2UITests/Functions/Formatting/OpenUrlTests.swift new file mode 100644 index 000000000..789ea30e7 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Functions/Formatting/OpenUrlTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing +@testable import A2UI + +@MainActor +struct OpenUrlTests { + private let surface = SurfaceState(id: "test") + + @Test func openUrl() async { + let mockOpener = MockURLOpener() + let originalOpener = A2UIStandardFunctions.sharedURLOpener + A2UIStandardFunctions.sharedURLOpener = mockOpener + defer { + A2UIStandardFunctions.sharedURLOpener = originalOpener + } + + let validCall = FunctionCall(call: "openUrl", args: ["url": AnyCodable("https://example.com")]) + _ = A2UIStandardFunctions.evaluate(call: validCall, surface: surface) + #expect(mockOpener.openedURL?.absoluteString == "https://example.com") + + let badCall = FunctionCall(call: "openUrl", args: ["url": AnyCodable("")]) + #expect(A2UIStandardFunctions.evaluate(call: badCall, surface: surface) == nil) + #expect(mockOpener.openedURL?.absoluteString == "https://example.com") // not updated + + let invalidArgs = FunctionCall(call: "openUrl", args: ["url": AnyCodable(123)]) + #expect(A2UIStandardFunctions.evaluate(call: invalidArgs, surface: surface) == nil) + #expect(mockOpener.openedURL?.absoluteString == "https://example.com") // not updated + } +} + +class MockURLOpener: NSObject, URLOpener { + var openedURL: URL? + func open(_ url: URL) { + openedURL = url + } +} diff --git a/renderers/swift/Tests/A2UITests/Functions/Formatting/PluralizeTests.swift b/renderers/swift/Tests/A2UITests/Functions/Formatting/PluralizeTests.swift new file mode 100644 index 000000000..c88ea5ef0 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Functions/Formatting/PluralizeTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +@testable import A2UI + +@MainActor +struct PluralizeTests { + private let surface = SurfaceState(id: "test") + + @Test func pluralize() async { + var call = FunctionCall.pluralize(value: 1.0, one: "item", other: "items") + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String == "item") + + call = FunctionCall.pluralize(value: 2.0, one: "item", other: "items") + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String == "items") + + // Test with optional categories + call = FunctionCall.pluralize(value: 0.0, zero: "none", other: "some") + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String == "none") + + call = FunctionCall.pluralize(value: 2.0, two: "couple", other: "many") + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? String == "couple") + } + + @Test func pluralizeEdgeCases() async { + let call1 = FunctionCall(call: "pluralize", args: ["value": AnyCodable(1), "other": AnyCodable("others")]) + #expect(A2UIStandardFunctions.evaluate(call: call1, surface: surface) as? String == "others") + + let call2 = FunctionCall(call: "pluralize", args: ["value": AnyCodable(0), "other": AnyCodable("others")]) + #expect(A2UIStandardFunctions.evaluate(call: call2, surface: surface) as? String == "others") + + let call3 = FunctionCall(call: "pluralize", args: ["value": AnyCodable(2), "other": AnyCodable("others")]) + #expect(A2UIStandardFunctions.evaluate(call: call3, surface: surface) as? String == "others") + + let invalid = FunctionCall(call: "pluralize", args: ["value": AnyCodable("not-double")]) + #expect(A2UIStandardFunctions.evaluate(call: invalid, surface: surface) as? String == "") + + let callOtherNum = FunctionCall.pluralize(value: 5, other: "others") + let resOtherNum = A2UIStandardFunctions.evaluate(call: callOtherNum, surface: surface) as? String + #expect(resOtherNum == "others") + } +} diff --git a/renderers/swift/Tests/A2UITests/Functions/Logical/LogicalFunctionsTests.swift b/renderers/swift/Tests/A2UITests/Functions/Logical/LogicalFunctionsTests.swift new file mode 100644 index 000000000..6c09dafee --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Functions/Logical/LogicalFunctionsTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing +@testable import A2UI + +@MainActor +struct LogicalFunctionsTests { + private let surface = SurfaceState(id: "test") + + @Test func logical() async { + var call = FunctionCall.and(values: [true, true]) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == true) + + call = FunctionCall.and(values: [true, false]) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + + // Min 2 items check + call = FunctionCall.and(values: [true]) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + + call = FunctionCall.or(values: [true, false]) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == true) + + call = FunctionCall.not(value: true) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + } +} diff --git a/renderers/swift/Tests/A2UITests/Functions/Validation/CheckLengthTests.swift b/renderers/swift/Tests/A2UITests/Functions/Validation/CheckLengthTests.swift new file mode 100644 index 000000000..58c33300f --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Functions/Validation/CheckLengthTests.swift @@ -0,0 +1,23 @@ +import Foundation +import Testing +@testable import A2UI + +@MainActor +struct CheckLengthTests { + private let surface = SurfaceState(id: "test") + + @Test func length() async { + var call = FunctionCall.length(value: "test", min: 2.0, max: 5.0) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == true) + + call = FunctionCall.length(value: "t", min: 2.0) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + + call = FunctionCall.length(value: "testtest", max: 5.0) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + + // Missing both min and max should fail according to anyOf spec + call = FunctionCall.length(value: "test") + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + } +} diff --git a/renderers/swift/Tests/A2UITests/Functions/Validation/CheckNumericTests.swift b/renderers/swift/Tests/A2UITests/Functions/Validation/CheckNumericTests.swift new file mode 100644 index 000000000..ad32cac8c --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Functions/Validation/CheckNumericTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +@testable import A2UI + +@MainActor +struct CheckNumericTests { + private let surface = SurfaceState(id: "test") + + @Test func numeric() async { + var call = FunctionCall.numeric(value: 10.0, min: 5.0, max: 15.0) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == true) + + call = FunctionCall.numeric(value: 20.0, min: 5.0, max: 15.0) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + + call = FunctionCall.numeric(value: 20.0, max: 15.0) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + + call = FunctionCall.numeric(value: 10.0, max: 15.0) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == true) + + call = FunctionCall.numeric(value: 10, min: 5.0) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == true) + + call = FunctionCall.numeric(value: 1, min: 5.0) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + + // Missing both min and max should fail according to anyOf spec + call = FunctionCall.numeric(value: 10.0) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + } +} diff --git a/renderers/swift/Tests/A2UITests/Functions/Validation/IsEmailTests.swift b/renderers/swift/Tests/A2UITests/Functions/Validation/IsEmailTests.swift new file mode 100644 index 000000000..91734ba70 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Functions/Validation/IsEmailTests.swift @@ -0,0 +1,16 @@ +import Foundation +import Testing +@testable import A2UI + +@MainActor +struct IsEmailTests { + private let surface = SurfaceState(id: "test") + + @Test func email() async { + var call = FunctionCall.email(value: "test@example.com") + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == true) + + call = FunctionCall.email(value: "invalid-email") + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + } +} diff --git a/renderers/swift/Tests/A2UITests/Functions/Validation/IsRequiredTests.swift b/renderers/swift/Tests/A2UITests/Functions/Validation/IsRequiredTests.swift new file mode 100644 index 000000000..8f1312d31 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Functions/Validation/IsRequiredTests.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing +@testable import A2UI + +@MainActor +struct IsRequiredTests { + private let surface = SurfaceState(id: "test") + + @Test func required() async { + var call = FunctionCall.required(value: "hello") + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == true) + + call = FunctionCall.required(value: "") + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + + call = FunctionCall.required(value: JSONNull()) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + + call = FunctionCall.required(value: 2) + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == true) + } +} diff --git a/renderers/swift/Tests/A2UITests/Functions/Validation/MatchesRegexTests.swift b/renderers/swift/Tests/A2UITests/Functions/Validation/MatchesRegexTests.swift new file mode 100644 index 000000000..27ee33924 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Functions/Validation/MatchesRegexTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing +@testable import A2UI + +@MainActor +struct MatchesRegexTests { + private let surface = SurfaceState(id: "test") + + @Test func regex() async { + var call = FunctionCall.regex(value: "123", pattern: "^[0-9]+$") + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == true) + + call = FunctionCall.regex(value: "abc", pattern: "^[0-9]+$") + #expect(A2UIStandardFunctions.evaluate(call: call, surface: surface) as? Bool == false) + } + + @Test func regexEdgeCases() async { + let call1 = FunctionCall.regex(value: "test", pattern: "[a-z") // Invalid regex + #expect(A2UIStandardFunctions.evaluate(call: call1, surface: surface) as? Bool == false) + + let invalid1 = FunctionCall(call: "regex", args: ["value": AnyCodable("test")]) + #expect(A2UIStandardFunctions.evaluate(call: invalid1, surface: surface) as? Bool == false) + } +} diff --git a/renderers/swift/Tests/A2UITests/Models/A2UIMessageTests.swift b/renderers/swift/Tests/A2UITests/Models/A2UIMessageTests.swift new file mode 100644 index 000000000..51abc1727 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Models/A2UIMessageTests.swift @@ -0,0 +1,129 @@ +import Testing +@testable import A2UI +import Foundation + +struct A2UIMessageTests { + @Test func a2UIMessageDecodeVersionError() { + let json = """ + { + "version": "v0.11", + "createSurface": {"surfaceId": "1"} + } + """.data(using: .utf8)! + + #expect(throws: Error.self) { + let error = try JSONDecoder().decode(A2UIMessage.self, from: json) + return error + } + + // Detailed check if possible, but Swift Testing #expect(throws:) is more limited in inspecting error details in-line + do { + _ = try JSONDecoder().decode(A2UIMessage.self, from: json) + } catch DecodingError.dataCorrupted(let context) { + #expect(context.debugDescription.contains("Unsupported A2UI version")) + } catch { + Issue.record("Expected dataCorrupted error, got \(error)") + } + } + + @Test func a2UIMessageAppMessage() throws { + let json = """ + { + "customEvent": {"data": 123} + } + """.data(using: .utf8)! + + let message = try JSONDecoder().decode(A2UIMessage.self, from: json) + if case let .appMessage(name, data) = message { + #expect(name == "customEvent") + #expect(data["customEvent"] != nil) + } else { + Issue.record("Expected appMessage") + } + + let encoded = try JSONEncoder().encode(message) + let decoded = try JSONDecoder().decode(A2UIMessage.self, from: encoded) + if case let .appMessage(name2, data2) = decoded { + #expect(name2 == "customEvent") + #expect(data2["customEvent"] != nil) + } else { + Issue.record("Expected appMessage") + } + } + + @Test func a2UIMessageAppMessageMultipleKeys() throws { + let json = """ + { + "event1": {"a": 1}, + "event2": {"b": 2} + } + """.data(using: .utf8)! + + let message = try JSONDecoder().decode(A2UIMessage.self, from: json) + if case let .appMessage(name, data) = message { + #expect(name == "event1" || name == "event2") + #expect(data.count == 2) + + let encoded = try JSONEncoder().encode(message) + let decodedAgain = try JSONDecoder().decode(A2UIMessage.self, from: encoded) + if case let .appMessage(_, data2) = decodedAgain { + #expect(data2.count == 2) + } else { Issue.record("Expected appMessage") } + } else { + Issue.record("Expected appMessage") + } + } + + @Test func a2UIMessageDecodeError() { + let json = "{}".data(using: .utf8)! + #expect(throws: Error.self) { try JSONDecoder().decode(A2UIMessage.self, from: json) } + } + + @Test func a2UIMessageDeleteAndDataUpdate() throws { + // Delete + let deleteJson = """ + { + "version": "v0.9", + "deleteSurface": {"surfaceId": "s1"} + } + """.data(using: .utf8)! + let deleteMsg = try JSONDecoder().decode(A2UIMessage.self, from: deleteJson) + if case .deleteSurface(let ds) = deleteMsg { + #expect(ds.surfaceId == "s1") + } else { Issue.record("Expected deleteSurface") } + + let encodedDelete = try JSONEncoder().encode(deleteMsg) + #expect(String(data: encodedDelete, encoding: .utf8)!.contains("deleteSurface")) + + // Data Model Update + let updateJson = """ + { + "version": "v0.9", + "updateDataModel": {"surfaceId": "s1", "value": {"key": "value"}} + } + """.data(using: .utf8)! + let updateMsg = try JSONDecoder().decode(A2UIMessage.self, from: updateJson) + if case .dataModelUpdate(let dmu) = updateMsg { + #expect(dmu.surfaceId == "s1") + #expect(dmu.value == AnyCodable(["key": "value"] as [String: Sendable])) + } else { Issue.record("Expected dataModelUpdate") } + } + + @Test func a2UICreateSurface() throws { + let createSurfaceJson = """ + { + "version": "v0.9", + "createSurface": {"surfaceId": "surface123","catalogId": "catalog456"} + } + """.data(using: .utf8)! + let message = try JSONDecoder().decode(A2UIMessage.self, from: createSurfaceJson) + if case .createSurface(let cs) = message { + #expect(cs.surfaceId == "surface123") + #expect(cs.catalogId == "catalog456") + #expect(cs.theme == nil) + #expect(cs.sendDataModel == nil) + } else { + Issue.record("Expected createSurface message") + } + } +} diff --git a/renderers/swift/Tests/A2UITests/Models/ActionTests.swift b/renderers/swift/Tests/A2UITests/Models/ActionTests.swift new file mode 100644 index 000000000..a868a18c2 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Models/ActionTests.swift @@ -0,0 +1,54 @@ +import Testing +@testable import A2UI +import Foundation + +struct ActionTests { + @Test func actionDecodeEncode() throws { + let eventJson = """ + { + "event": { + "name": "click", + "context": {"key": "val"} + } + } + """.data(using: .utf8)! + let eventAction = try JSONDecoder().decode(Action.self, from: eventJson) + if case let .event(name, context) = eventAction { + #expect(name == "click") + #expect(context?["key"] == AnyCodable("val")) + } else { + Issue.record("Expected event action") + } + + let functionCallJson = """ + { + "functionCall": { + "call": "doSomething" + } + } + """.data(using: .utf8)! + let functionCallAction = try JSONDecoder().decode(Action.self, from: functionCallJson) + if case let .functionCall(fc) = functionCallAction { + #expect(fc.call == "doSomething") + } else { + Issue.record("Expected functionCall action") + } + + // Error case (v0.8 legacy format should now fail) + let legacyJson = """ + { + "name": "submit", + "context": {"key": "val"} + } + """.data(using: .utf8)! + #expect(throws: Error.self) { try JSONDecoder().decode(Action.self, from: legacyJson) } + + // Encoding Event Action + let encodedEvent = try JSONEncoder().encode(eventAction) + let decodedEvent = try JSONDecoder().decode(Action.self, from: encodedEvent) + if case let .event(name, context) = decodedEvent { + #expect(name == "click") + #expect(context?["key"] == AnyCodable("val")) + } + } +} diff --git a/renderers/swift/Tests/A2UITests/Models/AnyCodableTests.swift b/renderers/swift/Tests/A2UITests/Models/AnyCodableTests.swift new file mode 100644 index 000000000..2d24559c5 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Models/AnyCodableTests.swift @@ -0,0 +1,82 @@ +import Testing +@testable import A2UI +import Foundation + +struct AnyCodableTests { + @Test func anyCodableJSONNull() throws { + let json = "null".data(using: .utf8)! + let val = try JSONDecoder().decode(AnyCodable.self, from: json) + #expect(val.value is JSONNull) + #expect(val == AnyCodable(JSONNull())) + + let encoded = try JSONEncoder().encode(val) + #expect(String(data: encoded, encoding: .utf8) == "null") + } + + @Test func anyCodableTypes() throws { + let json = """ + { + "string": "test", + "bool": true, + "double": 1.5, + "array": [1.0, "two"], + "dict": {"key": "value"} + } + """.data(using: .utf8)! + + let dict = try JSONDecoder().decode([String: AnyCodable].self, from: json) + #expect(dict["string"] == AnyCodable("test")) + #expect(dict["bool"] == AnyCodable(true)) + #expect(dict["double"] == AnyCodable(1.5)) + + let encoded = try JSONEncoder().encode(dict) + let decodedDict = try JSONDecoder().decode([String: AnyCodable].self, from: encoded) + + #expect(dict["string"] == decodedDict["string"]) + #expect(dict["bool"] == decodedDict["bool"]) + #expect(dict["double"] == decodedDict["double"]) + + #expect(AnyCodable([1.0, "two"] as [Sendable]) == AnyCodable([1.0, "two"] as [Sendable])) + } + + @Test func anyCodableDataCorrupted() throws { + let invalidJson = #"{"test": "#.data(using: .utf8)! + #expect(throws: Error.self) { try JSONDecoder().decode(AnyCodable.self, from: invalidJson) } + } + + @Test func anyCodableEquality() { + #expect(AnyCodable(JSONNull()) == AnyCodable(JSONNull())) + #expect(AnyCodable("a") == AnyCodable("a")) + #expect(AnyCodable("a") != AnyCodable("b")) + #expect(AnyCodable(true) == AnyCodable(true)) + #expect(AnyCodable(1.0) == AnyCodable(1.0)) + + let dict1: [String: Sendable] = ["a": 1.0] + let dict2: [String: Sendable] = ["a": 1.0] + #expect(AnyCodable(dict1) == AnyCodable(dict2)) + + let arr1: [Sendable] = [1.0, 2.0] + let arr2: [Sendable] = [1.0, 2.0] + #expect(AnyCodable(arr1) == AnyCodable(arr2)) + + #expect(AnyCodable("string") != AnyCodable(1.0)) + } + + @Test func anyCodableArrayEncode() throws { + let arr: [Sendable] = ["hello", 1.0, true] + let val = AnyCodable(arr) + let encoded = try JSONEncoder().encode(val) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: encoded) + #expect(val == decoded) + } + + @Test func jsonNull() throws { + let nullVal = JSONNull() + let encoded = try JSONEncoder().encode(nullVal) + let decoded = try JSONDecoder().decode(JSONNull.self, from: encoded) + #expect(nullVal == decoded) + + let invalid = "123".data(using: .utf8)! + #expect(throws: Error.self) { try JSONDecoder().decode(JSONNull.self, from: invalid) } + } +} diff --git a/renderers/swift/Tests/A2UITests/Models/BoundValueTests.swift b/renderers/swift/Tests/A2UITests/Models/BoundValueTests.swift new file mode 100644 index 000000000..836927aac --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Models/BoundValueTests.swift @@ -0,0 +1,39 @@ +import Testing +@testable import A2UI +import Foundation + +struct BoundValueTests { + @Test func boundValueDecodeEncode() throws { + // Literal Int -> gets decoded as Double via literal fallback + let literalJson = "42".data(using: .utf8)! + let literalVal = try JSONDecoder().decode(BoundValue.self, from: literalJson) + #expect(literalVal.literal == 42.0) + #expect(literalVal.path == nil) + + // Path + let pathJson = #"{"path": "user.age"}"#.data(using: .utf8)! + let pathVal = try JSONDecoder().decode(BoundValue.self, from: pathJson) + #expect(pathVal.path == "user.age") + #expect(pathVal.literal == nil) + #expect(pathVal.functionCall == nil) + + // Function Call + let funcJson = #"{"call": "getAge"}"#.data(using: .utf8)! + let funcVal = try JSONDecoder().decode(BoundValue.self, from: funcJson) + #expect(funcVal.functionCall != nil) + #expect(funcVal.functionCall?.call == "getAge") + + // Encode + let encodedLiteral = try JSONEncoder().encode(literalVal) + let decodedLiteral = try JSONDecoder().decode(BoundValue.self, from: encodedLiteral) + #expect(decodedLiteral.literal == 42.0) + + let encodedPath = try JSONEncoder().encode(pathVal) + let decodedPath = try JSONDecoder().decode(BoundValue.self, from: encodedPath) + #expect(decodedPath.path == "user.age") + + let encodedFunc = try JSONEncoder().encode(funcVal) + let decodedFunc = try JSONDecoder().decode(BoundValue.self, from: encodedFunc) + #expect(decodedFunc.functionCall?.call == "getAge") + } +} diff --git a/renderers/swift/Tests/A2UITests/Models/ChildrenTests.swift b/renderers/swift/Tests/A2UITests/Models/ChildrenTests.swift new file mode 100644 index 000000000..82bcbe38f --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Models/ChildrenTests.swift @@ -0,0 +1,46 @@ +import Testing +@testable import A2UI +import Foundation + +struct ChildrenTests { + @Test func childrenDecodeEncode() throws { + let listJson = #"["child1", "child2"]"#.data(using: .utf8)! + let listVal = try JSONDecoder().decode(Children.self, from: listJson) + if case let .list(items) = listVal { + #expect(items == ["child1", "child2"]) + } else { Issue.record("Expected list children") } + + let templateJson = #"{"componentId": "item", "path": "items"}"#.data(using: .utf8)! + let templateVal = try JSONDecoder().decode(Children.self, from: templateJson) + if case let .template(t) = templateVal { + #expect(t.componentId == "item") + #expect(t.path == "items") + } else { Issue.record("Expected template children") } + + // Legacy wrappers + let explicitListJson = #"{"explicitList": ["child1"]}"#.data(using: .utf8)! + let explicitListVal = try JSONDecoder().decode(Children.self, from: explicitListJson) + if case let .list(items) = explicitListVal { + #expect(items == ["child1"]) + } else { Issue.record("Expected list children from explicitList") } + + let explicitTemplateJson = #"{"template": {"componentId": "c", "path": "p"}}"#.data(using: .utf8)! + let explicitTemplateVal = try JSONDecoder().decode(Children.self, from: explicitTemplateJson) + if case let .template(t) = explicitTemplateVal { + #expect(t.componentId == "c") + } else { Issue.record("Expected template children from template wrapper") } + + // Error + let invalidJson = #"{"invalid": true}"#.data(using: .utf8)! + #expect(throws: Error.self) { try JSONDecoder().decode(Children.self, from: invalidJson) } + + // Encode + let encodedList = try JSONEncoder().encode(listVal) + let decodedList = try JSONDecoder().decode(Children.self, from: encodedList) + if case let .list(items) = decodedList { #expect(items == ["child1", "child2"]) } + + let encodedTemplate = try JSONEncoder().encode(templateVal) + let decodedTemplate = try JSONDecoder().decode(Children.self, from: encodedTemplate) + if case let .template(t) = decodedTemplate { #expect(t.componentId == "item") } + } +} diff --git a/renderers/swift/Tests/A2UITests/Models/ComponentInstanceTests.swift b/renderers/swift/Tests/A2UITests/Models/ComponentInstanceTests.swift new file mode 100644 index 000000000..4cd445f58 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Models/ComponentInstanceTests.swift @@ -0,0 +1,21 @@ +import Testing +@testable import A2UI +import Foundation + +struct ComponentInstanceTests { + @Test func componentInstanceFullInit() throws { + let textType = ComponentType.text(TextProperties(text: BoundValue(literal: "Test"), variant: nil)) + let check = CheckRule(condition: BoundValue(literal: true), message: "msg") + let comp = ComponentInstance(id: "1", weight: 2.5, checks: [check], component: textType) + + #expect(comp.id == "1") + #expect(comp.weight == 2.5) + #expect(comp.checks?.count == 1) + #expect(comp.componentTypeName == "Text") + + let encoded = try JSONEncoder().encode(comp) + let decoded = try JSONDecoder().decode(ComponentInstance.self, from: encoded) + #expect(decoded.id == "1") + #expect(decoded.weight == 2.5) + } +} diff --git a/renderers/swift/Tests/A2UITests/Models/ComponentTypeTests.swift b/renderers/swift/Tests/A2UITests/Models/ComponentTypeTests.swift new file mode 100644 index 000000000..5638442de --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Models/ComponentTypeTests.swift @@ -0,0 +1,80 @@ +import Testing +@testable import A2UI +import Foundation + +struct ComponentTypeTests { + @Test func componentTypeNames() { + let cases: [(ComponentType, String)] = [ + (.text(TextProperties(text: .init(literal: ""), variant: nil)), "Text"), + (.button(ButtonProperties(child: "c1", action: .event(name: "tap", context: nil))), "Button"), + (.column(ContainerProperties(children: .list([]), justify: nil, align: nil)), "Column"), + (.row(ContainerProperties(children: .list([]), justify: nil, align: nil)), "Row"), + (.card(CardProperties(child: "c1")), "Card"), + (.divider(DividerProperties(axis: .horizontal)), "Divider"), + (.image(ImageProperties(url: .init(literal: ""), fit: nil, variant: nil)), "Image"), + (.list(ListProperties(children: .list([]), direction: nil, align: nil)), "List"), + (.textField(TextFieldProperties(label: .init(literal: ""), value: .init(path: "p"))), "TextField"), + (.choicePicker(ChoicePickerProperties(label: .init(literal: ""), options: [], value: .init(path: "p"))), "ChoicePicker"), + (.dateTimeInput(DateTimeInputProperties(label: .init(literal: ""), value: .init(path: "p"))), "DateTimeInput"), + (.slider(SliderProperties(label: .init(literal: ""), min: 0, max: 100, value: .init(path: "p"))), "Slider"), + (.checkBox(CheckBoxProperties(label: .init(literal: ""), value: .init(path: "p"))), "CheckBox"), + (.tabs(TabsProperties(tabs: [])), "Tabs"), + (.icon(IconProperties(name: .init(literal: "star"))), "Icon"), + (.modal(ModalProperties(trigger: "t1", content: "c1")), "Modal"), + (.video(VideoProperties(url: .init(literal: ""))), "Video"), + (.audioPlayer(AudioPlayerProperties(url: .init(literal: ""), description: nil)), "AudioPlayer"), + (.custom("MyComp", [:]), "MyComp") + ] + + for (type, expectedName) in cases { + #expect(type.typeName == expectedName) + } + } + + @Test func componentTypeCodableRoundTrip() throws { + let cases: [ComponentType] = [ + .text(TextProperties(text: .init(literal: "hello"), variant: .h1)), + .button(ButtonProperties(child: "c1", action: .event(name: "tap", context: nil))), + .column(ContainerProperties(children: .list(["a"]), justify: .center, align: .center)), + .row(ContainerProperties(children: .list(["b"]), justify: .start, align: .end)), + .card(CardProperties(child: "c1")), + .divider(DividerProperties(axis: .vertical)), + .image(ImageProperties(url: .init(literal: "url"), fit: .contain, variant: nil)), + .list(ListProperties(children: .list([]), direction: "horizontal", align: "center")), + .textField(TextFieldProperties(label: .init(literal: "l"), value: .init(path: "p"))), + .choicePicker(ChoicePickerProperties(label: .init(literal: "l"), options: [], value: .init(path: "p"))), + .dateTimeInput(DateTimeInputProperties(label: .init(literal: "l"), value: .init(path: "p"))), + .slider(SliderProperties(label: .init(literal: "l"), min: 0, max: 100, value: .init(path: "p"))), + .checkBox(CheckBoxProperties(label: .init(literal: "l"), value: .init(path: "p"))), + .tabs(TabsProperties(tabs: [])), + .icon(IconProperties(name: .init(literal: "star"))), + .modal(ModalProperties(trigger: "t1", content: "c1")), + .video(VideoProperties(url: .init(literal: "v"))), + .audioPlayer(AudioPlayerProperties(url: .init(literal: "a"), description: nil)), + .custom("MyComp", ["foo": AnyCodable("bar")]) + ] + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + for original in cases { + let data = try encoder.encode(original) + let decoded = try decoder.decode(ComponentType.self, from: data) + #expect(original.typeName == decoded.typeName) + + // Re-encode to ensure consistency + let reEncoded = try encoder.encode(decoded) + // We can't always compare data directly because of dictionary ordering or other factors, + // but for these simple cases it usually works or we can decode again. + let reDecoded = try decoder.decode(ComponentType.self, from: reEncoded) + #expect(original.typeName == reDecoded.typeName) + } + } + + @Test func decodingInvalidComponentType() { + let json = "{}" // Missing keys + let data = json.data(using: .utf8)! + let decoder = JSONDecoder() + #expect(throws: Error.self) { try decoder.decode(ComponentType.self, from: data) } + } +} diff --git a/renderers/swift/Tests/A2UITests/Models/FunctionCallTests.swift b/renderers/swift/Tests/A2UITests/Models/FunctionCallTests.swift new file mode 100644 index 000000000..f7be3e963 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Models/FunctionCallTests.swift @@ -0,0 +1,29 @@ +import Testing +@testable import A2UI +import Foundation + +struct FunctionCallTests { + @Test func functionCallCodable() throws { + let json = """ + { + "call": "formatDate", + "args": {"timestamp": 12345}, + "returnType": "String" + } + """.data(using: .utf8)! + + let call = try JSONDecoder().decode(FunctionCall.self, from: json) + #expect(call.call == "formatDate") + #expect(call.returnType == "String") + #expect(call.args["timestamp"] == AnyCodable(12345.0)) + + let encoded = try JSONEncoder().encode(call) + let decoded = try JSONDecoder().decode(FunctionCall.self, from: encoded) + #expect(call == decoded) + + let emptyCall = FunctionCall(call: "empty") + let emptyEncoded = try JSONEncoder().encode(emptyCall) + let emptyDecoded = try JSONDecoder().decode(FunctionCall.self, from: emptyEncoded) + #expect(emptyCall == emptyDecoded) + } +} diff --git a/renderers/swift/Tests/A2UITests/Shared/FunctionCall+TestHelpers.swift b/renderers/swift/Tests/A2UITests/Shared/FunctionCall+TestHelpers.swift new file mode 100644 index 000000000..3ab10ebc8 --- /dev/null +++ b/renderers/swift/Tests/A2UITests/Shared/FunctionCall+TestHelpers.swift @@ -0,0 +1,78 @@ +import Foundation +@testable import A2UI + +extension FunctionCall { + static func required(value: Sendable?) -> FunctionCall { + FunctionCall(call: "required", args: ["value": AnyCodable(value)]) + } + + static func regex(value: Sendable, pattern: Sendable) -> FunctionCall { + FunctionCall(call: "regex", args: ["value": AnyCodable(value), "pattern": AnyCodable(pattern)]) + } + + static func length(value: Sendable, min: Sendable? = nil, max: Sendable? = nil) -> FunctionCall { + var args: [String: AnyCodable] = ["value": AnyCodable(value)] + if let min { args["min"] = AnyCodable(min) } + if let max { args["max"] = AnyCodable(max) } + return FunctionCall(call: "length", args: args) + } + + static func numeric(value: Sendable, min: Sendable? = nil, max: Sendable? = nil) -> FunctionCall { + var args: [String: AnyCodable] = ["value": AnyCodable(value)] + if let min { args["min"] = AnyCodable(min) } + if let max { args["max"] = AnyCodable(max) } + return FunctionCall(call: "numeric", args: args) + } + + static func email(value: Sendable) -> FunctionCall { + FunctionCall(call: "email", args: ["value": AnyCodable(value)]) + } + + static func formatString(value: Sendable) -> FunctionCall { + FunctionCall(call: "formatString", args: ["value": AnyCodable(value)]) + } + + static func formatNumber(value: Sendable, decimals: Sendable? = nil, grouping: Sendable? = nil) -> FunctionCall { + var args: [String: AnyCodable] = ["value": AnyCodable(value)] + if let decimals { args["decimals"] = AnyCodable(decimals) } + if let grouping { args["grouping"] = AnyCodable(grouping) } + return FunctionCall(call: "formatNumber", args: args) + } + + static func formatCurrency(value: Sendable, currency: Sendable) -> FunctionCall { + FunctionCall(call: "formatCurrency", args: ["value": AnyCodable(value), "currency": AnyCodable(currency)]) + } + + static func formatDate(value: Sendable, format: Sendable) -> FunctionCall { + FunctionCall(call: "formatDate", args: ["value": AnyCodable(value), "format": AnyCodable(format)]) + } + + static func pluralize(value: Sendable, zero: Sendable? = nil, one: Sendable? = nil, two: Sendable? = nil, other: Sendable) -> FunctionCall { + var args: [String: AnyCodable] = ["value": AnyCodable(value), "other": AnyCodable(other)] + if let zero { args["zero"] = AnyCodable(zero) } + if let one { args["one"] = AnyCodable(one) } + if let two { args["two"] = AnyCodable(two) } + return FunctionCall(call: "pluralize", args: args) + } + + static func and(values: Sendable) -> FunctionCall { + FunctionCall(call: "and", args: ["values": AnyCodable(values)]) + } + + static func or(values: Sendable) -> FunctionCall { + FunctionCall(call: "or", args: ["values": AnyCodable(values)]) + } + + static func not(value: Sendable) -> FunctionCall { + FunctionCall(call: "not", args: ["value": AnyCodable(value)]) + } + + static func formatCurrency(value: Sendable, currency: Sendable, decimals: Int, grouping: Bool) -> FunctionCall { + FunctionCall(call: "formatCurrency", args: [ + "value": AnyCodable(value), + "currency": AnyCodable(currency), + "decimals": AnyCodable(decimals), + "grouping": AnyCodable(grouping) + ]) + } +} diff --git a/samples/client/swift/.gitignore b/samples/client/swift/.gitignore new file mode 100644 index 000000000..ce9b514e0 --- /dev/null +++ b/samples/client/swift/.gitignore @@ -0,0 +1,15 @@ +# Build artifacts +.build/ + +# Swift Package Manager +.swiftpm/ + +# Xcode +.DS_Store +*.playground/ +DerivedData/ + +# User-specific workspace/project files +**/*.xcodeproj/project.xcworkspace/ +**/*.xcodeproj/xcuserdata/ +**/*.xcworkspace/xcuserdata/ diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp.xcodeproj/project.pbxproj b/samples/client/swift/A2UISampleApp/A2UISampleApp.xcodeproj/project.pbxproj new file mode 100644 index 000000000..3f86365fb --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp.xcodeproj/project.pbxproj @@ -0,0 +1,370 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + A33BAF0C2F4CA08800614D0C /* A2UI in Frameworks */ = {isa = PBXBuildFile; productRef = A33BAF0B2F4CA08800614D0C /* A2UI */; }; + A33BAF412F4CCEA800614D0C /* A2UI in Frameworks */ = {isa = PBXBuildFile; productRef = A33BAF402F4CCEA800614D0C /* A2UI */; }; + A3C9FD092F516211002606E5 /* A2UI in Frameworks */ = {isa = PBXBuildFile; productRef = A3C9FD082F516211002606E5 /* A2UI */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A33BAEFC2F4CA06E00614D0C /* A2UISampleApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = A2UISampleApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A33BAEFE2F4CA06E00614D0C /* A2UISampleApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = A2UISampleApp; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A33BAEF92F4CA06E00614D0C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A3C9FD092F516211002606E5 /* A2UI in Frameworks */, + A33BAF0C2F4CA08800614D0C /* A2UI in Frameworks */, + A33BAF412F4CCEA800614D0C /* A2UI in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A33BAEF32F4CA06E00614D0C = { + isa = PBXGroup; + children = ( + A33BAEFE2F4CA06E00614D0C /* A2UISampleApp */, + A33BAEFD2F4CA06E00614D0C /* Products */, + ); + sourceTree = ""; + }; + A33BAEFD2F4CA06E00614D0C /* Products */ = { + isa = PBXGroup; + children = ( + A33BAEFC2F4CA06E00614D0C /* A2UISampleApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A33BAEFB2F4CA06E00614D0C /* A2UISampleApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = A33BAF072F4CA06F00614D0C /* Build configuration list for PBXNativeTarget "A2UISampleApp" */; + buildPhases = ( + A33BAEF82F4CA06E00614D0C /* Sources */, + A33BAEF92F4CA06E00614D0C /* Frameworks */, + A33BAEFA2F4CA06E00614D0C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A33BAEFE2F4CA06E00614D0C /* A2UISampleApp */, + ); + name = A2UISampleApp; + packageProductDependencies = ( + A33BAF0B2F4CA08800614D0C /* A2UI */, + A33BAF402F4CCEA800614D0C /* A2UI */, + A3C9FD082F516211002606E5 /* A2UI */, + ); + productName = A2UISampleApp; + productReference = A33BAEFC2F4CA06E00614D0C /* A2UISampleApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A33BAEF42F4CA06E00614D0C /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2640; + LastUpgradeCheck = 2630; + TargetAttributes = { + A33BAEFB2F4CA06E00614D0C = { + CreatedOnToolsVersion = 26.3; + }; + }; + }; + buildConfigurationList = A33BAEF72F4CA06E00614D0C /* Build configuration list for PBXProject "A2UISampleApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A33BAEF32F4CA06E00614D0C; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + A3C9FD072F516211002606E5 /* XCLocalSwiftPackageReference "../../../../renderers/swift" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = A33BAEFD2F4CA06E00614D0C /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A33BAEFB2F4CA06E00614D0C /* A2UISampleApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A33BAEFA2F4CA06E00614D0C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A33BAEF82F4CA06E00614D0C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A33BAF052F4CA06F00614D0C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A33BAF062F4CA06F00614D0C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A33BAF082F4CA06F00614D0C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.a2ui.A2UISampleApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A33BAF092F4CA06F00614D0C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.a2ui.A2UISampleApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A33BAEF72F4CA06E00614D0C /* Build configuration list for PBXProject "A2UISampleApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A33BAF052F4CA06F00614D0C /* Debug */, + A33BAF062F4CA06F00614D0C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A33BAF072F4CA06F00614D0C /* Build configuration list for PBXNativeTarget "A2UISampleApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A33BAF082F4CA06F00614D0C /* Debug */, + A33BAF092F4CA06F00614D0C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + A3C9FD072F516211002606E5 /* XCLocalSwiftPackageReference "../../../../renderers/swift" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../../../renderers/swift; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A33BAF0B2F4CA08800614D0C /* A2UI */ = { + isa = XCSwiftPackageProductDependency; + productName = A2UI; + }; + A33BAF402F4CCEA800614D0C /* A2UI */ = { + isa = XCSwiftPackageProductDependency; + productName = A2UI; + }; + A3C9FD082F516211002606E5 /* A2UI */ = { + isa = XCSwiftPackageProductDependency; + productName = A2UI; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = A33BAEF42F4CA06E00614D0C /* Project object */; +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/samples/client/swift/A2UISampleApp/A2UISampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp.xcodeproj/xcshareddata/xcschemes/A2UISampleApp.xcscheme b/samples/client/swift/A2UISampleApp/A2UISampleApp.xcodeproj/xcshareddata/xcschemes/A2UISampleApp.xcscheme new file mode 100644 index 000000000..6722a8dcc --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp.xcodeproj/xcshareddata/xcschemes/A2UISampleApp.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/A2UIIcon.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/A2UIIcon.swift new file mode 100644 index 000000000..a727b4f4e --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/A2UIIcon.swift @@ -0,0 +1,146 @@ +import Foundation + +/// Supported Google Font / Material icon names. +enum A2UIIconName: String, CaseIterable { + case accountCircle + case add + case arrowBack + case arrowForward + case attachFile + case calendarToday + case call + case camera + case check + case close + case delete + case download + case edit + case event + case error + case fastForward + case favorite + case favoriteOff + case folder + case help + case home + case info + case locationOn + case lock + case lockOpen + case mail + case menu + case moreVert + case moreHoriz + case notificationsOff + case notifications + case pause + case payment + case person + case phone + case photo + case play + case print + case refresh + case rewind + case search + case send + case settings + case share + case shoppingCart + case skipNext + case skipPrevious + case star + case starHalf + case starOff + case stop + case upload + case visibility + case visibilityOff + case volumeDown + case volumeMute + case volumeOff + case volumeUp + case warning + + /// The SF Symbol equivalent for this Material icon name. + var sfSymbolName: String { + switch self { + case .accountCircle: return "person.circle" + case .add: return "plus" + case .arrowBack: return "arrow.left" + case .arrowForward: return "arrow.right" + case .attachFile: return "paperclip" + case .calendarToday: return "calendar" + case .call: return "phone" + case .camera: return "camera" + case .check: return "checkmark" + case .close: return "xmark" + case .delete: return "trash" + case .download: return "square.and.arrow.down" + case .edit: return "pencil" + case .event: return "calendar" + case .error: return "exclamationmark.circle" + case .fastForward: return "forward.fill" + case .favorite: return "heart.fill" + case .favoriteOff: return "heart" + case .folder: return "folder" + case .help: return "questionmark.circle" + case .home: return "house" + case .info: return "info.circle" + case .locationOn: return "mappin.and.ellipse" + case .lock: return "lock" + case .lockOpen: return "lock.open" + case .mail: return "envelope" + case .menu: return "line.3.horizontal" + case .moreVert: return "ellipsis.vertical" + case .moreHoriz: return "ellipsis" + case .notificationsOff: return "bell.slash" + case .notifications: return "bell" + case .pause: return "pause" + case .payment: return "creditcard" + case .person: return "person" + case .phone: return "phone" + case .photo: return "photo" + case .play: return "play" + case .print: return "printer" + case .refresh: return "arrow.clockwise" + case .rewind: return "backward.fill" + case .search: return "magnifyingglass" + case .send: return "paperplane" + case .settings: return "gear" + case .share: return "square.and.arrow.up" + case .shoppingCart: return "cart" + case .skipNext: return "forward.end" + case .skipPrevious: return "backward.end" + case .star: return "star" + case .starHalf: return "star.leadinghalf.filled" + case .starOff: return "star.slash" + case .stop: return "stop" + case .upload: return "square.and.arrow.up" + case .visibility: return "eye" + case .visibilityOff: return "eye.slash" + case .volumeDown: return "speaker.wave.1" + case .volumeMute: return "speaker.slash" + case .volumeOff: return "speaker.slash" + case .volumeUp: return "speaker.wave.3" + case .warning: return "exclamationmark.triangle" + } + } +} + +/// A utility to map Google Font / Material icon names to SF Symbols names. +enum IconMapper { + /// Returns the SF Symbol name for a given Material icon name string. + /// - Parameter materialIconName: The name of the Material icon. + /// - Returns: The SF Symbol name, or the original name if no mapping exists. + static func sfSymbolName(for materialIconName: String) -> String { + return A2UIIconName(rawValue: materialIconName)?.sfSymbolName ?? materialIconName + } +} + +extension String { + /// Converts a Material icon name to its SF Symbol equivalent. + var sfSymbolName: String { + return IconMapper.sfSymbolName(for: self) + } +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/A2UISampleApp.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/A2UISampleApp.swift new file mode 100644 index 000000000..e0debaa58 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/A2UISampleApp.swift @@ -0,0 +1,14 @@ +import SwiftUI +import A2UI + +@main +struct A2UIExplorerApp: App { + @State private var dataStore = A2UIDataStore() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(dataStore) + } + } +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Assets.xcassets/AccentColor.colorset/Contents.json b/samples/client/swift/A2UISampleApp/A2UISampleApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/samples/client/swift/A2UISampleApp/A2UISampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Assets.xcassets/Contents.json b/samples/client/swift/A2UISampleApp/A2UISampleApp/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/ComponentView.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/ComponentView.swift new file mode 100644 index 000000000..9a7e613b5 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/ComponentView.swift @@ -0,0 +1,483 @@ +import SwiftUI +import A2UI + +struct ComponentView: View { + @Environment(A2UIDataStore.self) var dataStore + @State private var jsonToShow: String? + @State private var jsonTitle: String? + @State private var component: GalleryComponent + @State private var actionLog: [(path: String, value: String)] = [] + private let numberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 4 + return formatter + }() + private let iso8601Formatter = ISO8601DateFormatter() + + init(component: GalleryComponent) { + self._component = State(initialValue: component) + } + + var body: some View { + VStack { + Rectangle() + .fill(.green) + .frame(maxWidth: .infinity) + .frame(height: 2) + A2UISurfaceView(surfaceId: component.id) + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2) + + Rectangle() + .fill(.green) + .frame(maxWidth: .infinity) + .frame(height: 2) + + if component.canEditProperties { + VStack(alignment: .leading, spacing: 10) { + ForEach($component.properties) { prop in + HStack { + Text(prop.wrappedValue.label) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + propertyEditor(for: prop) + } + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(10) + } + + Button(action: { + jsonTitle = "A2UI" + jsonToShow = component.prettyJson + }) { + Label("A2UI JSON", systemImage: "doc.text") + .font(.footnote) + } + .buttonStyle(PlainButtonStyle()) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(8) + + if component.canEditDataModel { + VStack(alignment: .leading, spacing: 10) { + ForEach($component.dataModelFields) { field in + if field.wrappedValue.showInEditor { + HStack { + Text(field.wrappedValue.label) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + dataModelEditor(for: field) + } + } + } + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(10) + } + + Button(action: { + jsonTitle = "Data Model" + jsonToShow = dataModelJson() + }) { + Label("Data Model JSON", systemImage: "doc.text") + .font(.footnote) + } + .buttonStyle(PlainButtonStyle()) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(8) + + if !actionLog.isEmpty { + VStack(alignment: .leading, spacing: 5) { + Text("Recent Actions") + .font(.caption) + .fontWeight(.bold) + .foregroundColor(.secondary) + + ForEach(0.. 3 { actionLog.removeLast() } + } + + // Example: Simulate a server response for "button_click" + if userAction.name == "button_click" { + let updateMsg = #"{"version":"v0.9","dataModelUpdate":{"surfaceId":"\#(component.id)","path":"/status","value":"Clicked at \#(timestamp)!"}}"# + dataStore.process(chunk: updateMsg) + dataStore.flush() + } + } + } + .sheet(isPresented: Binding( + get: { jsonToShow != nil }, + set: { if !$0 { jsonToShow = nil } } + )) { + NavigationView { + ScrollView { + Text(jsonToShow ?? "") + .font(.system(.body, design: .monospaced)) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle(jsonTitle ?? "JSON") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + jsonToShow = nil + } + } + } + } + } + .navigationTitle(component.id) + } + + private func updateSurface(for component: GalleryComponent) { + dataStore.process(chunk: component.updateComponentsA2UI) + dataStore.flush() + } + + @ViewBuilder + private func propertyEditor(for prop: Binding) -> some View { + if prop.wrappedValue.isDate { + DatePicker("", selection: propertyDateBinding(for: prop)) + .labelsHidden() + .onChange(of: prop.wrappedValue.value) { + updateSurface(for: component) + } + } else if prop.wrappedValue.isBoolean { + Toggle("", isOn: propertyBoolBinding(for: prop)) + .labelsHidden() + .onChange(of: prop.wrappedValue.value) { + updateSurface(for: component) + } + } else if !prop.wrappedValue.options.isEmpty { + Picker(prop.wrappedValue.label, selection: propertyStringBinding(for: prop)) { + ForEach(prop.wrappedValue.options, id: \.self) { option in + Text(option).tag(option) + } + } + .pickerStyle(.menu) + .onChange(of: prop.wrappedValue.value) { + updateSurface(for: component) + } + } else if let min = prop.wrappedValue.minValue, let max = prop.wrappedValue.maxValue { + HStack { + Slider(value: propertyNumericBinding(for: prop), in: min...max) + .frame(width: 100) + Text(prop.wrappedValue.value ?? "0") + .font(.caption) + .monospacedDigit() + .frame(width: 40, alignment: .trailing) + } + .onChange(of: prop.wrappedValue.value) { + updateSurface(for: component) + } + } else { + TextField("", text: propertyStringBinding(for: prop)) + .textFieldStyle(.roundedBorder) + .frame(width: 120) + .onChange(of: prop.wrappedValue.value) { + updateSurface(for: component) + } + } + } + + private func propertyStringBinding(for prop: Binding) -> Binding { + Binding( + get: { prop.wrappedValue.value ?? "" }, + set: { prop.wrappedValue.value = $0.isEmpty ? nil : $0 } + ) + } + + private func propertyNumericBinding(for prop: Binding) -> Binding { + Binding( + get: { + if let val = prop.wrappedValue.value { + return Double(val) ?? 0 + } + return 0 + }, + set: { newValue in + prop.wrappedValue.value = String(format: "%.0f", newValue) + } + ) + } + + private func propertyBoolBinding(for prop: Binding) -> Binding { + Binding( + get: { + prop.wrappedValue.value == "true" + }, + set: { newValue in + prop.wrappedValue.value = newValue ? "true" : "false" + } + ) + } + + private func propertyDateBinding(for prop: Binding) -> Binding { + Binding( + get: { + guard let value = prop.wrappedValue.value else { + return Date() + } + return iso8601Formatter.date(from: value) ?? Date() + }, + set: { newValue in + prop.wrappedValue.value = iso8601Formatter.string(from: newValue) + } + ) + } + + private func updateDataModel(for field: DataModelField) { + dataStore.process(chunk: field.updateDataModelA2UI(surfaceId: component.id)) + dataStore.flush() + } + + private func dataModelJson() -> String { + let dataModel = dataStore.surfaces[component.id]?.dataModel ?? buildDataModel() + + guard JSONSerialization.isValidJSONObject(dataModel), + let data = try? JSONSerialization.data(withJSONObject: dataModel, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]), + let pretty = String(data: data, encoding: .utf8) else { + return "{}" + } + return pretty + } + + private func buildDataModel() -> [String: Any] { + var root: [String: Any] = [:] + + for field in component.dataModelFields { + let segments = field.path.split(separator: "/").map(String.init) + guard !segments.isEmpty else { continue } + insert(value: field.value, into: &root, path: segments) + } + + return root + } + + private func insert(value: DataModelField.Value, into dict: inout [String: Any], path: [String]) { + guard let head = path.first else { return } + if path.count == 1 { + dict[head] = jsonValue(for: value) + return + } + + var child = dict[head] as? [String: Any] ?? [:] + insert(value: value, into: &child, path: Array(path.dropFirst())) + dict[head] = child + } + + private func jsonValue(for value: DataModelField.Value) -> Any { + switch value { + case .string(let stringValue): + return stringValue + case .number(let numberValue): + return numberValue + case .bool(let boolValue): + return boolValue + case .listObjects(let listValue): + return listValue + case .choice(let selected, _): + return selected + } + } + + @ViewBuilder + private func dataModelEditor(for field: Binding) -> some View { + switch field.wrappedValue.value { + case .string: + TextField("", text: stringBinding(for: field)) + .textFieldStyle(.roundedBorder) + .frame(width: 180) + case .number: + TextField("", value: numberBinding(for: field), formatter: numberFormatter) + .textFieldStyle(.roundedBorder) + .frame(width: 120) + case .bool: + Toggle("", isOn: boolBinding(for: field)) + .labelsHidden() + case .listObjects: + TextField("", text: listBinding(for: field)) + .textFieldStyle(.roundedBorder) + .frame(width: 180) + case .choice: + Picker("", selection: choiceBinding(for: field)) { + let options = getChoiceOptions(for: field.wrappedValue.value) + ForEach(options, id: \.self) { option in + Text(option).tag(option) + } + } + .pickerStyle(.menu) + } + } + + private func getChoiceOptions(for value: DataModelField.Value) -> [String] { + if case .choice(_, let options) = value { + return options + } + return [] + } + + private func choiceBinding(for field: Binding) -> Binding { + Binding( + get: { + if let surface = dataStore.surfaces[component.id], + let value = surface.getValue(at: field.wrappedValue.path) as? String { + return value + } + if case .choice(let selected, _) = field.wrappedValue.value { + return selected + } + return "" + }, + set: { newValue in + if case .choice(_, let options) = field.wrappedValue.value { + field.wrappedValue.value = .choice(newValue, options) + updateDataModel(for: field.wrappedValue) + } + } + ) + } + + private func stringBinding(for field: Binding) -> Binding { + Binding( + get: { + if let surface = dataStore.surfaces[component.id], + let value = surface.getValue(at: field.wrappedValue.path) as? String { + return value + } + if case .string(let value) = field.wrappedValue.value { + return value + } + return "" + }, + set: { newValue in + field.wrappedValue.value = .string(newValue) + updateDataModel(for: field.wrappedValue) + } + ) + } + + private func numberBinding(for field: Binding) -> Binding { + Binding( + get: { + if let surface = dataStore.surfaces[component.id], + let value = surface.getValue(at: field.wrappedValue.path) as? Double { + return value + } + if case .number(let value) = field.wrappedValue.value { + return value + } + return 0 + }, + set: { newValue in + field.wrappedValue.value = .number(newValue) + updateDataModel(for: field.wrappedValue) + } + ) + } + + private func boolBinding(for field: Binding) -> Binding { + Binding( + get: { + if let surface = dataStore.surfaces[component.id], + let value = surface.getValue(at: field.wrappedValue.path) as? Bool { + return value + } + if case .bool(let value) = field.wrappedValue.value { + return value + } + return false + }, + set: { newValue in + field.wrappedValue.value = .bool(newValue) + updateDataModel(for: field.wrappedValue) + } + ) + } + + private func listBinding(for field: Binding) -> Binding { + Binding( + get: { + if let surface = dataStore.surfaces[component.id], + let value = surface.getValue(at: field.wrappedValue.path) as? [[String: Any]] { + return jsonArrayLiteral(from: value) + } + if case .listObjects(let value) = field.wrappedValue.value { + return jsonArrayLiteral(from: value) + } + return "" + }, + set: { newValue in + let parsed = jsonArrayObjects(from: newValue) ?? [] + field.wrappedValue.value = .listObjects(parsed) + updateDataModel(for: field.wrappedValue) + } + ) + } + + private func jsonArrayObjects(from jsonArrayValue: String) -> [[String: Any]]? { + guard let data = jsonArrayValue.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let array = object as? [[String: Any]] else { + return nil + } + return array + } + + private func jsonArrayLiteral(from listValue: [[String: Any]]) -> String { + guard JSONSerialization.isValidJSONObject(listValue), + let data = try? JSONSerialization.data(withJSONObject: listValue), + let jsonString = String(data: data, encoding: .utf8) else { + return "[]" + } + return jsonString + } +} + +#Preview { + NavigationView { + ComponentView(component: GalleryComponent.row) + .environment(A2UIDataStore()) + } +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/ContentView.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/ContentView.swift new file mode 100644 index 000000000..2570e9d9f --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/ContentView.swift @@ -0,0 +1,95 @@ +import SwiftUI +import A2UI + +struct ContentView: View { + @Environment(A2UIDataStore.self) var dataStore + @State private var selectedComponent: String? + + var body: some View { + NavigationView { + List { + Section(header: Text("Gallery")) { + ForEach(ComponentCategory.allCases, id: \.self) { category in + NavigationLink { + List { + let components = GalleryData.components(for: category) + ForEach(components, id: \.self) { component in + NavigationLink { + ComponentView(component: component) + } label: { + Label(component.id, systemImage: component.systemImage) + } + } + } + .navigationTitle(category.rawValue) + } label: { + Label(category.rawValue, systemImage: category.systemImage) + } + } + } + + Section(header: Text("App")) { + NavigationLink { + ResourcesView() + } label: { + Label("Resources", systemImage: "books.vertical.fill") + } + } + } + .navigationTitle("A2UI Gallery") + } + } +} + +enum ComponentCategory: String, CaseIterable { + case layout = "Layout" + case content = "Content" + case input = "Input" + case navigation = "Navigation" + case decoration = "Decoration" + case functions = "Functions" + + var systemImage: String { + switch self { + case .layout: return "rectangle.3.group" + case .content: return "doc.text" + case .input: return "keyboard" + case .navigation: return "filemenu.and.selection" + case .decoration: return "sparkles" + case .functions: return "function" + } + } +} + +extension GalleryComponent { + var systemImage: String { + switch id { + case "Row": return "rectangle.split.1x2" + case "Column": return "rectangle.split.2x1" + case "List": return "list.bullet" + case "Text": return "textformat" + case "Image": return "photo" + case "Icon": return "face.smiling" + case "Video": return "play.rectangle" + case "AudioPlayer": return "speaker.wave.2" + case "Button": return "hand.tap" + case "TextField": return "character.cursor.ibeam" + case "CheckBox": return "checkmark.square" + case "Slider": return "slider.horizontal.3" + case "DateTimeInput": return "calendar" + case "ChoicePicker": return "list.bullet.rectangle" + case "Tabs": return "menubar.rectangle" + case "Modal": return "square.stack" + case "Divider": return "minus" + case "email": return "envelope" + case "required": return "questionmark.circle" + case "length": return "number" + case "regex": return "text.magnifyingglass" + case "numeric": return "123.rectangle" + case "formatDate": return "calendar" + case "formatCurrency": return "dollarsign.circle" + case "pluralize": return "list.bullet" + default: return "questionmark.circle" + } + } +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/DataModelField.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/DataModelField.swift new file mode 100644 index 000000000..73077734e --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/DataModelField.swift @@ -0,0 +1,61 @@ +import Foundation + +struct DataModelField: Identifiable { + enum Value { + case string(String) + case number(Double) + case bool(Bool) + case listObjects([[String: Any]]) + case choice(String, [String]) + } + + let id = UUID() + let path: String + let label: String + var value: Value + var showInEditor: Bool = true + + func updateDataModelA2UI(surfaceId: String) -> String { + let valueJson: String + switch value { + case .string(let stringValue): + valueJson = jsonLiteral(from: stringValue) + case .number(let numberValue): + valueJson = "\(numberValue)" + case .bool(let boolValue): + valueJson = boolValue ? "true" : "false" + case .listObjects(let listValue): + valueJson = jsonArrayLiteral(from: listValue) + case .choice(let selected, _): + valueJson = jsonLiteral(from: selected) + } + return #"{"version":"v0.9","updateDataModel":{"surfaceId":"\#(surfaceId)","path":"\#(path)","value":\#(valueJson)}}"# + } + + private func jsonLiteral(from stringValue: String) -> String { + if let data = stringValue.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + JSONSerialization.isValidJSONObject(object), + let jsonData = try? JSONSerialization.data(withJSONObject: object), + let jsonString = String(data: jsonData, encoding: .utf8) { + return jsonString + } + + guard let data = try? JSONSerialization.data(withJSONObject: [stringValue]), + let wrapped = String(data: data, encoding: .utf8), + wrapped.count >= 2 else { + return "\"\"" + } + + return String(wrapped.dropFirst().dropLast()) + } + + private func jsonArrayLiteral(from listValue: [[String: Any]]) -> String { + guard JSONSerialization.isValidJSONObject(listValue), + let jsonData = try? JSONSerialization.data(withJSONObject: listValue), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return "[]" + } + return jsonString + } +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/AudioPlayer.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/AudioPlayer.swift new file mode 100644 index 000000000..5141eb027 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/AudioPlayer.swift @@ -0,0 +1,16 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let audioPlayer: Self = { + return .init( + id: "AudioPlayer", + template: #"{"id":"gallery_component","component":{"AudioPlayer":{"url":{"path":"/url"}}}}"#, + staticComponents: [.root], + dataModelFields: [ + .init(path: "/url", label: "Video URL", value: .string("https://diviextended.com/wp-content/uploads/2021/10/sound-of-waves-marine-drive-mumbai.mp3")) + ], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Icon.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Icon.swift new file mode 100644 index 000000000..ff861f7c3 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Icon.swift @@ -0,0 +1,17 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let icon: Self = { + let allIconNames = A2UIIconName.allCases.map { $0.rawValue } + return .init( + id: "Icon", + template: #"{"id":"gallery_component","component":{"Icon":{"name":{"path":"/name"}}}}"#, + staticComponents: [.root], + dataModelFields: [ + .init(path: "/name", label: "Icon Name", value: .choice(A2UIIconName.search.rawValue, allIconNames)) + ], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Image.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Image.swift new file mode 100644 index 000000000..8c069cb80 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Image.swift @@ -0,0 +1,19 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let image: Self = { + return .init( + id: "Image", + template: #"{"id":"gallery_component","component":{"Image":{"url":{"path":"/url"},"variant":"{{\#(variantKey)}}","fit":"{{\#(fitKey)}}"}}}"#, + staticComponents: [.root], + dataModelFields: [ + .init(path: "/url", label: "Image URL", value: .string("https://picsum.photos/200")) + ], + properties: [ + PropertyDefinition(key: variantKey, label: "Variant", options: A2UIImageVariant.allCases.map { $0.rawValue }, value: A2UIImageVariant.icon.rawValue), + PropertyDefinition(key: fitKey, label: "Fit", options: A2UIImageFit.allCases.map { $0.rawValue }, value: A2UIImageFit.contain.rawValue) + ] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Text.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Text.swift new file mode 100644 index 000000000..75f508143 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Text.swift @@ -0,0 +1,18 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let text: Self = { + return .init( + id: "Text", + template: #"{"id":"gallery_component","component":{"Text":{"text":{"path":"/text"},"variant":"{{\#(variantKey)}}"}}}"#, + staticComponents: [.root], + dataModelFields: [ + .init(path: "/text", label: "Text", value: .string("Sample text")), + ], + properties: [ + PropertyDefinition(key: variantKey, label: "Variant", options: A2UITextVariant.allCases.map { $0.rawValue }, value: A2UITextVariant.body.rawValue) + ] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Video.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Video.swift new file mode 100644 index 000000000..bf98d685f --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Video.swift @@ -0,0 +1,16 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let video: Self = { + return .init( + id: "Video", + template: #"{"id":"gallery_component","component":{"Video":{"url":{"path":"/url"}}}}"#, + staticComponents: [.root], + dataModelFields: [ + .init(path: "/url", label: "Video URL", value: .string("https://lorem.video/720p")) + ], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Decoration/Divider.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Decoration/Divider.swift new file mode 100644 index 000000000..17573deb7 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Decoration/Divider.swift @@ -0,0 +1,16 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let divider: Self = { + return .init( + id: "Divider", + template: #"{"id":"gallery_component","component":{"Divider":{}}}"#, + staticComponents: [.dividerRow, .dividerRoot, .dividerColumn, .dividerContainer, .body], + dataModelFields: [ + DataModelField(path: "/body/text", label: "Text", value: .string("Text")) + ], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/EmailFunction.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/EmailFunction.swift new file mode 100644 index 000000000..4fba54a22 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/EmailFunction.swift @@ -0,0 +1,16 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let emailFunction: Self = { + return .init( + id: "email", + template: #"{"id":"gallery_component","checks":[{"condition":{"call":"email","args":{"value":{"path":"/email"}}},"message":"Invalid email format"}],"component":{"TextField":{"value":{"path":"/email"},"label":"Email Validation Demo"}}}"#, + staticComponents: [.validationRoot, .validationPreview], + dataModelFields: [ + DataModelField(path: "/email", label: "Email", value: .string("test@example.com"), showInEditor: false) + ], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/FormatCurrencyFunction.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/FormatCurrencyFunction.swift new file mode 100644 index 000000000..99c2c0be9 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/FormatCurrencyFunction.swift @@ -0,0 +1,18 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let formatCurrencyFunction: Self = { + return .init( + id: "formatCurrency", + template: #"{"id":"gallery_component","component":{"Column":{"children":["t_body"],"justify":"center","align":"center"}}}"#, + staticComponents: [.root, .formatCurrencyText], + dataModelFields: [ + DataModelField(path: "/amount", label: "Amount", value: .number(1234.56), showInEditor: false) + ], + properties: [ + PropertyDefinition(key: "currencyCode", label: "Currency", options: ["USD", "EUR", "GBP", "JPY"], value: "USD") + ] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/FormatDateFunction.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/FormatDateFunction.swift new file mode 100644 index 000000000..4efe3ca18 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/FormatDateFunction.swift @@ -0,0 +1,17 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let formatDateFunction: Self = { + return .init( + id: "formatDate", + template: #"{"id":"gallery_component","component":{"Column":{"children":["t_body"],"justify":"center","align":"center"}}}"#, + staticComponents: [.root, .formatDateText], + dataModelFields: [ + DataModelField(path: "/date", label: "ISO Date", value: .string(Date.now.ISO8601Format()), showInEditor: false), + DataModelField(path: "/dateFormat", label: "Date Format", value: .string("MMM d, yyyy")) + ], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/LengthFunction.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/LengthFunction.swift new file mode 100644 index 000000000..3b2114241 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/LengthFunction.swift @@ -0,0 +1,16 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let lengthFunction: Self = { + return .init( + id: "length", + template: #"{"id":"gallery_component","checks":[{"condition":{"call":"length","args":{"value":{"path":"/username"},"min":3,"max":10}},"message":"Username must be between 3 and 10 characters"}],"component":{"TextField":{"value":{"path":"/username"},"label":"Length Demo (3-10 characters)"}}}"#, + staticComponents: [.validationRoot, .validationPreview], + dataModelFields: [ + DataModelField(path: "/username", label: "Username", value: .string("ab"), showInEditor: false) + ], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/NumericFunction.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/NumericFunction.swift new file mode 100644 index 000000000..da4141524 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/NumericFunction.swift @@ -0,0 +1,16 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let numericFunction: Self = { + return .init( + id: "numeric", + template: #"{"id":"gallery_component","checks":[{"condition":{"call":"numeric","args":{"value":{"path":"/age"},"min":18,"max":99}},"message":"Age must be between 18 and 99"}],"component":{"Slider":{"value":{"path":"/age"},"label":"Numeric Demo (18-99)","min":0,"max":120}}}"#, + staticComponents: [.validationRoot, .validationPreview], + dataModelFields: [ + DataModelField(path: "/age", label: "Age", value: .number(25), showInEditor: false) + ], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/PluralizeFunction.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/PluralizeFunction.swift new file mode 100644 index 000000000..25d3a2abd --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/PluralizeFunction.swift @@ -0,0 +1,16 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let pluralizeFunction: Self = { + return .init( + id: "pluralize", + template: #"{"id":"gallery_component","component":{"Column":{"children":["gallery_input","t_body"],"justify":"center","align":"center"}}}"#, + staticComponents: [.root, .pluralizeText, .pluralizeInput], + dataModelFields: [ + DataModelField(path: "/count", label: "Count", value: .number(1), showInEditor: false) + ], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/RegexFunction.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/RegexFunction.swift new file mode 100644 index 000000000..31e7908e9 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/RegexFunction.swift @@ -0,0 +1,16 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let regexFunction: Self = { + return .init( + id: "regex", + template: #"{"id":"gallery_component","checks":[{"condition":{"call":"regex","args":{"value":{"path":"/code"},"pattern":"^[A-Z]{3}-[0-9]{3}$"}},"message":"Format must be AAA-000"}],"component":{"TextField":{"value":{"path":"/code"},"label":"Regex Demo (AAA-000)"}}}"#, + staticComponents: [.validationRoot, .validationPreview], + dataModelFields: [ + DataModelField(path: "/code", label: "Code", value: .string("ABC-12"), showInEditor: false) + ], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/RequiredFunction.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/RequiredFunction.swift new file mode 100644 index 000000000..40ca2e4f7 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/RequiredFunction.swift @@ -0,0 +1,16 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let requiredFunction: Self = { + return .init( + id: "required", + template: #"{"id":"gallery_component","checks":[{"condition":{"call":"required","args":{"value":{"path":"/name"}}},"message":"Name is required"}],"component":{"TextField":{"value":{"path":"/name"},"label":"Required Demo"}}}"#, + staticComponents: [.validationRoot, .validationPreview], + dataModelFields: [ + DataModelField(path: "/name", label: "Name", value: .string(""), showInEditor: false) + ], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/Button.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/Button.swift new file mode 100644 index 000000000..10005a839 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/Button.swift @@ -0,0 +1,14 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let button: Self = { + return .init( + id: "Button", + template: #"{"id":"gallery_component","component":{"Button":{"child":"button_child","action":{"event":{"name": "button_click"}}}}}"#, + staticComponents: [.root, .buttonChild], + dataModelFields: [], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/CheckBox.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/CheckBox.swift new file mode 100644 index 000000000..da94d6a74 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/CheckBox.swift @@ -0,0 +1,17 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let checkbox: Self = { + return .init( + id: "CheckBox", + template: #"{"id":"gallery_component","component":{"CheckBox":{"value":{"path":"/value"},"label":{"path":"/label"}}}}"#, + staticComponents: [.checkboxRoot, .checkboxValue, .checkboxPreview], + dataModelFields: [ + DataModelField(path: "/label", label: "Label", value: .string("Toggle")), + DataModelField(path: "/value", label: "", value: .bool(false), showInEditor: false) + ], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/ChoicePicker.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/ChoicePicker.swift new file mode 100644 index 000000000..460cdf3a2 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/ChoicePicker.swift @@ -0,0 +1,50 @@ +import Foundation +import A2UI + +extension GalleryComponent { + var options: [[String:Any]] { + [ + [ + "label": "Option 1", + "value": "option1" + ], + [ + "label": "Option 2", + "value": "option2" + ], + [ + "label": "Option 3", + "value": "option3" + ] + ] + } + static let choicePicker: Self = { + return .init( + id: "ChoicePicker", + template: #"{"id":"gallery_component","component":{"ChoicePicker":{"label":{"path":"/label"},"variant":"{{\#(choicePickerVariantKey)}}","options":[{"label":{"path":"/options/0/label"},"value":"option1"},{"label":{"path":"/options/1/label"},"value":"option2"},{"label":{"path":"/options/2/label"},"value":"option3"}],"value":{"path":"/value"}}}}"#, + staticComponents: [.choicePickerRoot, .choicePickerPreview, .valueText], + dataModelFields: [ + DataModelField(path: "/options", label: "Options", value: .listObjects([ + [ + "label": "Option 1", + "value": "option1" + ], + [ + "label": "Option 2", + "value": "option2" + ], + [ + "label": "Option 3", + "value": "option3" + ] + ]), showInEditor: false), + DataModelField(path: "/value", label: "Selected", value: .listObjects([]), showInEditor: false), + DataModelField(path: "/label", label: "Label", value: .string("Picker")) + + ], + properties: [ + PropertyDefinition(key: choicePickerVariantKey, label: "Type", options: ChoicePickerVariant.allCases.map(\.rawValue), value: ChoicePickerVariant.mutuallyExclusive.rawValue) + ] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/DateTimeInput.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/DateTimeInput.swift new file mode 100644 index 000000000..37e63d68f --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/DateTimeInput.swift @@ -0,0 +1,22 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let dateTimeInput: Self = { + return .init( + id: "DateTimeInput", + template: #"{"id":"gallery_component","component":{"DateTimeInput":{"value":{"path":"/value"},"label":{"path":"/label"},"enableDate":{{\#(enableDateKey)}},"enableTime":{{\#(enableTimeKey)}},"min":"{{\#(minDateKey)}}","max":"{{\#(maxDateKey)}}"}}}"#, + staticComponents: [.datetimeRoot, .datetimePreview, .valueText], + dataModelFields: [ + DataModelField(path: "/value", label: "Date", value: .string(""), showInEditor: false), + DataModelField(path: "/label", label: "Label", value: .string("DateTime")), + ], + properties: [ + PropertyDefinition(key: enableDateKey, label: "Show Date", value: "true", isBoolean: true), + PropertyDefinition(key: enableTimeKey, label: "Show Time", value: "true", isBoolean: true), + PropertyDefinition(key: minDateKey, label: "Min.", value: Calendar.current.startOfDay(for: .now).ISO8601Format(), isDate: true), + PropertyDefinition(key: maxDateKey, label: "Max.", value: Calendar.current.date(byAdding: .year, value: 1, to: .now)!.ISO8601Format(), isDate: true) + ] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/Slider.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/Slider.swift new file mode 100644 index 000000000..03fc28b26 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/Slider.swift @@ -0,0 +1,20 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let slider: Self = { + return .init( + id: "Slider", + template: #"{"id":"gallery_component","component":{"Slider":{"label":{"path":"/label"},"value":{"path":"/value"},"min":{{\#(minKey)}},"max":{{\#(maxKey)}}}}}"#, + staticComponents: [.sliderRoot, .sliderPreview, .valueText], + dataModelFields: [ + DataModelField(path: "/value", label: "Value", value: .number(50), showInEditor: false), + DataModelField(path: "/label", label: "Label", value: .string("Slider")), + ], + properties: [ + PropertyDefinition(key: minKey, label: "Min", value: "0", minValue: 0, maxValue: 50), + PropertyDefinition(key: maxKey, label: "Max", value: "100", minValue: 51, maxValue: 200) + ] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/TextField.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/TextField.swift new file mode 100644 index 000000000..16389aeb1 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/TextField.swift @@ -0,0 +1,30 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let textField: Self = { + let functions: [StandardCheckFunction] = [.email, .required] + return .init( + id: "TextField", + template: #"{"id":"gallery_component","checks":[{{\#(checkFunctionKey)}}],"component":{"TextField":{"value":{"path":"/body/text"},"label":{"path":"/label"},"variant":"{{\#(textFieldVariantKey)}}"}}}"#, + staticComponents: [.textFieldRoot, .body, .textFieldPreview], + dataModelFields: [ + DataModelField(path: "/label", label: "Placeholder", value: .string("Enter text")), + DataModelField(path: "/body/text", label: "", value: .string(""), showInEditor: false), + ], + properties: [ + PropertyDefinition(key: textFieldVariantKey, label: "Type", options: TextFieldVariant.allCases.map(\.rawValue), value: TextFieldVariant.shortText.rawValue), + PropertyDefinition( + key: checkFunctionKey, + label: "Check", + options: ["None"] + functions.map(\.rawValue), + value: "None", + mapValue: { value in + guard let funcName = value, funcName != "None" else { return "" } + return #"{"condition":{"call":"\#(funcName)","args":{"value":{"path":"/body/text"}}},"message":"Validation failed"}"# + } + ) + ] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/Column.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/Column.swift new file mode 100644 index 000000000..11ef0bae9 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/Column.swift @@ -0,0 +1,21 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let column: Self = { + return .init( + id: "Column", + template: #"{"id":"gallery_component","component":{"Column":{"children":["t_h2","t_body","t_caption"],"justify":"{{\#(justifyKey)}}","align":"{{\#(alignKey)}}"}}}"#, + staticComponents: [.root, .h2, .body, .caption], + dataModelFields: [ + .init(path: "/headline/text", label: "Headline", value: .string("Headline")), + .init(path: "/body/text", label: "Body", value: .string("Body text")), + .init(path: "/caption/text", label: "Caption", value: .string("Caption")) + ], + properties: [ + PropertyDefinition(key: justifyKey, label: "Justify", options: A2UIJustify.allCases.map { $0.rawValue }, value: A2UIJustify.start.rawValue), + PropertyDefinition(key: alignKey, label: "Align", options: A2UIAlign.allCases.map { $0.rawValue }, value: A2UIAlign.start.rawValue) + ] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/List.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/List.swift new file mode 100644 index 000000000..d8f7fe388 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/List.swift @@ -0,0 +1,16 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let list: Self = { + return .init( + id: "List", + template: #"{"id":"gallery_component","component":{"List":{"children":{"template":{"componentId":"card_content_container","path":"/items"}}}}}"#, + staticComponents: [.root, .cardContentContainer, .cardContentTop, .cardContentBottom, .listH2, .listBody, .listCaption], + dataModelFields: [ + .init(path: "/items", label: "Items (JSON array)", value: .listObjects((1...20).map { ["headline":"Headline \($0)","body":"Body text \($0)","caption":"Caption \($0)"] })) + ], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/Row.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/Row.swift new file mode 100644 index 000000000..ee707fada --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/Row.swift @@ -0,0 +1,21 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let row: Self = { + return .init( + id: "Row", + template: #"{"id":"gallery_component","component":{"Row":{"children":["t_h2","t_body","t_caption"],"justify":"{{\#(justifyKey)}}","align":"{{\#(alignKey)}}"}}}"#, + staticComponents: [.root, .h2, .body, .caption], + dataModelFields: [ + .init(path: "/headline/text", label: "Headline", value: .string("Headline")), + .init(path: "/body/text", label: "Body", value: .string("Body text")), + .init(path: "/caption/text", label: "Caption", value: .string("Caption")) + ], + properties: [ + PropertyDefinition(key: justifyKey, label: "Justify", options: A2UIJustify.allCases.map { $0.rawValue }, value: A2UIJustify.start.rawValue), + PropertyDefinition(key: alignKey, label: "Align", options: A2UIAlign.allCases.map { $0.rawValue }, value: A2UIAlign.start.rawValue) + ] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Navigation/Modal.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Navigation/Modal.swift new file mode 100644 index 000000000..08c9fead0 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Navigation/Modal.swift @@ -0,0 +1,14 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let modal: Self = { + return .init( + id: "Modal", + template: #"{"id":"gallery_component","component":{"Modal":{"content":"modal_content","trigger":"trigger_button"}}}"#, + staticComponents: [.root, .modalContent, .modalButton, .buttonChild], + dataModelFields: [], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Navigation/Tabs.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Navigation/Tabs.swift new file mode 100644 index 000000000..9afe405d7 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Navigation/Tabs.swift @@ -0,0 +1,14 @@ +import Foundation +import A2UI + +extension GalleryComponent { + static let tabs: Self = { + return .init( + id: "Tabs", + template: #"{"id":"gallery_component","component":{"Tabs":{"tabs":[{"title":"Tab 1","child":"tab1_content"},{"title":"Tab 2","child":"tab2_content"}]}}}"#, + staticComponents: [.root, .tab1, .tab2], + dataModelFields: [], + properties: [] + ) + }() +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/GalleryComponent.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/GalleryComponent.swift new file mode 100644 index 000000000..ee999957b --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/GalleryComponent.swift @@ -0,0 +1,73 @@ +import Foundation +import A2UI + +struct GalleryComponent: Identifiable, Hashable { + let id: String + let template: String + let staticComponents: [StaticComponent] + var dataModelFields: [DataModelField] + var canEditDataModel: Bool { + return !dataModelFields.isEmpty + } + var properties: [PropertyDefinition] + var canEditProperties: Bool { + return !properties.isEmpty + } + + mutating func setProperty(_ key: String, to value: String?) { + guard let index = properties.firstIndex(where: { $0.key == key }) else { return } + properties[index].value = value + } + + func resolveProperties(_ input: String) -> String { + var output = input + for prop in properties { + let replacement = prop.mapValue?(prop.value) ?? prop.value ?? "" + output = output.replacingOccurrences(of: "{{\(prop.key)}}", with: replacement) + } + return output + } + + var resolvedTemplate: String { + return resolveProperties(template) + } + + var a2ui: String { + let dataModelUpdates = dataModelFields.map { $0.updateDataModelA2UI(surfaceId: id) } + return ([createSurfaceA2UI, updateComponentsA2UI] + dataModelUpdates) + .joined(separator: "\n") + } + + var createSurfaceA2UI: String { + return #"{"version":"v0.9","createSurface":{"surfaceId":"\#(id)","catalogId":"a2ui.org:standard_catalog"}}"# + } + var updateComponentsA2UI: String { + return #"{"version":"v0.9","updateComponents":{"surfaceId":"\#(id)","components":[\#(resolvedComponents.joined(separator: ","))]}}"# + } + + var resolvedComponents: [String] { + return [resolvedTemplate] + staticComponents.map { resolveProperties($0.rawValue) } + } + + var prettyJson: String { + let objects: [Any] = resolvedComponents.compactMap { json in + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data) + } + guard !objects.isEmpty else { return "[]" } + let options: JSONSerialization.WritingOptions = [.prettyPrinted, .sortedKeys] + guard let data = try? JSONSerialization.data(withJSONObject: objects, options: options), + let pretty = String(data: data, encoding: .utf8) else { + return "[\n\(resolvedComponents.joined(separator: ",\n"))\n]" + } + return pretty + } + + static func == (lhs: GalleryComponent, rhs: GalleryComponent) -> Bool { + return lhs.resolvedTemplate == rhs.resolvedTemplate + } + func hash(into hasher: inout Hasher) { + hasher.combine(resolvedTemplate) + } +} + diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/GalleryData.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/GalleryData.swift new file mode 100644 index 000000000..3009e9864 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/GalleryData.swift @@ -0,0 +1,21 @@ +import Foundation +import A2UI + +struct GalleryData { + static func components(for category: ComponentCategory) -> [GalleryComponent] { + switch category { + case .layout: + return [.column, .list, .row] + case .content: + return [.audioPlayer, .icon, .image, .text, .video] + case .input: + return [.button, .checkbox, .choicePicker, .dateTimeInput, .slider, .textField] + case .navigation: + return [.modal, .tabs] + case .decoration: + return [.divider] + case .functions: + return [.emailFunction, .requiredFunction, .lengthFunction, .regexFunction, .numericFunction, .formatDateFunction, .formatCurrencyFunction, .pluralizeFunction] + } + } +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/PropertyDefinition.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/PropertyDefinition.swift new file mode 100644 index 000000000..bcfaee632 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/PropertyDefinition.swift @@ -0,0 +1,52 @@ +import Foundation + +struct PropertyDefinition: Identifiable { + var id: String { key } + let key: String + let label: String + let options: [String] + var value: String? + var minValue: Double? + var maxValue: Double? + var isBoolean: Bool + var isDate: Bool + var mapValue: ((String?) -> String)? + + init(key: String, label: String, options: [String] = [], value: String? = nil, minValue: Double? = nil, maxValue: Double? = nil, isBoolean: Bool = false, isDate: Bool = false, mapValue: ((String?) -> String)? = nil) { + self.key = key + self.label = label + self.options = options + self.value = value + self.minValue = minValue + self.maxValue = maxValue + self.isBoolean = isBoolean + self.isDate = isDate + self.mapValue = mapValue + } +} + +let justifyKey = "justify" +let alignKey = "align" +let variantKey = "variant" +let fitKey = "fit" +let iconNameKey = "iconName" +let textFieldVariantKey = "textFieldVariant" +let axisKey = "axis" +let choicePickerVariantKey = "choicePickerVariant" +let minKey = "min" +let maxKey = "max" +let enableDateKey = "enableDate" +let enableTimeKey = "enableTime" +let minDateKey = "min" +let maxDateKey = "max" +public enum StandardCheckFunction: String, Codable, Sendable, CaseIterable, Identifiable { + public var id: String { self.rawValue } + case required = "required" + case regex = "regex" + case length = "length" + case numeric = "numeric" + case email = "email" +} + +let checkFunctionKey = "checkFunction" + diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/ResourcesView.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/ResourcesView.swift new file mode 100644 index 000000000..812a79eb9 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/ResourcesView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct ResourcesView: View { + @Environment(\.openURL) var openURL + var body: some View { + List { + Text("A2UI on GitHub") + .onTapGesture { + openURL(URL(string:"https://github.com/google/a2ui")!) + } + Text("Sample App README") + .onTapGesture { + openURL(URL(string:"https://github.com/sunnypurewal/A2UI/blob/main/samples/client/swift/README.md")!) + } + Text("SwiftUI Renderer README") + .onTapGesture { + openURL(URL(string:"https://github.com/sunnypurewal/A2UI/blob/main/renderers/swift/README.md")!) + } + } + .listStyle(.plain) + .padding() + .navigationTitle("Resources") + } +} + +#Preview { + NavigationView { + ResourcesView() + } +} diff --git a/samples/client/swift/A2UISampleApp/A2UISampleApp/StaticComponent.swift b/samples/client/swift/A2UISampleApp/A2UISampleApp/StaticComponent.swift new file mode 100644 index 000000000..0ca192fed --- /dev/null +++ b/samples/client/swift/A2UISampleApp/A2UISampleApp/StaticComponent.swift @@ -0,0 +1,54 @@ +enum StaticComponent: String { + case root = #"{"id":"root","component":{"Card":{"child":"gallery_component"}}}"# + + case valueText = #"{"id":"value_text","component":{"Text":{"text":{"path":"/value"},"variant":"body"}}}"# + + case h2 = #"{"id":"t_h2","component":{"Text":{"text":{"path":"/headline/text"},"variant":"h2"}}}"# + case body = #"{"id":"t_body","component":{"Text":{"text":{"path":"/body/text"},"variant":"body"}}}"# + case caption = #"{"id":"t_caption","component":{"Text":{"text":{"path":"/caption/text"},"variant":"caption"}}}"# + + case cardContentContainer = #"{"id":"card_content_container","component":{"Column":{"children":["card_content_top","card_content_bottom"],"justify":"spaceAround","align":"center"}}}"# + case cardContentTop = #"{"id":"card_content_top","component":{"Row":{"children":["t_h2"],"justify":"start","align":"center"}}}"# + case cardContentBottom = #"{"id":"card_content_bottom","component":{"Row":{"children":["t_body","t_caption"],"justify":"spaceBetween","align":"center"}}}"# + + case listH2 = #"{"id":"t_h2","component":{"Text":{"text":{"path":"headline"},"variant":"h2"}}}"# + case listBody = #"{"id":"t_body","component":{"Text":{"text":{"path":"body"},"variant":"body"}}}"# + case listCaption = #"{"id":"t_caption","component":{"Text":{"text":{"path":"caption"},"variant":"caption"}}}"# + + case tab1 = #"{"id":"tab1_content","component":{"Text":{"text":"Tab 1 Content"}}}"# + case tab2 = #"{"id":"tab2_content","component":{"Text":{"text":"Tab 2 Content"}}}"# + + case modalContent = #"{"id":"modal_content","component":{"Text":{"text":"This is a modal"}}}"# + case modalButton = #"{"id":"trigger_button","component":{"Button":{"child":"button_child","action":{"functionCall":{"call": "button_click"}}}}}"# + + case textFieldRoot = #"{"id":"root","component":{"Card":{"child":"text_field_preview"}}}"# + case textFieldPreview = #"{"id":"text_field_preview","component":{"Column":{"children":["t_body","gallery_component"],"justify":"spaceBetween","align":"center"}}}"# + + case validationRoot = #"{"id":"root","component":{"Card":{"child":"validation_preview"}}}"# + case validationPreview = #"{"id":"validation_preview","component":{"Column":{"children":["gallery_component"],"justify":"spaceBetween","align":"center"}}}"# + + case checkboxRoot = #"{"id":"root","component":{"Card":{"child":"check_box_preview"}}}"# + case checkboxValue = #"{"id":"t_h2","component":{"Text":{"text":{"path":"/value"},"variant":"h2"}}}"# + case checkboxPreview = #"{"id":"check_box_preview","component":{"Column":{"children":["t_h2","gallery_component"],"justify":"spaceBetween","align":"center"}}}"# + + case choicePickerRoot = #"{"id":"root","component":{"Card":{"child":"choice_picker_preview"}}}"# + case choicePickerPreview = #"{"id":"choice_picker_preview","component":{"Column":{"children":["value_text","gallery_component"],"justify":"spaceAround","align":"center"}}}"# + + case sliderRoot = #"{"id":"root","component":{"Card":{"child":"slider_preview"}}}"# + case sliderPreview = #"{"id":"slider_preview","component":{"Column":{"children":["value_text","gallery_component"],"justify":"spaceBetween","align":"center"}}}"# + + case datetimeRoot = #"{"id":"root","component":{"Card":{"child":"datetime_preview"}}}"# + case datetimePreview = #"{"id":"datetime_preview","component":{"Column":{"children":["value_text","gallery_component"],"justify":"spaceAround","align":"center"}}}"# + + case buttonChild = #"{"id":"button_child","component":{"Text":{"text":"Tap Me"}}}"# + + case dividerRoot = #"{"id":"root","component":{"Card":{"child":"divider_preview"}}}"# + case dividerContainer = #"{"id":"divider_preview","component":{"Column":{"children":["divider_row","divider_column"],"justify":"spaceBetween","align":"center"}}}"# + case dividerColumn = #"{"id":"divider_column","component":{"Column":{"children":["t_body","gallery_component","t_body"],"justify":"spaceAround","align":"center"}}}"# + case dividerRow = #"{"id":"divider_row","component":{"Row":{"children":["t_body","gallery_component","t_body"],"justify":"spaceAround","align":"center"}}}"# + + case formatDateText = #"{"id":"t_body","component":{"Text":{"text":{"call":"formatDate","args":{"value":{"path":"/date"},"format":{"path":"/dateFormat"}}},"variant":"h2"}}}"# + case formatCurrencyText = #"{"id":"t_body","component":{"Text":{"text":{"call":"formatCurrency","args":{"value":{"path":"/amount"},"currency":"{{currencyCode}}"}},"variant":"h2"}}}"# + case pluralizeText = #"{"id":"t_body","component":{"Text":{"text":{"call":"pluralize","args":{"value":{"path":"/count"},"zero":"No items","one":"One item","other":"Multiple items"}},"variant":"h2"}}}"# + case pluralizeInput = #"{"id":"gallery_input","component":{"Slider":{"value":{"path":"/count"},"min":0,"max":10,"label":"Count"}}}"# +} diff --git a/samples/client/swift/A2UISampleApp/README.md b/samples/client/swift/A2UISampleApp/README.md new file mode 100644 index 000000000..abaec03f5 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/README.md @@ -0,0 +1,15 @@ +# A2UI Swift Explorer + +This directory contains the source code for the A2UI Explorer, a sample application demonstrating the capabilities of the Swift renderer. + +## Why no `.xcodeproj`? +To keep the open-source repository clean and avoid "Bundle Identifier" issues common with shared Xcode projects, we provide the raw source files. + +## How to Run (iOS Simulator / Mac) +1. In Xcode, go to **File > New > Project**. +2. Select **iOS > App** or **macOS > App**. +3. Name it **A2UIExplorer** (use your own Team/Organization identifier). +4. **Add Dependency**: Right-click your project, select **Add Package Dependencies...**, click **Add Local...**, and select the `renderers/swift` folder. +5. **Add Files**: Drag all `.swift` files from the `Samples/A2UIExplorer/A2UIExplorer/` folder into your new project. +6. **Clean Up**: Delete the default `ContentView.swift` and the `@main` struct in your generated `App.swift` (since `A2UIExplorerApp.swift` provides its own). +7. **Run**: Select your simulator and press **Cmd + R**. diff --git a/samples/client/swift/A2UISampleApp/build_output.txt b/samples/client/swift/A2UISampleApp/build_output.txt new file mode 100644 index 000000000..7c419d1c0 --- /dev/null +++ b/samples/client/swift/A2UISampleApp/build_output.txt @@ -0,0 +1,282 @@ +Command line invocation: + /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -scheme A2UISampleApp -sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 17 Pro Max" build + +Build settings from command line: + SDKROOT = iphonesimulator26.2 + +Resolve Package Graph + + +Resolved source packages: + A2UI: /Users/sunny/code/A2UI/renderers/swift @ local + +ComputePackagePrebuildTargetDependencyGraph + +Prepare packages + +CreateBuildRequest + +SendProjectDescription + +CreateBuildOperation + +ComputeTargetDependencyGraph +note: Building targets in dependency order +note: Target dependency graph (3 targets) + Target 'A2UISampleApp' in project 'A2UISampleApp' + ➜ Explicit dependency on target 'A2UI' in project 'A2UI' + Target 'A2UI' in project 'A2UI' + ➜ Explicit dependency on target 'A2UI' in project 'A2UI' + Target 'A2UI' in project 'A2UI' (no dependencies) + +GatherProvisioningInputs + +CreateBuildDescription + +ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -v -E -dM -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator26.2.sdk -x c -c /dev/null + +ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/usr/bin/actool --print-asset-tag-combinations --output-format xml1 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Assets.xcassets + +ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc --version + +ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/usr/bin/actool --version --output-format xml1 + +ExecuteExternalTool /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld -version_details + +Build description signature: a94890a5a80ec5ccb6b34df7e796db3a +Build description path: /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/XCBuildData/a94890a5a80ec5ccb6b34df7e796db3a.xcbuilddata +ClangStatCache /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang-stat-cache /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator26.2.sdk /Users/sunny/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/iphonesimulator26.2-23C57-7d00a8b37fbd7999ea79df8ebc024bf0.sdkstatcache + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp.xcodeproj + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang-stat-cache /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator26.2.sdk -o /Users/sunny/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/iphonesimulator26.2-23C57-7d00a8b37fbd7999ea79df8ebc024bf0.sdkstatcache + +ProcessProductPackaging "" /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/A2UISampleApp.app-Simulated.xcent (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + Entitlements: + + { + "application-identifier" = "2QZG92T633.org.a2ui.A2UISampleApp"; +} + + builtin-productPackagingUtility -entitlements -format xml -o /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/A2UISampleApp.app-Simulated.xcent + +ProcessProductPackagingDER /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/A2UISampleApp.app-Simulated.xcent /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/A2UISampleApp.app-Simulated.xcent.der (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + /usr/bin/derq query -f xml -i /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/A2UISampleApp.app-Simulated.xcent -o /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/A2UISampleApp.app-Simulated.xcent.der --raw + +SwiftDriver A2UISampleApp normal arm64 com.apple.xcode.tools.swift.compiler (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + builtin-SwiftDriver -- /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc -module-name A2UISampleApp -Onone -enforce-exclusivity\=checked @/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64/A2UISampleApp.SwiftFileList -DDEBUG -default-isolation\=MainActor -enable-bare-slash-regex -enable-upcoming-feature DisableOutwardActorInference -enable-upcoming-feature InferSendableFromCaptures -enable-upcoming-feature GlobalActorIsolatedTypesUsability -enable-upcoming-feature MemberImportVisibility -enable-upcoming-feature InferIsolatedConformances -enable-upcoming-feature NonisolatedNonsendingByDefault -enable-experimental-feature DebugDescriptionMacro -sdk /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator26.2.sdk -target arm64-apple-ios26.2-simulator -g -module-cache-path /Users/sunny/Library/Developer/Xcode/DerivedData/ModuleCache.noindex -Xfrontend -serialize-debugging-options -enable-testing -index-store-path /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Index.noindex/DataStore -Xcc -D_LIBCPP_HARDENING_MODE\=_LIBCPP_HARDENING_MODE_DEBUG -swift-version 5 -I /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Products/Debug-iphonesimulator -F /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Products/Debug-iphonesimulator/PackageFrameworks -F /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Products/Debug-iphonesimulator -emit-localized-strings -emit-localized-strings-path /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64 -c -j10 -enable-batch-mode -incremental -Xcc -ivfsstatcache -Xcc /Users/sunny/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/iphonesimulator26.2-23C57-7d00a8b37fbd7999ea79df8ebc024bf0.sdkstatcache -output-file-map /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64/A2UISampleApp-OutputFileMap.json -use-frontend-parseable-output -save-temps -no-color-diagnostics -explicit-module-build -module-cache-path /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -clang-scanner-module-cache-path /Users/sunny/Library/Developer/Xcode/DerivedData/ModuleCache.noindex -sdk-module-cache-path /Users/sunny/Library/Developer/Xcode/DerivedData/ModuleCache.noindex -serialize-diagnostics -emit-dependencies -emit-module -emit-module-path /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64/A2UISampleApp.swiftmodule -validate-clang-modules-once -clang-build-session-file /Users/sunny/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/Session.modulevalidation -Xcc -I/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/swift-overrides.hmap -emit-const-values -Xfrontend -const-gather-protocols-file -Xfrontend /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64/A2UISampleApp_const_extract_protocols.json -Xcc -iquote -Xcc /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/A2UISampleApp-generated-files.hmap -Xcc -I/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/A2UISampleApp-own-target-headers.hmap -Xcc -I/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/A2UISampleApp-all-non-framework-target-headers.hmap -Xcc -ivfsoverlay -Xcc /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp-3dedb373d2dbe95ac5378cbd6bd80056-VFS-iphonesimulator/all-product-headers.yaml -Xcc -iquote -Xcc /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/A2UISampleApp-project-headers.hmap -Xcc -I/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Products/Debug-iphonesimulator/include -Xcc -I/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/DerivedSources-normal/arm64 -Xcc -I/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/DerivedSources/arm64 -Xcc -I/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/DerivedSources -Xcc -DDEBUG\=1 -emit-objc-header -emit-objc-header-path /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64/A2UISampleApp-Swift.h -working-directory /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp -experimental-emit-module-separately -disable-cmo + +ProcessInfoPlistFile /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Products/Debug-iphonesimulator/A2UISampleApp.app/Info.plist /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/empty-A2UISampleApp.plist (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + builtin-infoPlistUtility /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/empty-A2UISampleApp.plist -producttype com.apple.product-type.application -genpkginfo /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Products/Debug-iphonesimulator/A2UISampleApp.app/PkgInfo -expandbuildsettings -format binary -platform iphonesimulator -additionalcontentfile /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/assetcatalog_generated_info.plist -o /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Products/Debug-iphonesimulator/A2UISampleApp.app/Info.plist + +SwiftCompile normal arm64 Compiling\ ComponentView.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ComponentView.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ComponentView.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftEmitModule normal arm64 Emitting\ module\ for\ A2UISampleApp (in target 'A2UISampleApp' from project 'A2UISampleApp') + +EmitSwiftModule normal arm64 (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 Compiling\ DataModelField.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/DataModelField.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/DataModelField.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 Compiling\ ContentView.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ContentView.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ContentView.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + +/Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ContentView.swift:14:25: error: cannot convert value of type '[GalleryComponent]' to expected argument type 'Binding' + List(GalleryData.components(for: category)) { component in + ^ +/Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ContentView.swift:14:8: error: generic parameter 'Data' could not be inferred + List(GalleryData.components(for: category)) { component in + ^ +SwiftUI.List.init:2:35: note: in call to initializer +@MainActor @preconcurrency public init(_ data: Binding, @ViewBuilder rowContent: @escaping (Binding) -> RowContent) where Content == ForEach, Data.Element.ID, RowContent>, Data : MutableCollection, Data : RandomAccessCollection, RowContent : View, Data.Element : Identifiable, Data.Index : Hashable} + ^ +/Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ContentView.swift:16:35: error: cannot convert value of type 'Binding' to expected argument type 'GalleryComponent' + ComponentView(component: component) + ^ +/Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ContentView.swift:18:53: error: cannot convert value of type 'Binding' to expected argument type 'String' + Label(component.id, systemImage: component.systemImage) + ^ + +Failed frontend command: +/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift-frontend -frontend -c /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/AudioPlayer.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Icon.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Image.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Text.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Video.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Decoration/Divider.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/EmailFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/FormatCurrencyFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/FormatDateFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/LengthFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/NumericFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/PluralizeFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/RegexFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/RequiredFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/Button.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/CheckBox.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/ChoicePicker.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/DateTimeInput.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/Slider.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/TextField.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/Column.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/List.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/Row.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Navigation/Modal.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Navigation/Tabs.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/A2UIIcon.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/A2UISampleApp.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ComponentView.swift -primary-file /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ContentView.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/DataModelField.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/GalleryComponent.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/GalleryData.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/PropertyDefinition.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ResourcesView.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/StaticComponent.swift /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/DerivedSources/GeneratedAssetSymbols.swift -emit-dependencies-path /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64/ContentView.d -emit-const-values-path /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64/ContentView.swiftconstvalues -emit-reference-dependencies-path /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64/ContentView.swiftdeps -serialize-diagnostics-path /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64/ContentView.dia -emit-localized-strings -emit-localized-strings-path /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64 -target arm64-apple-ios26.2-simulator -module-can-import-version DeveloperToolsSupport 23.0.4 23.0.4 -module-can-import-version SwiftUI 7.2.5.1 7.2.5 -module-can-import-version UIKit 9126.2.4.1 9126.2.4 -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/lib/swift/host/plugins/libFoundationMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/bin/swift-plugin-server\#FoundationMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/lib/swift/host/plugins/libObservationMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/bin/swift-plugin-server\#ObservationMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/lib/swift/host/plugins/libPreviewsMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/bin/swift-plugin-server\#PreviewsMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/lib/swift/host/plugins/libSwiftMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/bin/swift-plugin-server\#SwiftMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/lib/swift/host/plugins/libSwiftUIMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/bin/swift-plugin-server\#SwiftUIMacros -load-resolved-plugin /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/lib/swift/host/plugins/libTipKitMacros.dylib\#/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/bin/swift-plugin-server\#TipKitMacros -disable-implicit-swift-modules -Xcc -fno-implicit-modules -Xcc -fno-implicit-module-maps -explicit-swift-module-map-file /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64/A2UISampleApp-dependencies-1.json -Xllvm -aarch64-use-tbi -enable-objc-interop -sdk /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator26.2.sdk -I /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Products/Debug-iphonesimulator -F /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Products/Debug-iphonesimulator/PackageFrameworks -F /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Products/Debug-iphonesimulator -no-color-diagnostics -Xcc -fno-color-diagnostics -enable-testing -g -debug-info-format\=dwarf -dwarf-version\=5 -module-cache-path /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/SwiftExplicitPrecompiledModules -swift-version 5 -enforce-exclusivity\=checked -Onone -D DEBUG -serialize-debugging-options -const-gather-protocols-file /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64/A2UISampleApp_const_extract_protocols.json -enable-upcoming-feature DisableOutwardActorInference -enable-upcoming-feature InferSendableFromCaptures -enable-upcoming-feature GlobalActorIsolatedTypesUsability -enable-upcoming-feature MemberImportVisibility -enable-upcoming-feature InferIsolatedConformances -enable-upcoming-feature NonisolatedNonsendingByDefault -enable-experimental-feature DebugDescriptionMacro -enable-bare-slash-regex -default-isolation\=MainActor -empty-abi-descriptor -validate-clang-modules-once -clang-build-session-file /Users/sunny/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/Session.modulevalidation -Xcc -working-directory -Xcc /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp -enable-anonymous-context-mangled-names -file-compilation-dir /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp -Xcc -D_LIBCPP_HARDENING_MODE\=_LIBCPP_HARDENING_MODE_DEBUG -Xcc -ivfsstatcache -Xcc /Users/sunny/Library/Developer/Xcode/DerivedData/SDKStatCaches.noindex/iphonesimulator26.2-23C57-7d00a8b37fbd7999ea79df8ebc024bf0.sdkstatcache -Xcc -I/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/swift-overrides.hmap -Xcc -iquote -Xcc /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/A2UISampleApp-generated-files.hmap -Xcc -I/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/A2UISampleApp-own-target-headers.hmap -Xcc -I/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/A2UISampleApp-all-non-framework-target-headers.hmap -Xcc -ivfsoverlay -Xcc /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp-3dedb373d2dbe95ac5378cbd6bd80056-VFS-iphonesimulator/all-product-headers.yaml -Xcc -iquote -Xcc /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/A2UISampleApp-project-headers.hmap -Xcc -I/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Products/Debug-iphonesimulator/include -Xcc -I/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/DerivedSources-normal/arm64 -Xcc -I/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/DerivedSources/arm64 -Xcc -I/Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/DerivedSources -Xcc -DDEBUG\=1 -no-auto-bridging-header-chaining -module-name A2UISampleApp -frontend-parseable-output -disable-clang-spi -target-sdk-version 26.2 -target-sdk-name iphonesimulator26.2 -clang-target arm64-apple-ios26.2-simulator -in-process-plugin-server-path /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/host/libSwiftInProcPluginServer.dylib -o /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Build/Intermediates.noindex/A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64/ContentView.o -index-unit-output-path /A2UISampleApp.build/Debug-iphonesimulator/A2UISampleApp.build/Objects-normal/arm64/ContentView.o -index-store-path /Users/sunny/Library/Developer/Xcode/DerivedData/A2UISampleApp-dhyqhmxewjezoveeuctsnjynpnrv/Index.noindex/DataStore -index-system-modules +SwiftDriverJobDiscovery normal arm64 Compiling DataModelField.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 Compiling\ LengthFunction.swift,\ NumericFunction.swift,\ PluralizeFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/LengthFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/NumericFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/PluralizeFunction.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/LengthFunction.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/NumericFunction.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/PluralizeFunction.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 Compiling\ AudioPlayer.swift,\ Icon.swift,\ Image.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/AudioPlayer.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Icon.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Image.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/AudioPlayer.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Icon.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Image.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 Compiling\ Slider.swift,\ TextField.swift,\ Column.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/Slider.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/TextField.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/Column.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/Slider.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/TextField.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/Column.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 Compiling\ Modal.swift,\ Tabs.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Navigation/Modal.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Navigation/Tabs.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Navigation/Modal.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Navigation/Tabs.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 Compiling\ List.swift,\ Row.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/List.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/Row.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/List.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Layout/Row.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 Compiling\ RegexFunction.swift,\ RequiredFunction.swift,\ Button.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/RegexFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/RequiredFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/Button.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/RegexFunction.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/RequiredFunction.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/Button.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 Compiling\ GalleryComponent.swift,\ GalleryData.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/GalleryComponent.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/GalleryData.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/GalleryComponent.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/GalleryData.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 Compiling\ Text.swift,\ Video.swift,\ Divider.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Text.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Video.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Decoration/Divider.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Text.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Content/Video.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Decoration/Divider.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 Compiling\ CheckBox.swift,\ ChoicePicker.swift,\ DateTimeInput.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/CheckBox.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/ChoicePicker.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/DateTimeInput.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/CheckBox.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/ChoicePicker.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Input/DateTimeInput.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftDriverJobDiscovery normal arm64 Compiling Modal.swift, Tabs.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 Compiling\ EmailFunction.swift,\ FormatCurrencyFunction.swift,\ FormatDateFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/EmailFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/FormatCurrencyFunction.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/FormatDateFunction.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/EmailFunction.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/FormatCurrencyFunction.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/Gallery/Functions/FormatDateFunction.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftDriverJobDiscovery normal arm64 Compiling RegexFunction.swift, RequiredFunction.swift, Button.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftDriverJobDiscovery normal arm64 Compiling LengthFunction.swift, NumericFunction.swift, PluralizeFunction.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftDriverJobDiscovery normal arm64 Compiling AudioPlayer.swift, Icon.swift, Image.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftDriverJobDiscovery normal arm64 Compiling List.swift, Row.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftDriverJobDiscovery normal arm64 Compiling Slider.swift, TextField.swift, Column.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftDriverJobDiscovery normal arm64 Compiling GalleryComponent.swift, GalleryData.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftDriverJobDiscovery normal arm64 Compiling Text.swift, Video.swift, Divider.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftDriverJobDiscovery normal arm64 Compiling EmailFunction.swift, FormatCurrencyFunction.swift, FormatDateFunction.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftDriverJobDiscovery normal arm64 Compiling CheckBox.swift, ChoicePicker.swift, DateTimeInput.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftDriverJobDiscovery normal arm64 Compiling ComponentView.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 Compiling\ ResourcesView.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ResourcesView.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ResourcesView.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + cd /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp + + +SwiftDriverJobDiscovery normal arm64 Compiling ResourcesView.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + +** BUILD FAILED ** + + +The following build commands failed: + SwiftCompile normal arm64 /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ContentView.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + SwiftCompile normal arm64 Compiling\ ContentView.swift /Users/sunny/code/A2UI/samples/client/swift/A2UISampleApp/A2UISampleApp/ContentView.swift (in target 'A2UISampleApp' from project 'A2UISampleApp') + Building project A2UISampleApp with scheme A2UISampleApp +(3 failures) diff --git a/samples/client/swift/README.md b/samples/client/swift/README.md new file mode 100644 index 000000000..b0c36f21a --- /dev/null +++ b/samples/client/swift/README.md @@ -0,0 +1,23 @@ +# Sample App +The sample app attempts to demonstrate the correct functionality of the SwiftUI A2UI renderer. + +It shows the link between the 3 components of A2UI +1. Component adjacency list +2. Data model +3. Rendered UI + +## Gallery +- Each component can be viewed in the Gallery +- The **data model** and the **component adjacency list** are displayed as JSON. +- The bounds of the A2UI Surface are indicated by **green lines**. +- Some components have variants which can be specified through a **native** input control below the rendered component. + +## Component Types +- **Layout** components arrange child A2UI components. +- **Content** components display values from the data model and are non-interactive. +- **Input** components modify the data model. +- **Navigation** components toggle between child A2UI components +- **Decoration** components consist of only the Divider component + +## Functions +The A2UI basic catalog defines functions for the client to implement natively to be called by A2UI components. They fall into 3 categories: `validation`, `format`, and `logic`.