import SwiftUI
import SharedTypes
import UIKit
// The ENTIRE iOS shell renderer. Knows only the fixed Mobiler ABI — `Widget`
// (what to draw) + `Action` (what to send back). No app-specific types; this exact
// code renders any Mobiler app. Style *intent* (TextStyle, Tone, …) is decided in
// Rust; the concrete look (fonts, colors, dp) is decided here — the iOS twin of the
// Compose `Render`. Recursion is type-erased through `AnyView`.
func render(_ widget: SharedTypes.Widget, _ send: @escaping (Action) -> Void) -> AnyView {
switch widget {
// MARK: content
case .text(let content, let style):
return AnyView(Text(content).modifier(TextStyleMod(style)))
case .image(let source, let shape, let ratio):
// Size the box from a ratio'd `Color.clear` (an intrinsic-less sizing view)
// and let the image fill it as an overlay. Applying `.aspectRatio` to
// `AsyncImage` directly is unreliable. Local file URLs (a picked photo) load
// via UIImage — AsyncImage doesn't reliably fetch `file://`; remote URLs use
// AsyncImage.
let fill: AnyView
if source.hasPrefix("file:"), let img = URL(string: source).flatMap({ UIImage(contentsOfFile: $0.path) }) {
fill = AnyView(Image(uiImage: img).resizable().aspectRatio(contentMode: .fill))
} else {
fill = AnyView(AsyncImage(url: URL(string: source)) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: { Color.gray.opacity(0.15) })
}
return AnyView(
Color.clear
.aspectRatio(aspect(ratio), contentMode: .fit)
.overlay(fill)
.clipShape(imageShape(shape))
)
case .badge(let label, let tone):
let (bg, fg) = toneColors(tone)
return AnyView(
Text(label).font(.footnote.weight(.semibold))
.padding(.horizontal, 12).padding(.vertical, 5)
.background(bg).foregroundColor(fg).clipShape(Capsule())
)
case .colorDot(let color):
return AnyView(Circle().fill(projectColor(color)).frame(width: 12, height: 12))
case .divider:
return AnyView(Divider())
case .spacer(let size):
return AnyView(Color.clear.frame(height: spacing(size)))
// MARK: layout
case .row(let children):
return AnyView(HStack(spacing: 8) { childViews(children, send) })
case .column(let children):
return AnyView(VStack(alignment: .leading, spacing: 6) { childViews(children, send) })
case .card(let child, let style, let onPress):
let body = AnyView(render(child, send).padding(14).frame(maxWidth: .infinity, alignment: .leading).modifier(CardMod(style)))
if let token = onPress {
return AnyView(Button(action: { send(.fired(token: token)) }) { body }.buttonStyle(.plain))
}
return body
case .box(let children, let align, let scrim):
// A scrim box (hero banner) must be sized by its background — the first
// child, an image — not by the dimming layer. A bare `Color` is an
// infinitely greedy view, so leaving it as a ZStack sibling inflates the
// box to fill the scroll view. Instead the scrim + foreground ride along
// as an `.overlay` on the image (the SwiftUI twin of Compose's
// `matchParentSize()`): sized to the image, never driving layout. The
// clipShape matches the rounded hero image so the dim doesn't bleed corners.
if scrim, children.count > 1, let first = children.first {
return AnyView(
render(first, send).overlay(
ZStack(alignment: boxAlign(align)) {
Color.black.opacity(0.4)
VStack(alignment: .leading) { childViews(Array(children.dropFirst()), send) }
.padding().foregroundColor(.white)
}
.clipShape(RoundedRectangle(cornerRadius: 16))
)
)
}
return AnyView(
ZStack(alignment: boxAlign(align)) { childViews(children, send) }
)
case .grid(let children):
// Column count adapts to width: 2 on a phone (compact), more on iPad.
return AnyView(GridView(children: children, send: send))
// MARK: input / actions
case .button(let label, let style, let onPress):
return AnyView(Button(label) { send(.fired(token: onPress)) }.modifier(ButtonStyleMod(style)))
case .iconButton(let icon, let onPress):
return AnyView(
Button(action: { send(.fired(token: onPress)) }) {
Image(systemName: sfSymbol(icon)).foregroundColor(iconTint(icon))
}.buttonStyle(.plain)
)
case .chip(let label, let selected, let onPress):
return AnyView(
Button(action: { send(.fired(token: onPress)) }) {
Text(label).font(.subheadline)
.padding(.horizontal, 12).padding(.vertical, 6)
.background(selected ? Color.accentColor.opacity(0.18) : Color.gray.opacity(0.12))
.foregroundColor(selected ? Color.accentColor : .primary)
.overlay(Capsule().stroke(selected ? Color.accentColor : .clear))
.clipShape(Capsule())
}.buttonStyle(.plain)
)
case .textField(let id, let placeholder, let value):
return AnyView(
TextField(placeholder, text: Binding(
get: { value },
set: { send(.input(id: id, value: .text($0))) }
))
.textFieldStyle(.roundedBorder)
)
case .toggle(let id, let label, let value):
return AnyView(
Toggle(label, isOn: Binding(
get: { value },
set: { send(.input(id: id, value: .bool($0))) }
))
)
case .checkbox(let id, let label, let value):
// iOS has no native checkbox; a leading toggle-style control + label.
return AnyView(
Button(action: { send(.input(id: id, value: .bool(!value))) }) {
HStack(spacing: 10) {
Image(systemName: value ? "checkmark.square.fill" : "square")
.foregroundColor(value ? .accentColor : .secondary)
Text(label).foregroundColor(.primary)
Spacer()
}
}.buttonStyle(.plain)
)
case .slider(let id, let value, let max):
return AnyView(
Slider(
value: Binding(
get: { Double(value) },
set: { send(.input(id: id, value: .int(Int64($0.rounded())))) }
),
in: 0...Double(max)
)
)
case .stepper(let value, let onDecrement, let onIncrement):
return AnyView(
HStack(spacing: 12) {
Button("−") { send(.fired(token: onDecrement)) }.buttonStyle(.bordered)
Text("\(value)").font(.title3)
Button("+") { send(.fired(token: onIncrement)) }.buttonStyle(.bordered)
}
)
case .scaffold(let title, let body, let tabs, let back, let darkMode, let route, let depth):
return AnyView(ScaffoldView(
title: title, content: body, tabs: tabs, back: back,
darkMode: darkMode, route: route, depth: depth, send: send
))
}
}
/// Renders a `[Widget]` as sibling views (children of a stack/grid).
@ViewBuilder
private func childViews(_ children: [SharedTypes.Widget], _ send: @escaping (Action) -> Void) -> some View {
ForEach(Array(children.enumerated()), id: \.offset) { _, child in
render(child, send)
}
}
/// A responsive grid: 2 columns on a phone (compact width), 4 on an iPad (regular) —
/// the iOS twin of the web shell's `auto-fill` grid and Android's width-derived count.
private struct GridView: View {
let children: [SharedTypes.Widget]
let send: (Action) -> Void
@Environment(\.horizontalSizeClass) private var hSize
var body: some View {
let cols = hSize == .regular ? 4 : 2
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: cols), spacing: 12) {
childViews(children, send)
}
}
}
// MARK: - Scaffold (top bar + scrollable body + bottom tabs + theme-as-data)
private struct ScaffoldView: View {
let title: String
let content: SharedTypes.Widget
let tabs: [SharedTypes.Tab]
let back: String?
let darkMode: Bool
let route: String
let depth: UInt32
let send: (Action) -> Void
// Remember the depth of the previous route so a route change knows its
// direction (push vs pop). Updated after each route settles.
@State private var prevDepth: UInt32 = 0
// Regular width (iPad / large landscape) swaps the bottom tab-bar for a side rail.
@Environment(\.horizontalSizeClass) private var hSize
var body: some View {
let useRail = hSize == .regular && !tabs.isEmpty
Group {
if useRail {
HStack(spacing: 0) {
navRail
Divider()
mainColumn(showBottomTabs: false)
}
} else {
mainColumn(showBottomTabs: true)
}
}
.preferredColorScheme(darkMode ? .dark : .light)
.animation(.easeInOut(duration: 0.28), value: route)
// Edge-swipe to go back — the iOS idiom for Android's system BackHandler.
.simultaneousGesture(
DragGesture(minimumDistance: 24, coordinateSpace: .local).onEnded { value in
guard let back = back else { return }
let horizontal = abs(value.translation.width) > abs(value.translation.height)
if value.startLocation.x < 32, value.translation.width > 90, horizontal {
send(.fired(token: back))
}
}
)
// After each route settles, record its depth for the next transition.
.task(id: route) { prevDepth = depth }
}
// Top bar + scrollable body, optionally with the phone's bottom tab-bar.
private func mainColumn(showBottomTabs: Bool) -> some View {
VStack(spacing: 0) {
HStack {
if let back = back {
Button(action: { send(.fired(token: back)) }) {
Image(systemName: "chevron.left")
}
}
Spacer()
Text(title).font(.headline)
Spacer()
// keep the title centered when a back button is present
if back != nil { Image(systemName: "chevron.left").hidden() }
}
.padding()
Divider()
// The body is keyed by `route`, so a push/pop swaps the whole screen
// (with a slide+fade; lateral move crossfades); a same-route update
// just re-renders in place. The iOS twin of Android's AnimatedContent.
// On a regular width the column is capped + centered so it doesn't stretch.
ScrollView {
VStack(alignment: .leading, spacing: 6) { render(self.content, send) }
.padding(16)
.frame(maxWidth: hSize == .regular ? 760 : .infinity, alignment: .leading)
.frame(maxWidth: .infinity)
}
.id(route)
.transition(navTransition)
.frame(maxWidth: .infinity, maxHeight: .infinity)
if showBottomTabs && !tabs.isEmpty {
Divider()
HStack {
ForEach(Array(tabs.enumerated()), id: \.offset) { _, tab in
Button(action: { send(.fired(token: tab.onSelect)) }) {
Text(tab.label)
.fontWeight(tab.selected ? .semibold : .regular)
.foregroundColor(tab.selected ? .accentColor : .secondary)
.frame(maxWidth: .infinity)
}
}
}
.padding(.vertical, 10)
}
}
}
// Vertical navigation rail (regular width) — the iPad twin of the bottom tabs.
private var navRail: some View {
VStack(spacing: 4) {
ForEach(Array(tabs.enumerated()), id: \.offset) { _, tab in
Button(action: { send(.fired(token: tab.onSelect)) }) {
Text(tab.label)
.fontWeight(tab.selected ? .semibold : .regular)
.foregroundColor(tab.selected ? .accentColor : .secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 12)
.padding(.horizontal, 14)
.background(tab.selected ? Color.accentColor.opacity(0.12) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.buttonStyle(.plain)
}
Spacer()
}
.padding(.vertical, 12)
.padding(.horizontal, 8)
.frame(width: 220)
}
private var navTransition: AnyTransition {
if depth == prevDepth { return .opacity } // lateral move → crossfade
let forward = depth > prevDepth // push vs pop
return .asymmetric(
insertion: .move(edge: forward ? .trailing : .leading).combined(with: .opacity),
removal: .move(edge: forward ? .leading : .trailing).combined(with: .opacity)
)
}
}
// MARK: - Style-token → concrete look (the only place that decides looks)
private struct TextStyleMod: ViewModifier {
let style: TextStyle
init(_ s: TextStyle) { style = s }
func body(content: Content) -> some View {
switch style {
case .title: return AnyView(content.font(.largeTitle.bold()))
case .subtitle: return AnyView(content.font(.title3.weight(.semibold)))
case .caption: return AnyView(content.font(.footnote).foregroundColor(.secondary))
case .emphasis: return AnyView(content.font(.body.weight(.semibold)))
case .body: return AnyView(content.font(.body))
}
}
}
private struct ButtonStyleMod: ViewModifier {
let style: SharedTypes.ButtonStyle
init(_ s: SharedTypes.ButtonStyle) { style = s }
func body(content: Content) -> some View {
switch style {
case .filled: return AnyView(content.buttonStyle(.borderedProminent))
case .outlined: return AnyView(content.buttonStyle(.bordered))
case .text: return AnyView(content.buttonStyle(.borderless))
}
}
}
private struct CardMod: ViewModifier {
let style: CardStyle
init(_ s: CardStyle) { style = s }
func body(content: Content) -> some View {
let shape = RoundedRectangle(cornerRadius: 14)
switch style {
case .elevated:
return AnyView(content.background(shape.fill(Color(.secondarySystemBackground)))
.shadow(color: .black.opacity(0.08), radius: 4, y: 2))
case .filled:
return AnyView(content.background(shape.fill(Color(.tertiarySystemBackground))))
case .outlined:
return AnyView(content.overlay(shape.stroke(Color.gray.opacity(0.3))))
}
}
}
private func spacing(_ s: Spacing) -> CGFloat {
switch s { case .xs: return 4; case .sm: return 8; case .md: return 12; case .lg: return 16; case .xl: return 24 }
}
private func toneColors(_ tone: Tone) -> (Color, Color) {
switch tone {
case .neutral: return (Color.gray.opacity(0.15), .secondary)
case .success: return (Color.green.opacity(0.15), .green)
case .warning: return (Color.orange.opacity(0.15), .orange)
case .danger: return (Color.red.opacity(0.15), .red)
case .info: return (Color.accentColor.opacity(0.15), .accentColor)
}
}
private func projectColor(_ c: ProjectColor) -> Color {
switch c {
case .indigo: return Color(red: 0.36, green: 0.42, blue: 0.75)
case .teal: return Color(red: 0.15, green: 0.65, blue: 0.60)
case .coral: return Color(red: 1.0, green: 0.44, blue: 0.26)
case .amber: return Color(red: 1.0, green: 0.70, blue: 0.0)
case .lime: return Color(red: 0.61, green: 0.80, blue: 0.40)
case .pink: return Color(red: 0.93, green: 0.25, blue: 0.48)
}
}
private func sfSymbol(_ icon: Icon) -> String {
switch icon {
case .delete: return "trash"
case .add: return "plus"
case .edit: return "pencil"
case .close: return "xmark"
case .settings: return "gearshape"
case .check: return "checkmark"
case .star: return "star.fill"
}
}
private func iconTint(_ icon: Icon) -> Color {
switch icon { case .star: return .accentColor; default: return .primary }
}
private func imageShape(_ s: ImageShape) -> AnyShape {
switch s {
case .square: return AnyShape(Rectangle())
case .rounded: return AnyShape(RoundedRectangle(cornerRadius: 16))
case .circle: return AnyShape(Circle())
}
}
private func aspect(_ r: ImageRatio) -> CGFloat {
switch r { case .wide: return 16.0 / 10.0; case .square: return 1.0; case .tall: return 3.0 / 4.0 }
}
private func boxAlign(_ a: BoxAlign) -> Alignment {
switch a {
case .topStart: return .topLeading
case .topEnd: return .topTrailing
case .center: return .center
case .bottomStart: return .bottomLeading
case .bottomCenter: return .bottom
case .bottomEnd: return .bottomTrailing
}
}