diff --git a/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/AppComponent.kt b/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/AppComponent.kt index f80540f6..ce1127a7 100644 --- a/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/AppComponent.kt +++ b/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/AppComponent.kt @@ -2,6 +2,8 @@ package software.amazon.app.platform.recipes import me.tatarka.inject.annotations.IntoSet import me.tatarka.inject.annotations.Provides +import software.amazon.app.platform.presenter.molecule.MoleculeScopeFactory +import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter import software.amazon.app.platform.scope.Scoped import software.amazon.app.platform.scope.coroutine.CoroutineScopeScoped import software.amazon.lastmile.kotlin.inject.anvil.AppScope @@ -28,4 +30,10 @@ interface AppComponent { * needed. */ @Provides @IntoSet @ForScope(AppScope::class) fun provideEmptyScoped(): Scoped = Scoped.NO_OP + + /** The root presenter for the SwiftUI recipe. */ + val swiftUiHomePresenter: SwiftUiHomePresenter + + /** Factory needed to launch presenters from native. */ + val moleculeScopeFactory: MoleculeScopeFactory } diff --git a/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/DemoApplication.kt b/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/DemoApplication.kt index c7d42595..54de838d 100644 --- a/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/DemoApplication.kt +++ b/recipes/app/src/commonMain/kotlin/software/amazon/app/platform/recipes/DemoApplication.kt @@ -4,6 +4,7 @@ import software.amazon.app.platform.scope.RootScopeProvider import software.amazon.app.platform.scope.Scope import software.amazon.app.platform.scope.coroutine.addCoroutineScopeScoped import software.amazon.app.platform.scope.di.addKotlinInjectComponent +import software.amazon.app.platform.scope.di.kotlinInjectComponent import software.amazon.app.platform.scope.register /** @@ -17,6 +18,10 @@ class DemoApplication : RootScopeProvider { override val rootScope: Scope get() = checkNotNull(_rootScope) { "Must call create() first." } + /** Provides the application scope DI component. */ + val appComponent: AppComponent + get() = rootScope.kotlinInjectComponent() + /** Creates the root scope and remembers the instance. */ fun create(appComponent: AppComponent) { check(_rootScope == null) { "create() should be called only once." } diff --git a/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiChildPresenter.kt b/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiChildPresenter.kt new file mode 100644 index 00000000..621d1201 --- /dev/null +++ b/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiChildPresenter.kt @@ -0,0 +1,43 @@ +@file:Suppress("UndocumentedPublicProperty", "UndocumentedPublicClass") + +package software.amazon.app.platform.recipes.swiftui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.snapshots.SnapshotStateList +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import software.amazon.app.platform.presenter.BaseModel +import software.amazon.app.platform.presenter.molecule.MoleculePresenter +import software.amazon.app.platform.recipes.swiftui.SwiftUiChildPresenter.Model + +class SwiftUiChildPresenter( + private val index: Int, + private val backstack: SnapshotStateList>, +) : MoleculePresenter { + @Composable + override fun present(input: Unit): Model { + val counter by + produceState(0) { + while (isActive) { + delay(1.seconds) + value += 1 + } + } + + return Model(index = index, counter = counter) { + when (it) { + Event.AddPeer -> + backstack.add(SwiftUiChildPresenter(index = index + 1, backstack = backstack)) + } + } + } + + data class Model(val index: Int, val counter: Int, val onEvent: (Event) -> Unit) : BaseModel + + sealed interface Event { + data object AddPeer : Event + } +} diff --git a/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiHomePresenter.kt b/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiHomePresenter.kt new file mode 100644 index 00000000..8925bcfa --- /dev/null +++ b/recipes/common/impl/src/commonMain/kotlin/software/amazon/app/platform/recipes/swiftui/SwiftUiHomePresenter.kt @@ -0,0 +1,65 @@ +@file:Suppress("UndocumentedPublicProperty", "UndocumentedPublicClass") + +package software.amazon.app.platform.recipes.swiftui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import me.tatarka.inject.annotations.Inject +import software.amazon.app.platform.presenter.BaseModel +import software.amazon.app.platform.presenter.molecule.MoleculePresenter +import software.amazon.app.platform.recipes.swiftui.SwiftUiHomePresenter.Model + +/** + * A presenter that manages a backstack of presenters that are rendered by SwiftUI's + * `NavigationStack`. All presenters in this backstack are always active, because `NavigationStack` + * renders them on stack modification. In SwiftUI this is necessary as views remain alive even when + * they are no longer visible. + * + * A detail of note for this class is that we pass a list of [BaseModel] to the view but receive a + * list of [Int] back where each integer represents the position of a presenter in the backstack + * list. This is because to share control of state with `NavigationStack` we need to initialize the + * `NavigationStack` with a `Binding` to a collection of `Hashable` data values. [BaseModel] by + * default is not `Hashable` and we cannot extend it to conform to `Hashable` due to current + * Kotlin-Swift interop limitations. As such in Swift the list of [BaseModel] is converted to a list + * of indices, which are hashable by default. This should be sufficient to handle most navigation + * cases but if it is required to receive more information to determine how to modify the presenter + * backstack, it is possible to create a generic class that implements [BaseModel] and wrap that + * class in a hashable `struct`. + */ +@Inject +class SwiftUiHomePresenter : MoleculePresenter { + @Composable + override fun present(input: Unit): Model { + val backstack = remember { + mutableStateListOf>().apply { + // There must be always one element. + add(SwiftUiChildPresenter(index = 0, backstack = this)) + } + } + + return Model(modelBackstack = backstack.map { it.present(Unit) }) { + when (it) { + is Event.BackstackModificationEvent -> { + val updatedBackstack = it.indicesBackstack.map { index -> backstack[index] } + + backstack.clear() + backstack.addAll(updatedBackstack) + } + } + } + } + + /** + * Model that contains all the information needed for SwiftUI to render the backstack. + * [modelBackstack] contains the backage and [onEvent] exposes an event handling function that can + * be called by the binding that `NavigationStack` is initialized with. + */ + data class Model(val modelBackstack: List, val onEvent: (Event) -> Unit) : BaseModel + + /** All events that [SwiftUiHomePresenter] can process. */ + sealed interface Event { + /** Sent when `NavigationStack` has modified its stack. */ + data class BackstackModificationEvent(val indicesBackstack: List) : Event + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift b/recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift deleted file mode 100644 index 5eb518ff..00000000 --- a/recipes/recipesIosApp/recipesIosApp/ComposeContentView.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// ComposeContentView.swift -// recipesIosApp -// -// Created by Wang, Jessalyn on 11/10/25. -// - -import SwiftUI -import RecipesApp - -struct ComposeView: UIViewControllerRepresentable { - private var rootScopeProvider: RootScopeProvider - - init(rootScopeProvider: RootScopeProvider) { - self.rootScopeProvider = rootScopeProvider - } - - func makeUIViewController(context: Context) -> UIViewController { - MainViewControllerKt.mainViewController(rootScopeProvider: rootScopeProvider) - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} -} - -struct ComposeContentView: View { - var rootScopeProvider: RootScopeProvider - - init(rootScopeProvider: RootScopeProvider) { - self.rootScopeProvider = rootScopeProvider - } - - var body: some View { - ComposeView(rootScopeProvider: rootScopeProvider).ignoresSafeArea(.keyboard) // Compose has its own keyboard handler - } -} diff --git a/recipes/recipesIosApp/recipesIosApp/ContentView.swift b/recipes/recipesIosApp/recipesIosApp/ContentView.swift new file mode 100644 index 00000000..d2b2a26b --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/ContentView.swift @@ -0,0 +1,63 @@ +// +// ContentView.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/10/25. +// + +import SwiftUI +import RecipesApp + +struct ComposeView: UIViewControllerRepresentable { + private var rootScopeProvider: RootScopeProvider + + init(rootScopeProvider: RootScopeProvider) { + self.rootScopeProvider = rootScopeProvider + } + + func makeUIViewController(context: Context) -> UIViewController { + MainViewControllerKt.mainViewController(rootScopeProvider: rootScopeProvider) + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +struct ContentView: View { + var appDelegate: AppDelegate + + @State var showComposeRecipes = false + @State var showSwiftUIRecipe = false + + init(appDelegate: AppDelegate) { + self.appDelegate = appDelegate + } + + var body: some View { + VStack { + Spacer() + + Button(action: { showComposeRecipes.toggle() }) { + Text("CMP-rendered recipes") + } + .buttonStyle(.borderedProminent) + + Spacer() + + Button(action: { showSwiftUIRecipe.toggle() }) { + Text("SwiftUI recipe") + } + .buttonStyle(.borderedProminent) + + Spacer() + } + .sheet(isPresented: $showComposeRecipes) { + ComposeView(rootScopeProvider: appDelegate) + .ignoresSafeArea(.keyboard) // Compose has its own keyboard handler + } + .sheet(isPresented: $showSwiftUIRecipe) { + SwiftUiRootPresenterView( + homePresenter: SwiftUiHomePresenterBuilder(appDelegate: appDelegate).makeHomePresenter() + ) + } + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/PresenterViews/AppPlatform+Extensions.swift b/recipes/recipesIosApp/recipesIosApp/PresenterViews/AppPlatform+Extensions.swift new file mode 100644 index 00000000..5c1b1ff8 --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/PresenterViews/AppPlatform+Extensions.swift @@ -0,0 +1,119 @@ +// +// AppPlatform+Extensions.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/24/25. +// + +import RecipesApp +import SwiftUI + +extension Presenter { + /// Returns an async sequence of type `Model` from a `Presenter` model `StateFlow`. + func viewModels(ofType type: Model.Type) -> AsyncThrowingStream { + model + .values() + .compactMap { $0 as? Model } + .asAsyncThrowingStream() + } +} + +enum KotlinFlowError { + case unexpectedValueInKotlinFlow(value: Any, expectedType: String) +} + +extension Kotlinx_coroutines_coreFlow { + + /// Returns an async sequence of Any? from the Kotlin Flow. + /// + /// The Flows send Any, so we lose type information and need to cast at runtime instead of getting a type-safe compile time check. + /// You can use `valuesOfType` instead which returns a stream that throws an error if the values are not of the right type. + /// `valuesOfType` is usually preferred because we want to catch bad values from Kotlin instead of the Flow going silent. + func values() -> AsyncThrowingStream { + let collector = Kotlinx_coroutines_coreFlowCollectorImpl() + collect(collector: collector, completionHandler: collector.onComplete(_:)) + return collector.values + } + + /// Returns an async sequence from the Kotlin Flow. + /// + /// The Flows send Any, so we lose type information and need to cast at runtime instead of getting a type-safe compile time check. + /// If the Flow sends the right type, this stream will throw an error. + /// This is usually preferred because we want to catch bad values from Kotlin instead of the Flow going silent. + func valuesOfType(_ type: T.Type = T.self) -> AsyncThrowingStream { + let collector = Kotlinx_coroutines_coreFlowCollectorImpl() + Task { @MainActor in + do { + try await collect(collector: collector) + collector.onComplete(nil) + } catch { + collector.onComplete(error) + } + } + return collector.values + } +} + +fileprivate class Kotlinx_coroutines_coreFlowCollectorImpl: Kotlinx_coroutines_coreFlowCollector { + + let values: AsyncThrowingStream + private let continuation: AsyncThrowingStream.Continuation + + init() { + let (values, continuation) = AsyncThrowingStream.makeStream() + self.values = values + self.continuation = continuation + } + + func emit(value: Any?) async throws { + if let castedValue = value as? Value { + continuation.yield(castedValue) + } + } + + func onComplete(_ error: Error?) { + continuation.finish(throwing: error) + } + + deinit { + print("Deiniting collector") + } +} + +extension AsyncSequence { + + func asAsyncThrowingStream() -> AsyncThrowingStream { + if let self = self as? AsyncThrowingStream { + return self + } + var asyncIterator = self.makeAsyncIterator() + return AsyncThrowingStream { + try await asyncIterator.next() + } + } +} + +extension Int { + /// Converts Swift Int to Kotlin's Int type for interop. + func toKotlinInt() -> KotlinInt { + return KotlinInt(integerLiteral: self) + } +} + +extension BaseModel { + /// Gets the view for some `BaseModel` + /// + /// Returns. created by `makeViewRenderer()` if a model conforms to `PresenterViewModel` otherwise, crash the build for + /// debug builds or return a default view. + @MainActor func getViewRenderer() -> AnyView { + guard let viewModel = self as? (any PresenterViewModel) else { + assertionFailure("ViewModel \(self) does not conform to `PresenterViewModel`") + + // This is an implementation detail. If crashing is preferred even in production builds, `fatalError(..)` + // can be used instead + return AnyView(Text("Error, some ViewModel was not implemented!")) + } + + return AnyView(viewModel.makeViewRenderer()) + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/PresenterViews/MoleculePresenterWrapper.swift b/recipes/recipesIosApp/recipesIosApp/PresenterViews/MoleculePresenterWrapper.swift new file mode 100644 index 00000000..cf6467b7 --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/PresenterViews/MoleculePresenterWrapper.swift @@ -0,0 +1,31 @@ +// +// MoleculePresenterWrapper.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/24/25. +// + +import RecipesApp + +/// Wraps a Molecule Presenter that has been converted into a regular Presenter. +/// +/// In order to convert a Molecule Presenter to a regular Presenter, we need to create a MoleculeScope, +/// and that scope needs to be cancelled when we are done, +/// so we create this class which will automatically cancel the scope upon deinit. +class MoleculePresenterWrapper: Presenter { + var model: Kotlinx_coroutines_coreStateFlow { wrapped.model } + + private let wrapped: Presenter + private let scope: MoleculeScope + + init(moleculeScopeFactory: MoleculeScopeFactory, moleculePresenter: MoleculePresenter, input: Any) { + let scope = moleculeScopeFactory.createMoleculeScope() + self.scope = scope + self.wrapped = scope.launchMoleculePresenter(presenter: moleculePresenter, input: input) + } + + deinit { + scope.cancel() + } + +} diff --git a/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterView.swift b/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterView.swift new file mode 100644 index 00000000..2ccdea87 --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterView.swift @@ -0,0 +1,58 @@ +// +// PresenterView.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/24/25. +// + +import SwiftUI +import RecipesApp + +/// Displays the view model hieararchy from a root `Presenter`. +/// +/// `PresenterView` can be instantiated by passing in a Kotlin `Presenter` or an `AsyncSequence` of `ViewModels`. +/// +/// Note that `PresenterView` should only be used at the root of a `Presenter` hierarchy. `Presenters` are hierarchical. The view for a parent +/// `Presenter` model should also present the model of its children, so `PresenterView` is only be needed for the root parent `Presenter`. +struct PresenterView: View { + @StateObject var viewModelObserver: ViewModelObserver + + init(presenter: Presenter, viewModelType: Model.Type, handleViewModelError: @escaping (Error) -> ()) { + self.init(viewModels: presenter.viewModels(ofType: viewModelType), handleViewModelError: handleViewModelError) + } + + init(viewModels: ViewModels, handleViewModelError: @escaping (Error) -> ()) where ViewModels.Element == Model { + self._viewModelObserver = StateObject(wrappedValue: ViewModelObserver( + viewModels: viewModels, + handleError: handleViewModelError + )) + } + + var body: some View { + if let viewModel = viewModelObserver.viewModel { + viewModel.getViewRenderer() + } + } + + @MainActor + class ViewModelObserver: ObservableObject { + @Published var viewModel: Model? + private var task: Task? = nil + + init(viewModels: ViewModels, handleError: @escaping (Error) -> ()) where ViewModels.Element == Model { + task = Task { @MainActor [weak self] in + do { + for try await viewModel in viewModels { + self?.viewModel = viewModel + } + } catch { + handleError(error) + } + } + } + + deinit { + task?.cancel() + } + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterViewModel.swift b/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterViewModel.swift new file mode 100644 index 00000000..2d73c349 --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/PresenterViews/PresenterViewModel.swift @@ -0,0 +1,15 @@ +// +// PresenterViewModel.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/24/25. +// + +import SwiftUI +import RecipesApp + +/// A protocol for view models that create their own SwiftUI view representation. +protocol PresenterViewModel { + associatedtype Renderer : View + @ViewBuilder @MainActor func makeViewRenderer() -> Self.Renderer +} diff --git a/recipes/recipesIosApp/recipesIosApp/RecipesIosApp.swift b/recipes/recipesIosApp/recipesIosApp/RecipesIosApp.swift index e775f514..8a44262d 100644 --- a/recipes/recipesIosApp/recipesIosApp/RecipesIosApp.swift +++ b/recipes/recipesIosApp/recipesIosApp/RecipesIosApp.swift @@ -9,12 +9,10 @@ import SwiftUI import RecipesApp class AppDelegate: NSObject, UIApplicationDelegate, RootScopeProvider { - private let demoApplication: DemoApplication = DemoApplication() + let demoApplication: DemoApplication = DemoApplication() var rootScope: Scope { - get { - demoApplication.rootScope - } + get { demoApplication.rootScope } } func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { @@ -30,7 +28,7 @@ struct recipesIosApp: App { var body: some Scene { WindowGroup { - ComposeContentView(rootScopeProvider: appDelegate) + ContentView(appDelegate: appDelegate) } } } diff --git a/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiChildPresenterView.swift b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiChildPresenterView.swift new file mode 100644 index 00000000..4ecf68df --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiChildPresenterView.swift @@ -0,0 +1,30 @@ +// +// SwiftUiChildPresenterView.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/23/25. +// + +import RecipesApp +import SwiftUI + +extension SwiftUiChildPresenter.Model: PresenterViewModel { + func makeViewRenderer() -> some View { + SwiftUiChildPresenterView(model: self) + } +} + +struct SwiftUiChildPresenterView: View { + var model: SwiftUiChildPresenter.Model + + var body: some View { + Text("Index: \(model.index)") + .font(.system(size: 36)) + Text("Counter: \(model.counter)") + .font(.system(size: 36)) + Button(action: { model.onEvent(SwiftUiChildPresenterEventAddPeer()) }) { + Text("Add peer") + } + .buttonStyle(.borderedProminent) + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterBuilder.swift b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterBuilder.swift new file mode 100644 index 00000000..3965205e --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterBuilder.swift @@ -0,0 +1,24 @@ +// +// SwiftUiHomePresenterBuilder.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/24/25. +// + +import RecipesApp + +struct SwiftUiHomePresenterBuilder { + private let appComponent: AppComponent + + init(appDelegate: AppDelegate) { + self.appComponent = appDelegate.demoApplication.appComponent + } + + func makeHomePresenter() -> Presenter { + MoleculePresenterWrapper( + moleculeScopeFactory: appComponent.moleculeScopeFactory, + moleculePresenter: appComponent.swiftUiHomePresenter, + input: Void() + ) + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterView.swift b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterView.swift new file mode 100644 index 00000000..3ddf676c --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiHomePresenterView.swift @@ -0,0 +1,66 @@ +// +// SwiftUiHomePresenterView.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/24/25. +// + +import SwiftUI +import RecipesApp + +extension SwiftUiHomePresenter.Model: PresenterViewModel { + + func makeViewRenderer() -> some View { + SwiftUiHomePresenterView(model: self) + } +} + +// Creates a binding for the workflow presenter model backstack so we can provide it to +// NavigationStack. The backstack is indexed here as the type of the Binding needs to be hashable. +// SwiftUiHomePresenter.Model accepts a modified list of indices +extension SwiftUiHomePresenter.Model { + func pathBinding() -> Binding<[Int]> { + .init { + // drop the first value of the backstack from the path because that should be the root view + Array(self.modelBackstack.indices.dropFirst()) + } set: { modifiedIndices in + + // the resulting backstack indices the presenter should compute on is the first index (0) that was + // dropped as well as the remaining indices post modification + let indicesBackstack = [0] + modifiedIndices.map { $0.toKotlinInt() } + + self.onEvent( + SwiftUiHomePresenterEventBackstackModificationEvent ( + indicesBackstack: indicesBackstack + ) + ) + } + } +} + +struct SwiftUiHomePresenterView: View { + var model: SwiftUiHomePresenter.Model + + var body: some View { + NavigationStackView(model: self.model) + } +} + +private struct NavigationStackView: View { + var backstack: [BaseModel] + var model: SwiftUiHomePresenter.Model + + init(model: SwiftUiHomePresenter.Model) { + self.backstack = model.modelBackstack + self.model = model + } + + var body: some View { + NavigationStack(path: model.pathBinding()) { + backstack[0].getViewRenderer() + .navigationDestination(for: Int.self) { index in + backstack[index].getViewRenderer() + } + } + } +} diff --git a/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiRootPresenterView.swift b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiRootPresenterView.swift new file mode 100644 index 00000000..cb420032 --- /dev/null +++ b/recipes/recipesIosApp/recipesIosApp/SwiftUI/SwiftUiRootPresenterView.swift @@ -0,0 +1,24 @@ +// +// SwiftUiRootPresenterView.swift +// recipesIosApp +// +// Created by Wang, Jessalyn on 11/23/25. +// + +import SwiftUI +import RecipesApp + +/// Creates the view model hierarchy for the root presenter of this recipe, `SwiftUiHomePresenter`. +struct SwiftUiRootPresenterView: View { + var homePresenter: Presenter + + var body: some View { + PresenterView( + presenter: homePresenter, + viewModelType: BaseModel.self, + handleViewModelError: { error in + fatalError("View model error occured: \(error)") + } + ) + } +}