Getting started
Providers, one NoughtyTours, start(), and pushing the screens onto your own
stack.
The device attests against your App ID, and nothing syncs until it does. See App Attest.
Five steps, all of them at launch except the last.
1. Implement the two providers
The SDK reads both live, at the moment it needs them, so a settings change or a
re-login is picked up without rebuilding anything. Both are Sendable with
async requirements, so you can keep the underlying state on any isolation you
like.
extension MyAuthService: OrganizationProviderType {
/// The organisation the SDK scopes its device bearer to. `nil` when nobody is signed in.
func currentOrganizationId() async -> String? { await session?.organizationId }
}
extension MySettingsStore: SettingsProviderType {
func recordingResolution() async -> RecordingResolution { await resolution }
func floorPlanStyle() async -> FloorPlanStyle { await style }
// captureMedia / capturePointCloud / captureARData are developer toggles and
// default to `false` — implement them only if you expose them.
}
2. Build the SDK at launch
One NoughtyTours for the whole process. init(configuration:) cannot fail.
let tours = NoughtyTours(configuration: Configuration(
environment: isReleaseBuild ? .production : .development,
publishableKey: "pk_ios_…",
organizationProvider: authService,
settings: settingsStore
))
3. Start it before the first screen
start() prepares local storage and reconnects to uploads still in flight from
a previous launch.
try await tours.start() // idempotent; concurrent calls share one open
Its only failure is storageUnavailable(reason:), and it is terminal — nothing
the SDK offers works without local storage. A failed start can be retried. Await
it behind your launch or loading screen; after it returns you never have to model
a "not ready" state.
4. Establish device auth
try await tours.prepareDeviceAuth()
See App Attest for when to call it and which failures are worth surfacing.
5. Push the screens
Every screen hangs off NoughtyTours in three namespaces — properties,
structureCapture, panoramaCapture — and reports what the user did through a
single onEvent closure. Each event carries the handle for the next screen, so
navigation is a switch.
@MainActor
final class CaptureCoordinator {
private let navigationController: UINavigationController
private let tours: NoughtyTours
/// The factories are `async` (they read your settings) and `@MainActor`, and each one
/// starts the SDK if you have not — so they throw `.storageUnavailable` when storage is dead.
func start() async throws(NoughtyToursError) {
let list = try await tours.properties.makePropertyListViewController(
subtitle: "Acme Ltd", // optional line under the title
onEvent: { [weak self] event in
switch event {
case .selectProperty(let property): self?.showProperty(property)
case .failed(let error): self?.present(error)
}
}
)
navigationController.setViewControllers([list], animated: false)
}
private func showProperty(_ property: Property) {
Task {
do throws(NoughtyToursError) {
let vc = try await tours.properties.makePropertyViewController(
property: property,
onEvent: { [weak self] event in
switch event {
case .selectFloor(let floor): self?.showFloor(floor)
case .selectRoom(let room): self?.showRoom(room)
case .startStructureCapture(let floor): self?.mapFloor(floor)
case .startPanoramaCapture(let floor): self?.capturePanorama(floor: floor)
case .failed(let error): self?.present(error)
}
}
)
navigationController.pushViewController(vc, animated: true)
} catch {
present(error)
}
}
}
private func mapFloor(_ floor: Floor) {
Task {
do throws(NoughtyToursError) {
let vc = try await tours.structureCapture.makeViewController(
floor: floor,
onEvent: { [weak self] event in
switch event {
case .finished: break // the flow pops itself back
case .cancelled: self?.navigationController.popViewController(animated: true)
case .redoRequested: self?.mapFloor(floor) // fresh flow, same floor
}
}
)
navigationController.pushViewController(vc, animated: true)
} catch {
present(error)
}
}
}
}
6. Forward background upload events
Uploads continue after your app is suspended, and iOS may relaunch the app to finish them. Forward the delegate call verbatim:
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
tours.handleEventsForBackgroundURLSession(identifier: identifier, completionHandler: completionHandler)
}
Call it unconditionally, whatever the SDK's state — this is the one entry
point that works before start() has finished. It returns true if the session
was the SDK's; false means the events are still yours.
Opening a property directly
For a host that already knows what it wants to capture — a work order, a
project, a job — two non-screen calls skip the list. Store the id next to your
own record, look it up on the next launch, and create a fresh property when it
comes back nil:
let property = if let id = storedPropertyId,
let found = try await tours.properties.property(id: id) {
found
} else {
try await tours.properties.createProperty(name: job.address)
}
let vc = try await tours.properties.makePropertyViewController(property: property, onEvent: handle)
navigationController.setViewControllers([vc], animated: false)
Rules of the road
- Handles come from the SDK.
Property,FloorandRoomarrive in events, or fromcreateProperty(name:floorCount:metadata:)/property(id:), and go straight back into a factory. You cannot construct one, by design — a hand-built handle could only name a row that doesn't exist. - The screens title themselves. They know whether a floor is "Ground floor" or "Floor 2". You own the stack and any bar-button items you add on top.
- Structure capture owns its sub-navigation (capture → review) and pops
itself back on
finished. You only handlecancelledandredoRequested. - Event enums grow. New cases arrive in minor versions — keep a
defaultin exhaustive switches. - Nothing is presented for you. The SDK never puts up an alert; failures are
reported through a
failedcase or thrown. See Errors.