Skip to content

About

Indoor navigation for stores, super and much more.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

16 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 

Repository files navigation

IndoorSDK

Indoor store navigation for iOS: floor map, AR camera guidance, and multi-stop trips.

This package is binary-only (XCFramework). Engine source is not included.

Platform iOS 17+ (iPhone / iPad)
Xcode 15+
Integration Swift Package Manager
UI SwiftUI (IndoorNavigationView, IndoorSurveyView)
Repository https://github.com/Indoorly/Indoorly-iOS
Current version 1.1.1

Table of contents

  1. Install
  2. Permissions (Info.plist)
  3. Where to configure the SDK
  4. Tokens
  5. Bootstrap (scopes & live settings)
  6. Visitor app — end-to-end
  7. Presenting navigation
  8. Customization
  9. Pin a venue & nearby radius
  10. Discover & resolve stores by GPS
  11. Presence (live in-store count)
  12. Callbacks
  13. Custom screens with IndoorDirectory
  14. Staff app — register / extend a store
  15. Device GPS & capabilities
  16. Advanced setup (without the facade)
  17. Telemetry
  18. UIKit / AppDelegate apps
  19. API reference (public surface)
  20. Common issues
  21. Support

1. Install

Xcode UI

  1. File → Add Package Dependencies…
  2. Paste: https://github.com/Indoorly/Indoorly-iOS.git
  3. Choose a version (git tag, e.g. 1.1.1)
  4. Add the IndoorSDK product to your app target

Package.swift

dependencies: [
    .package(url: "https://github.com/Indoorly/Indoorly-iOS.git", from: "1.1.1"),
],

Then add .product(name: "IndoorSDK", package: "Indoorly-iOS") to your target.

Note: Do not add this package to a target that already links a local IndoorSDK source package — SPM allows the product name only once.


2. Permissions (Info.plist)

Add these keys to your app target’s Info tab (or Info.plist):

Key Required when
NSCameraUsageDescription Navigation with AR camera, or walking survey
NSMotionUsageDescription Device heading while navigating
NSLocationWhenInUseUsageDescription Filtering nearby stores with GPS (IndoorLocation / venues(near:))

Example strings:

NSCameraUsageDescription = "Camera is used to guide you through the store."
NSMotionUsageDescription = "Motion is used to keep your heading on the map."
NSLocationWhenInUseUsageDescription = "Location is used to show nearby stores."

3. Where to configure the SDK

Call configuration once at launch, before any navigation or directory call.

App style Where to call Indoorly.initialize
SwiftUI @main App.init() (recommended)
UIKit + AppDelegate application(_:didFinishLaunchingWithOptions:)
UIKit + SceneDelegate only First scene connect, before presenting Indoorly UI
Unit tests setUp / test entry — use a test token if needed

Also at launch (optional, still before UI):

  • Indoorly.appearance(...)
  • Indoorly.venueID(...)
  • Indoorly.enableAddMoreStores(...)
  • Indoorly.telemetry(...)
  • await Indoorly.bootstrap() — recommended when the token may span several stores

Do not call initialize again from every screen. Views only present navigationView / surveyView / directory().

The production API URL is baked into the SDK. Host apps do not set it (optional serviceURL: exists only for local API debugging).


4. Tokens

Issued in the Indoorly admin panel (Comercios → detalle del comercio / Uso SDK). Tokens belong to the business, not to a single venue. You can also create scoped visitor pk_ tokens that list only 1…N venues (Tokens SDK con alcance).

Kind Prefix Use in host app
API Token (visitors) pk_… Public / consumer apps (Indoorly.bootstrap / directory().venues())
Admin Token sk_… Staff-only apps that register or extend stores
// Visitor app — principal or scoped pk_ of the business
Indoorly.initialize(apiToken: "pk_…")
Indoorly.enableAddMoreStores(false)

// Staff app (register / extend a store by walking)
Indoorly.initialize(apiToken: "sk_…")
Indoorly.enableAddMoreStores(true)

If bootstrap returns several venues, show a store picker (or set Indoorly.venueID("…") to pin one).

Never ship an sk_… token inside a public App Store build.


5. Bootstrap (scopes & live settings)

Call once after initialize to learn what the token can see and which panel settings apply. No SDK redeploy is needed when the panel changes venue scope, GPS pins, discovery mode, or presence intervals.

Indoorly.initialize(apiToken: "pk_…")

let boot = try await Indoorly.bootstrap()
// boot.businessName, boot.role ("client" | "admin")
// boot.allVenues == false → boot.venues is the allow-list from the panel
// boot.settings.presenceHeartbeatSeconds / presenceTtlSeconds
// boot.settings.requireNearVenueMeters  // soft geofence hint; nil = off

if boot.venues.count == 1 {
    Indoorly.venueID(boot.venues[0].id)
} else if boot.venues.count > 1 {
    // Show your store picker, then Indoorly.venueID(chosen.id)
}
Field Meaning
allVenues true → every store of the business; false → only venues
venues[].discoveryMode .always or .nearby (GPS required for nearby)
venues[].latitude / .longitude Store GPS pin from the panel
settings.presenceHeartbeatSeconds How often navigation heartbeats while open
settings.presenceTtlSeconds How long a heartbeat counts as “in store”
settings.requireNearVenueMeters Optional soft geofence radius for host logic

Equivalent: try await Indoorly.directory().bootstrap().


6. Visitor app — end-to-end

Step A — Configure at launch

import IndoorSDK
import SwiftUI

@main
struct MyApp: App {
    init() {
        Indoorly.initialize(apiToken: "pk_…")          // from admin panel (principal or scoped)
        Indoorly.enableAddMoreStores(false)
        Indoorly.telemetry(true)                       // optional; default is on
        Indoorly.appearance(
            IndoorAppearance(
                accentColor: Color(red: 0.0, green: 0.45, blue: 0.25),
                layout: .withHostBackButton            // room for your Back button
            )
        )
        // Optional: lock to one store, or call bootstrap() and pick from boot.venues
        // Indoorly.venueID("walmart-demo")
    }

    var body: some Scene {
        WindowGroup {
            NavigationStack {
                HomeView()
            }
        }
    }
}

Step B — Own home screen, then open Indoorly

import IndoorSDK
import SwiftUI

struct HomeView: View {
    @State private var showNavigation = false
    @State private var loadError: String?

    var body: some View {
        VStack(spacing: 24) {
            Text("My Store App")
                .font(.largeTitle.bold())

            Button("Start indoor navigation") {
                showNavigation = true
            }
            .buttonStyle(.borderedProminent)

            if let loadError {
                Text(loadError).foregroundStyle(.red)
            }
        }
        .fullScreenCover(isPresented: $showNavigation) {
            NavigationHostView(isPresented: $showNavigation)
        }
    }
}

Step C — Host wrapper with Back + SDK UI

Indoorly.navigationView() throws if the token was never set. Catch that when building the view, or use a small host wrapper:

import IndoorSDK
import SwiftUI

struct NavigationHostView: View {
    @Binding var isPresented: Bool
    /// Destination IDs from your backend / deep link / shopping list.
    var stops: [String] = []

    var body: some View {
        ZStack(alignment: .topLeading) {
            navigationContent

            Button {
                isPresented = false
            } label: {
                Image(systemName: "chevron.left")
                    .font(.body.weight(.semibold))
                    .frame(width: 44, height: 44)
                    .background(.ultraThinMaterial, in: Circle())
            }
            .padding(.leading, 14)
            .padding(.top, 6)
        }
    }

    @ViewBuilder
    private var navigationContent: some View {
        if let view = try? Indoorly.navigationView(stops: stops) {
            view
                .onArrival { place in
                    print("Arrived:", place.id, place.name, place.category)
                }
                .onVisitFinished { summary in
                    print(
                        "Done venue=\(summary.venueID)",
                        "stops=\(summary.visited.count)",
                        "meters=\(summary.walkedMeters)",
                        "seconds=\(summary.duration)"
                    )
                    isPresented = false
                }
        } else {
            ContentUnavailableView(
                "Indoorly not configured",
                systemImage: "exclamationmark.triangle",
                description: Text("Call Indoorly.initialize(apiToken:) at app launch.")
            )
        }
    }
}

Use IndoorAppearance(layout: .withHostBackButton) (or a custom IndoorLayout with topLeadingReserved) so SDK banners do not sit under your back control.


7. Presenting navigation

Full-screen cover (typical)

.fullScreenCover(isPresented: $showNavigation) {
    NavigationHostView(isPresented: $showNavigation, stops: ["dairy", "bakery"])
}

Push onto a NavigationStack

NavigationLink("Navigate") {
    NavigationHostView(isPresented: .constant(true), stops: [])
        .toolbar(.hidden, for: .navigationBar) // SDK draws its own chrome
}

Prefer fullScreenCover for AR navigation so system bars do not fight the camera UI.

Pre-selected stops

// Empty list → visitor picks destinations inside the SDK
try Indoorly.navigationView(stops: [])

// Preload a multi-stop trip (place IDs from the panel / IndoorDirectory)
try Indoorly.navigationView(stops: ["dairy", "bakery", "pharmacy"])

Place IDs are the destination identifiers configured for that venue (not display names).


8. Customization

Call Indoorly.appearance(...) at launch (or before presenting navigation). Dynamic Type and light/dark mode follow the system.

Brand colors & category icons — IndoorAppearance

let brand = IndoorAppearance(
    accentColor: Color(red: 0.0, green: 0.45, blue: 0.25),   // buttons, lists, chips
    arrivalColor: Color(red: 0.13, green: 0.75, blue: 0.38), // arrival / beacon
    trailColor: Color(red: 0.13, green: 0.75, blue: 0.38),   // walked trail on the map
    categoryIcons: [
        "dairy": "cup.and.saucer.fill",
        "pharmacy": "cross.case.fill",
        "bakery": "birthday.cake.fill",
    ],
    layout: .withHostBackButton,   // or .standard / custom IndoorLayout
    showsArrivalIlluminate: true,  // Find My–style green wash on arrival
    playsArrivalHaptic: true       // success vibration at each stop
)

Indoorly.appearance(brand)

A place’s own icon (from the panel) wins over categoryIcons. Set showsArrivalIlluminate / playsArrivalHaptic to false for a quieter arrival while the stop list still advances.

Chrome layout — IndoorLayout

Controls banners, mini-map, and space for host chrome over the SDK.

let layout = IndoorLayout(
    topLeadingReserved: 56,   // room for host back button
    topPadding: 6,
    horizontalPadding: 14,
    chromeSpacing: 10,
    showsManeuverBanner: true,
    showsTrackingBanner: true,
    showsMiniMap: true,
    miniMapSize: 128,
    miniMapLeadingPadding: 16,
    miniMapBottomPadding: 16
)

Indoorly.appearance(IndoorAppearance(layout: layout))

// Shortcut when you only need space for a circular back control:
Indoorly.appearance(IndoorAppearance(layout: .withHostBackButton))
Property Effect
topLeadingReserved Keeps banners clear of top-leading host chrome
topPadding / horizontalPadding Insets under the safe area
chromeSpacing Gap between maneuver banner, tracking banner, controls
showsManeuverBanner Turn / distance instruction
showsTrackingBanner AR localization status
showsMiniMap Mini-map while the camera is active
miniMapSize / paddings Mini-map size and placement

9. Pin a venue & nearby radius

// Show only this store (skip venue picker when the token has several venues)
Indoorly.venueID("walmart-demo")

// Clear pin → list all venues for the token
Indoorly.venueID(nil)

// Optional GPS preference used by host “nearby” flows (0 = no preference)
Indoorly.nearbyRadiusMeters(2_000)

Multi-floor venues (stairs / elevators configured in the panel map builder) are handled inside navigation: the map can browse floors and routes transfer via connectors automatically. Host apps do not need extra setup.


10. Discover & resolve stores by GPS

Each venue has a panel discovery mode:

Mode Behavior
always Listed for the token even without GPS (distance may be nil)
nearby Included only when the visitor is within radiusMeters and the store has a GPS pin
import CoreLocation

let directory = try Indoorly.directory()
let coordinate = /* from IndoorLocation or CLLocationManager */

// Annotate all token venues with distance (stores without GPS stay listed)
let ranked = try await directory.venues(annotatedNear: coordinate)

// Discovery list (server /v1/venues/nearby, with client-side fallback)
let nearby = try await directory.venues(near: coordinate, radiusMeters: 2_000)

// Pick the single nearest store (throws if none / ambiguous)
do {
    let venue = try await directory.resolve(
        near: coordinate,
        radiusMeters: 2_000,
        ambiguityMeters: 40   // two stores within 40 m → ambiguousVenue
    )
    Indoorly.venueID(venue.id)
} catch let error as IndoorError where error.isVenueSelectionError {
    // .unknownVenue → none in range; .ambiguousVenue → show a picker
}

IndoorVenueInfo.distanceMeters is filled when GPS is available. Place coordinates (IndoorPlaceInfo.x / .y) remain floor-plan meters, not GPS.


11. Presence (live in-store count)

While IndoorNavigationView is open, the SDK heartbeats automatically using bootstrap.settings.presenceHeartbeatSeconds (panel Uso SDK). The admin panel shows live active visitors per store.

For custom host screens (without opening navigation):

let directory = try Indoorly.directory()

// Report that this session is at the store
let snap = try await directory.reportPresence(
    venueID: "walmart-demo",
    sessionID: UUID().uuidString,   // stable per app session
    coordinate: location.coordinate,
    accuracyMeters: location.accuracyMeters.map { Double($0) }
)
print("Active visitors:", snap.activeUsers)

// Read the current count
let live = try await directory.presence(venueID: "walmart-demo")

TTL and heartbeat interval come from IndoorBootstrap.settings — change them in the panel; the next bootstrap / navigation start picks them up.


12. Callbacks

try Indoorly.navigationView(stops: ["pharmacy"])
    .onArrival { place in
        // Called each time the visitor reaches a stop
        // place.id, place.name, place.category, place.x, place.y (floor-plan meters)
    }
    .onVisitFinished { summary in
        // Called when the visitor ends the visit
        // summary.venueID, summary.visited, summary.walkedMeters, summary.duration
    }

Wire analytics, receipts, or dismissal of your fullScreenCover inside these handlers.


13. Custom screens with IndoorDirectory

Tokens for the SDK (pk_… / sk_…) are issued in the Indoorly admin panel (Uso SDK / Comercios). Panel CRM APIs (users, analytics feed, search) are separate from this mobile SDK.

Use the directory when you build your own venue / destination UI and only open the SDK for walking.

import IndoorSDK
import CoreLocation
import SwiftUI

@MainActor
final class StoreBrowserModel: ObservableObject {
    @Published var venues: [IndoorVenueInfo] = []
    @Published var places: [IndoorPlaceInfo] = []
    @Published var errorMessage: String?

    func loadVenues() async {
        do {
            let boot = try await Indoorly.bootstrap()
            venues = boot.venues
            // boot.settings → presenceHeartbeatSeconds, requireNearVenueMeters, …
        } catch {
            errorMessage = String(describing: error)
        }
    }

    func loadNearby(coordinate: CLLocationCoordinate2D) async {
        do {
            let directory = try Indoorly.directory()
            venues = try await directory.venues(near: coordinate, radiusMeters: 2_000)
        } catch {
            errorMessage = String(describing: error)
        }
    }

    func loadPlaces(venueID: String) async {
        do {
            let directory = try Indoorly.directory()
            places = try await directory.places(venueID: venueID)
        } catch {
            errorMessage = String(describing: error)
        }
    }
}

struct StoreBrowserView: View {
    @StateObject private var model = StoreBrowserModel()
    @State private var showNavigation = false
    @State private var selectedStops: [String] = []

    var body: some View {
        List {
            ForEach(model.venues) { venue in
                Button(venue.name) {
                    Indoorly.venueID(venue.id)
                    showNavigation = true
                }
            }
        }
        .task { await model.loadVenues() }
        .fullScreenCover(isPresented: $showNavigation) {
            NavigationHostView(isPresented: $showNavigation, stops: selectedStops)
        }
    }
}

IndoorPlaceInfo.x / .y are floor-plan meters, not GPS.

Admin-only directory writes

Requires an sk_… token:

let directory = try Indoorly.directory()

try await directory.addPlace(
    venueID: "walmart-demo",
    name: "Wine",
    category: "liquor"   // or pass x: / y: in floor-plan meters
)
try await directory.updatePlace(venueID: "walmart-demo", placeID: "dairy", name: "Dairy")
try await directory.removePlace(venueID: "walmart-demo", placeID: "dairy")
try await directory.updateVenue(
    venueID: "walmart-demo",
    name: "Walmart Downtown",
    latitude: 19.43,
    longitude: -99.13
)

Issue pk_… / sk_… in the Indoorly admin panel (Tokens SDK / Comercios). Panel CRM routes (users, search, analytics) are not part of this mobile package.


14. Staff app — register / extend a store

Staff builds must use sk_… and enableAddMoreStores(true).

@main
struct StaffApp: App {
    init() {
        Indoorly.initialize(apiToken: "sk_…")
        Indoorly.enableAddMoreStores(true)
        Indoorly.appearance(IndoorAppearance(layout: .withHostBackButton))
    }

    var body: some Scene {
        WindowGroup {
            StaffHomeView()
        }
    }
}

struct StaffHomeView: View {
    @State private var showSurvey = false

    var body: some View {
        Button("Register store by walking") { showSurvey = true }
            .fullScreenCover(isPresented: $showSurvey) {
                SurveyHostView(isPresented: $showSurvey)
            }
    }
}

struct SurveyHostView: View {
    @Binding var isPresented: Bool

    var body: some View {
        ZStack(alignment: .topLeading) {
            if let survey = try? Indoorly.surveyView(venue: .new(name: "Downtown Store")) {
                survey
                    .onFinish { venue in
                        print("Saved", venue.id, venue.name)
                        isPresented = false
                    }
                    .onCancel {
                        isPresented = false
                    }
            } else {
                ContentUnavailableView(
                    "Survey unavailable",
                    systemImage: "lock",
                    description: Text("Needs sk_… token and enableAddMoreStores(true).")
                )
            }

            Button { isPresented = false } label: {
                Image(systemName: "xmark")
                    .frame(width: 44, height: 44)
                    .background(.ultraThinMaterial, in: Circle())
            }
            .padding()
        }
    }
}

Venue modes:

// Create a new store (id derived from name when omitted)
.surveyView(venue: .new(name: "Downtown Store"))
.surveyView(venue: .new(name: "Downtown Store", id: "downtown-01"))

// Extend a store that was surveyed before (relocalize + add to world map)
.surveyView(venue: .existing(id: "downtown-01"))

15. Device GPS & capabilities

GPS helper

import IndoorSDK
import CoreLocation

let location = IndoorLocation()
location.requestWhenInUse()
location.start()

// Observe:
// location.coordinate
// location.authorization   // .notDetermined / .denied / .authorizedWhenInUse / …
location.stop()

Device capabilities

let caps = IndoorDeviceCapabilities.current
// caps.supportsWorldTracking
// caps.hasLiDAR
// caps.supportsSceneDepth

LiDAR does not unlock extra features in this version. Navigation uses a 2D floor graph plus ARWorldMap relocalization when the venue was surveyed with a world map.

On Simulator (or devices without world tracking), the map preview still works; the AR camera path will not.


16. Advanced setup (without the facade)

When you need per-screen configuration instead of the global Indoorly settings:

let config = IndoorConfiguration(
    serviceURL: Indoorly.productionServiceURL,
    clientToken: "pk_…",
    venueID: "walmart-demo",
    appearance: IndoorAppearance(layout: .withHostBackButton),
    hostOptions: IndoorHostOptions(allowsSurvey: false)
)

IndoorNavigationView(configuration: config, stops: ["pharmacy"])
    .onArrival { _ in }
    .onVisitFinished { _ in }

Survey without the facade:

let surveyConfig = IndoorSurveyConfiguration(
    serviceURL: Indoorly.productionServiceURL,
    adminToken: "sk_…",
    venue: .new(name: "Downtown Store"),
    appearance: .standard
)

IndoorSurveyView(configuration: surveyConfig)
    .onFinish { _ in }
    .onCancel { }

17. Telemetry

By default the SDK reports sessions, arrivals, and failures to Indoorly (analytics panel).

Indoorly.telemetry(false)   // opt out — call at launch with initialize

18. UIKit / AppDelegate apps

Configure in AppDelegate

import UIKit
import IndoorSDK

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        Indoorly.initialize(apiToken: "pk_…")
        Indoorly.enableAddMoreStores(false)
        Indoorly.appearance(IndoorAppearance(layout: .withHostBackButton))
        return true
    }
}

Present SwiftUI from UIKit

import SwiftUI
import IndoorSDK
import UIKit

final class HomeViewController: UIViewController {
    @IBAction func startNavigation() {
        guard let nav = try? Indoorly.navigationView(stops: []) else { return }
        let host = UIHostingController(
            rootView: nav
                .onArrival { place in print(place.name) }
                .onVisitFinished { _ in self.dismiss(animated: true) }
        )
        host.modalPresentationStyle = .fullScreen
        present(host, animated: true)
    }
}

19. API reference (public surface)

API Role
Indoorly.initialize(apiToken:serviceURL:) Required once at launch
Indoorly.bootstrap() Scoped venues + GPS + presence settings from the panel
Indoorly.appearance(_:) Brand colors, icons, layout, arrival illuminate / haptic
Indoorly.venueID(_:) Pin or clear venue
Indoorly.enableAddMoreStores(_:) Allow survey UI (needs sk_…)
Indoorly.telemetry(_:) Opt in/out of usage events
Indoorly.nearbyRadiusMeters(_:) GPS radius preference for host flows
Indoorly.navigationView(stops:) Full navigation UI (auto presence heartbeats)
Indoorly.surveyView(venue:) Walking survey / register UI
Indoorly.directory() Venues, discovery, resolve, presence, places
Indoorly.configuration(venueID:) Build IndoorConfiguration from facade
IndoorDirectory.bootstrap() Same as Indoorly.bootstrap()
IndoorDirectory.venues(annotatedNear:) All scoped venues + distance
IndoorDirectory.venues(near:radiusMeters:) Discovery list (respects .always / .nearby)
IndoorDirectory.resolve(near:radiusMeters:ambiguityMeters:) Single nearest store
IndoorDirectory.reportPresence / presence Live in-store count
IndoorNavigationView Navigation UI (advanced init)
IndoorSurveyView Survey UI (advanced init)
IndoorAppearance / IndoorLayout Visual customization
IndoorBootstrap / IndoorSDKSettings Bootstrap snapshot + panel settings
IndoorDiscoveryMode .always / .nearby
IndoorPresenceInfo Live visitor count
IndoorPlaceInfo / IndoorVenueInfo / IndoorVisitSummary Models
IndoorLocation When-in-use GPS helper (coordinate, accuracyMeters)
IndoorDeviceCapabilities AR / LiDAR probes
IndoorError unauthorized, adminTokenRequired, unknownVenue, ambiguousVenue, …

Readable state after configure: Indoorly.isConfigured, apiToken, configuredVenueID, configuredAppearance, addMoreStoresEnabled, etc.


20. Common issues

Situation Check
Xcode shows nothing after Indoorly. Update package to 1.1.1+, Clean Build Folder, rebuild
401 / unauthorized Empty token, rotated token, or sk_ used by mistake in a visitor app
Indoorly.navigationView throws / nil initialize was never called, or token string is empty
No venues Business has no stores, or the pk_… is scoped to an empty allow-list — check bootstrap
ambiguousVenue on resolve Two stores within ambiguityMeters; show a picker
Nearby list empty Store set to discoveryMode: nearby without GPS, or visitor outside radius
Camera does not open Simulator or device without ARWorldTracking; map preview still works
Register / survey UI missing Needs sk_… and enableAddMoreStores(true)
Banners under your Back button Use IndoorLayout.withHostBackButton or raise topLeadingReserved
SPM product conflict Same target cannot link both local IndoorSDK source and this binary package

21. Support

  • Tokens, venues, destinations, analytics: your organization’s Indoorly admin panel
  • Package versions: git tags on this repository (SPM)
  • Release assets: Releases (XCFramework zip only — no source)

About

Indoor navigation for stores, super and much more.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages