Skip to main content
Version: 0.4.0

Screens

The SDK controls the navigation of its flows. A flow starts at the property list, at one property, or at creating a property, and you start it in one of two ways:

  • Place it. You put the SDK's entry screen in your own navigation, and the SDK pushes and pops its screens below it.
  • Have the SDK present it. You say where to present from, and the SDK presents the flow full screen in a navigation controller of its own, gives it a Close button, and dismisses it when the user closes it.

Either way it reports what happened through one onEvent closure. Nothing asks you to navigate, and the SDK never pops or dismisses a screen of yours. A handler that ignores every event still has a working flow.

Entry points​

Every flow can be started from UIKit and from SwiftUI. All of them report PropertyFlowEvent.

Starts atUIKit, placedUIKit, presentedSwiftUI, placedSwiftUI, presented
The property listmakePropertyListViewController(subtitle:onEvent:)presentPropertyList(from:subtitle:onEvent:)PropertyListFlow.propertyList(isPresented:tours:subtitle:onEvent:)
One property, for a host that already knows what it is capturingmakePropertyViewController(property:onEvent:)presentProperty(_:from:onEvent:)PropertyFlow.property(item:tours:onEvent:)
Creating a property, for a host with its own "new capture" action—presentPropertyCreation(from:metadata:onEvent:)—.propertyCreation(isPresented:tours:metadata:onEvent:)

The UIKit calls are on tours.properties. They are async (they read your settings), @MainActor, and each one starts the SDK if you have not — so all of them can throw storageUnavailable(reason:). The SwiftUI views and modifiers call them for you, and report what they throw as failed.

In a UIKit app​

Placing an entry screen​

let list = try await tours.properties.makePropertyListViewController(
subtitle: "Acme Ltd",
onEvent: { event in
if case .failed(let error) = event { present(error) }
}
)
navigationController.setViewControllers([list], animated: false)

subtitle is an optional line under the title. The returned screen is yours to decorate — bar-button items, a title view — and yours to place, as the root of your stack or pushed onto it.

The entry screen must be inside a navigation controller

The SDK pushes onto the navigation controller the entry screen is in. Outside one, the entry screen shows but opens nothing.

Presenting the list or a property​

When the flow is something your screen opens rather than a place in your stack, let the SDK present it. You build no navigation controller and add no button:

try await tours.properties.presentPropertyList(from: self) { event in
switch event {
case .closedPropertyList:
refresh() // your screen is showing again
case .failed(let error):
present(error)
default:
break
}
}

try await tours.properties.presentProperty(job.property, from: self) { event in
if case .failed(let error) = event { present(error) }
}

The flow covers the screen, and continues inside it exactly as it does from a placed entry screen. Its Close button dismisses the navigation controller and everything on it, and reports closedPropertyList or closedProperty. presentProperty re-reads the property first, and throws notFound for one that was deleted — before anything is presented. It reports no openedProperty: you opened it.

The call returns once the flow is presented, not when it ends.

Starting at a new property​

presentPropertyCreation(from:metadata:onEvent:) asks the user for a new property and opens it, without the list.

try await tours.properties.presentPropertyCreation(
from: self, // your view controller
metadata: JobRef(jobId: "WO-4471", priority: 2) // optional
) { event in
switch event {
case .openedProperty(let property):
job.propertyId = property.id // the property the user just created
case .closedProperty:
refresh() // your screen is showing again
case .failed(let error):
present(error)
default:
break
}
}
  1. The SDK presents the creation form as a sheet. Cancelling dismisses it with nothing created and nothing reported.
  2. On Create it dismisses the form and creates the property.
  3. It presents the property full screen, in a navigation controller of its own, and reports openedProperty. Floors, rooms and both capture flows are pushed on that controller.
  4. The property screen has a Close button. It dismisses the navigation controller and everything on it, and reports closedProperty.

metadata attaches your own record to the property the user creates, under the rules createProperty(name:floorCount:metadata:) has: any Encodable that encodes to a JSON object, set once and never changed. A value that does not encode to one throws invalidMetadata(reason:) before anything is presented.

The presenter must be free to present

Hand any of the present… calls a view controller that is on screen and is not presenting anything. Otherwise nothing is presented.

Plus four non-screen calls:

MethodPurpose
createProperty(name:floorCount:metadata:)Create a property, returns a Property handle
property(id:)Look one up; nil if it no longer exists
status(of:)One-shot PropertyStatus
statusUpdates(for:)AsyncStream<PropertyStatus>

In a tab​

An entry screen can be the root of a tab's navigation controller. The capture screens and the floor-plan review take the whole screen: in a UITabBarController they hide the tab bar while they are showing, and it returns when the user leaves them. The property, floor and room screens keep it.

A SwiftUI TabView does not hide its bar for them. In a SwiftUI app with tabs, present the flow instead, as below.

In a SwiftUI app​

A SwiftUI app writes no wrappers: the SDK ships a view for each flow you place and a modifier for each flow it presents. Inside, the flow is the same UIKit flow, so everything below the entry screen behaves exactly as in a UIKit app.

Placing a flow​

PropertyListFlow and PropertyFlow go wherever a screen of yours would — the root of a scene, a tab, a full-screen cover. Each brings its own navigation and fills its space edge to edge:

TabView {
PropertyListFlow(tours: tours, subtitle: "Acme Ltd") { event in
if case .failed(let error) = event { failure = error }
}
.failureAlert($failure)
.tabItem { Label("Properties", systemImage: "house") }
}

PropertyFlow(tours: tours, property: job.property) { event in
if case .failed(let error) = event { failure = error }
}
.failureAlert($failure)

failure is a @State private var failure: NoughtyToursError? of yours; see Showing a failure.

Handing PropertyFlow a different property starts the flow again at that property. subtitle is read once, when the list first appears.

Not inside a NavigationStack

Each view is a navigation stack of its own. Put inside a NavigationStack it shows under a second, empty navigation bar.

Presenting a flow​

The modifiers are shaped like sheet(isPresented:): set the binding, and the SDK presents the flow over whatever is on screen. When nothing of the flow is left — the user closed it, cancelled or swiped away the creation form, or a failure ended it — the SDK sets the binding back.

struct JobView: View {
let tours: NoughtyTours
let job: Job
@State private var browsing = false
@State private var capturing: Property?
@State private var creating = false
@State private var failure: NoughtyToursError?

var body: some View {
VStack {
Button("All properties") { browsing = true }
Button("Open this job's property") { capturing = job.property }
Button("New capture") { creating = true }
}
.propertyList(isPresented: $browsing, tours: tours, onEvent: handle)
.property(item: $capturing, tours: tours, onEvent: handle)
.propertyCreation(isPresented: $creating, tours: tours, metadata: job.ref, onEvent: handle)
.failureAlert($failure)
}

private func handle(_ event: PropertyFlowEvent) {
if case .failed(let error) = event { failure = error }
}
}

Each flow covers the screen with a Close button, as its UIKit call describes, and reports the same events. Clearing the binding yourself dismisses the flow and everything on it, and reports nothing: you closed it. Presenting works from a view that is itself in a sheet; the sheet is showing again when the flow closes.

Showing a failure​

failed can arrive while a flow the SDK presented is on screen: one of the present… calls or modifiers, or + on the list. The SDK shows nothing for it; you show it, over whatever the user is looking at.

In a UIKit app, present your alert from the view controller on top, the last one reached by following presentedViewController from your window's root. A view controller that is already presenting something cannot present the alert.

In a SwiftUI app, use failureAlert rather than alert. SwiftUI presents an alert from the view it is attached to, and while a flow is presented over that view the alert is not shown — not then, and not after the flow closes either. failureAlert shows your alert where it can be seen: titled with the error's errorDescription, with its recoverySuggestion as the message, and your own buttons if you pass any.

@State private var failure: NoughtyToursError?

Button("New capture") { creating = true }
.propertyCreation(isPresented: $creating, tours: tours) { event in
if case .failed(let error) = event { failure = error }
}
.failureAlert($failure) { error in
if error.isRetryable { Button("Retry") { creating = true } }
Button("OK", role: .cancel) {}
}

Dismissing the alert sets failure back to nil. A failure that arrives while one is showing is shown next.

Inside a flow​

From the list, selecting a property pushes its property screen. + on the list starts the flow presentPropertyCreation presents, over the list: the form as a sheet, then the new property full screen in a navigation controller of the SDK's own, whose Close button returns to the list. From the property screen the user maps floors and captures panoramas.

Every screen comes in the UI/UX version your SettingsProviderType.uiVersion() returns. In .v0, the default, the property screen lists the floors and opens floors and rooms on screens of their own, and a new property is asked for by name and floor count. In .v1 the property screen shows every floor and room on one scrolling tree, and a new property is asked for by name only. Both versions report the same events.

Structure capture​

Maps a floor's plan, then shows it for review. Confirming returns to the screen that started the capture and reports structureCaptureFinished; rescanning starts a fresh capture of the same floor. A floor that already has a plan can also have rooms added to it: the camera relocalizes into the floor's saved world map first ("Finding position…"), the rooms already on the floor keep their identity and their panoramas, and that review is confirm-only.

A RoomPlan or ARKit failure mid-scan does not end the flow. The active room is discarded, the session relocalizes, and the screen offers Retry, Build with N rooms or Go back — the rooms already finished are kept either way.

Panorama capture​

Captures panoramas across a floor, or in one room. Finishing returns to the screen that started it and reports panoramaCaptureFinished with how many panoramas that visit added. Backing out keeps anything captured before it.

Where a panorama is stitched​

SettingsProviderType.panoramaProcessing() chooses, with PanoramaProcessing as its value. The SDK reads it once as a panorama capture opens, so a change applies to the next capture, not to the one in progress.

ValueRecordedStitchedUploaded
.server, the defaultA movie, at your recordingResolution()By the platform, after the uploadThe recording
.onDeviceA 4K sweep, whatever recordingResolution() returnsOn the phoneThe finished panorama
extension MySettingsStore: SettingsProviderType {
func panoramaProcessing() async -> PanoramaProcessing { await processing }
}

Each case has a title, already localized, so a picker in your settings screen needs no strings of its own.

With .onDevice the user does not wait for a stitch. Each sweep joins a queue and is stitched in the background while the user captures the next one, and the capture screen counts what is still stitching. The queue runs only while your app is in the foreground: it pauses when the app is backgrounded and carries on where it stopped when the app returns. A stitch cut short by the app being killed starts again at the next launch.

A panorama stitched on the phone is uploaded once its stitch has finished — see Progress and sync.

In .v0, the Room screen shows a thumbnail and the status of each panorama, opens a stitched one in a full-screen viewer, and offers Retry stitch on one that failed. In .v1, the project tree lists each panorama with its sync state.

Panoramas the SDK discards​

A panorama is graded while it records. Tilt, sweep speed, shake, reversing the sweep and low light each warn on screen first, and a bad enough breach discards the scan: its folder is deleted, nothing reaches local storage or the upload queue, and the user gets the reason and a Try again. In very low light — ISO above 2000, or a shutter slower than 1/15s — the shutter is blocked before recording starts.

A discard is not reported to the host. It shows up only as a smaller capturedInSession than the user may describe, so count panoramas from that value or from PropertyStatus.

The screens title themselves

They know whether a floor is "Ground floor" or "Floor 2". You own the entry screen's place in your app and anything you add to its navigation bar.