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 at | UIKit, placed | UIKit, presented | SwiftUI, placed | SwiftUI, presented |
|---|---|---|---|---|
| The property list | makePropertyListViewController(subtitle:onEvent:) | presentPropertyList(from:subtitle:onEvent:) | PropertyListFlow | .propertyList(isPresented:tours:subtitle:onEvent:) |
| One property, for a host that already knows what it is capturing | makePropertyViewController(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 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
}
}
- The SDK presents the creation form as a sheet. Cancelling dismisses it with nothing created and nothing reported.
- On Create it dismisses the form and creates the property.
- 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. - 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.
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:
| Method | Purpose |
|---|---|
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.
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.
| Value | Recorded | Stitched | Uploaded |
|---|---|---|---|
.server, the default | A movie, at your recordingResolution() | By the platform, after the upload | The recording |
.onDevice | A 4K sweep, whatever recordingResolution() returns | On the phone | The 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.
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.