Skip to main content

Complete ContentView with Loading States

This production-ready example shows all three SDK states: loading, error, and ready.
import SwiftUI
import Combine
import StringbootSDK

struct ContentView: View {

    @ObservedObject var stringProvider = StringProvider.shared
    @State private var selectedLanguage: String = ""

    var body: some View {
        NavigationView {
            Group {
                if stringProvider.isReady {
                    mainContent
                } else if let error = stringProvider.initializationError {
                    errorView(error)
                } else {
                    loadingView
                }
            }
            .navigationTitle("Stringboot Demo")
            .onAppear {
                selectedLanguage = stringProvider.currentLanguage ?? stringProvider.deviceLocale()
            }
            .onChange(of: stringProvider.currentLanguage) { _, newLang in
                if let newLang = newLang {
                    selectedLanguage = newLang
                }
            }
        }
    }

    private var loadingView: some View {
        VStack(spacing: 20) {
            ProgressView()
                .scaleEffect(2.0)
                .padding()

            Text("Loading strings...")
                .font(.headline)
                .foregroundColor(.secondary)
        }
    }

    private func errorView(_ message: String) -> some View {
        VStack(spacing: 20) {
            Image(systemName: "exclamationmark.triangle.fill")
                .font(.system(size: 60))
                .foregroundColor(.orange)
                .padding()

            Text("Initialization Failed")
                .font(.title2)
                .fontWeight(.bold)

            Text(message)
                .font(.body)
                .foregroundColor(.secondary)
                .multilineTextAlignment(.center)
                .padding(.horizontal, 32)

            Button {
                Task {
                    await stringProvider.retryInitialization()
                }
            } label: {
                HStack {
                    Image(systemName: "arrow.clockwise")
                    Text("Retry")
                }
                .font(.headline)
            }
            .buttonStyle(.borderedProminent)
            .padding(.top)
        }
    }

    private var mainContent: some View {
        VStack(spacing: 20) {
            // Your app content here
            Text("SDK Ready!")
        }
    }
}

Language Change with Loading Overlay

Handle language switching with visual feedback to the user:
struct ContentView: View {

    @ObservedObject var stringProvider = StringProvider.shared
    @State private var selectedLanguage: String = ""

    var body: some View {
        NavigationView {
            VStack {
                // Your content here
            }
            .alert("Language Change Failed", isPresented: .constant(stringProvider.languageChangeError != nil)) {
                Button("OK") {
                    selectedLanguage = stringProvider.currentLanguage ?? stringProvider.deviceLocale()
                }
            } message: {
                Text(stringProvider.languageChangeError ?? "Unknown error")
            }
            .overlay {
                if stringProvider.isChangingLanguage {
                    ZStack {
                        Color.black.opacity(0.4)
                            .ignoresSafeArea()

                        VStack(spacing: 16) {
                            ProgressView()
                                .scaleEffect(1.5)
                                .tint(.white)

                            Text("Changing language...")
                                .foregroundColor(.white)
                                .font(.headline)
                        }
                        .padding(32)
                        .background(Color(.systemBackground))
                        .cornerRadius(16)
                        .shadow(radius: 20)
                    }
                }
            }
        }
    }
}

Language Picker Integration

Use a language picker with proper state management:
struct ContentView: View {

    @ObservedObject var stringProvider = StringProvider.shared
    @State private var selectedLanguage: String = ""

    var body: some View {
        VStack {
            // Language Picker - Just observe and call SDK
            Picker("Language", selection: $selectedLanguage) {
                ForEach(stringProvider.availableLanguages, id: \.code) { language in
                    Text(language.name).tag(language.code)
                }
            }
            .pickerStyle(.segmented)
            .padding()
            .disabled(stringProvider.isChangingLanguage)
            .onChange(of: selectedLanguage) { _, newLanguage in
                Task {
                    await stringProvider.changeLanguage(to: newLanguage)
                }
            }
        }
        .onAppear {
            selectedLanguage = stringProvider.currentLanguage ?? stringProvider.deviceLocale()
        }
        .onChange(of: stringProvider.currentLanguage) { _, newLang in
            if let newLang = newLang {
                selectedLanguage = newLang
            }
        }
    }
}

Using SBText for Auto-Updating Strings

The SBText component automatically updates when the language changes:
import SwiftUI
import StringbootSDK

struct ContentView: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 15) {

            // Using SBText - SDK handles everything automatically:
            // - Language detection (follows picker selection)
            // - Auto-updates when strings refresh from network
            // - Falls back to Localizable.xcstrings if backend offline
            SBText("welcome_message")
                .font(.title)
                .fontWeight(.bold)

            SBText("title_activity_main")
                .font(.body)
                .foregroundColor(.secondary)

            SBText("app_description")
                .font(.subheadline)
                .foregroundColor(.gray)
        }
        .padding()
    }
}

Refresh from Network and Cache Management

Manually trigger network refreshes and manage the cache:
struct ContentView: View {

    @ObservedObject var stringProvider = StringProvider.shared
    @State private var selectedLanguage: String = ""

    var body: some View {
        VStack {
            Button("Refresh from Network") {
                Task {
                    let lang = selectedLanguage.isEmpty ? nil : selectedLanguage
                    StringbootLogger.i("Refresh button: fetching \(lang ?? "device locale")")
                    let success = await stringProvider.refreshFromNetwork(lang: lang, forceRefresh: true)
                    StringbootLogger.i("Refresh button: result = \(success)")
                    // No need to toggle refreshTrigger - SDK auto-updates via lastUpdate
                }
            }
            .buttonStyle(.borderedProminent)

            Button("Clear Cache") {
                stringProvider.clearCache(clearDatabase: false)
                // No need to toggle refreshTrigger - SDK auto-updates
            }
            .buttonStyle(.bordered)
        }
        .padding()
    }
}

Cache Statistics View

Display real-time cache performance metrics:
import SwiftUI
import StringbootSDK

struct CacheStatsView: View {

    @State private var stats: CacheStats?

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Cache Statistics")
                .font(.headline)

            if let stats = stats {
                HStack {
                    Text("Size:")
                    Spacer()
                    Text("\(stats.memorySize) / \(stats.memoryMaxSize)")
                }

                HStack {
                    Text("Hit Rate:")
                    Spacer()
                    Text(String(format: "%.2f%%", stats.hitRate * 100))
                }

                HStack {
                    Text("Hits:")
                    Spacer()
                    Text("\(stats.memoryHitCount)")
                }

                HStack {
                    Text("Misses:")
                    Spacer()
                    Text("\(stats.memoryMissCount)")
                }

                HStack {
                    Text("Evictions:")
                    Spacer()
                    Text("\(stats.memoryEvictionCount)")
                }
            }
        }
        .font(.caption)
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(8)
        .padding(.horizontal)
        .onAppear {
            updateStats()
        }
        .onReceive(Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()) { _ in
            updateStats()
        }
    }

    private func updateStats() {
        stats = StringProvider.shared.getCacheStats()
    }
}

Complete App Structure

Here’s a production-ready example combining all patterns:
import SwiftUI
import StringbootSDK

@main
struct MyApp: App {

    init() {
        // Configure logging
        StringbootLogger.isLoggingEnabled = true
        StringbootLogger.logLevel = .debug

        // Initialize SDK
        StringProvider.shared.initialize(
            cacheSize: 1000,
            apiToken: "YOUR_API_TOKEN_HERE",
            baseURL: "https://api.stringboot.com",
            autoSync: true
        )

        // Restore saved language
        let savedLang = UserDefaults.standard.string(forKey: "com.stringboot.currentLanguage")
            ?? StringProvider.shared.deviceLocale()
        StringProvider.shared.setLocale(savedLang)
    }

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

struct MainView: View {

    @ObservedObject var stringProvider = StringProvider.shared
    @State private var selectedLanguage: String = ""

    var body: some View {
        NavigationView {
            Group {
                if stringProvider.isReady {
                    contentView
                } else if let error = stringProvider.initializationError {
                    errorView(error)
                } else {
                    loadingView
                }
            }
            .navigationTitle("My App")
            .onAppear {
                selectedLanguage = stringProvider.currentLanguage ?? stringProvider.deviceLocale()
            }
            .onChange(of: stringProvider.currentLanguage) { _, newLang in
                if let newLang = newLang {
                    selectedLanguage = newLang
                    UserDefaults.standard.set(newLang, forKey: "com.stringboot.currentLanguage")
                }
            }
            .alert("Language Change Failed", isPresented: .constant(stringProvider.languageChangeError != nil)) {
                Button("OK") {
                    selectedLanguage = stringProvider.currentLanguage ?? stringProvider.deviceLocale()
                }
            } message: {
                Text(stringProvider.languageChangeError ?? "Unknown error")
            }
            .overlay {
                if stringProvider.isChangingLanguage {
                    languageChangeOverlay
                }
            }
        }
    }

    private var loadingView: some View {
        VStack(spacing: 20) {
            ProgressView()
                .scaleEffect(2.0)
                .padding()

            Text("Loading strings...")
                .font(.headline)
                .foregroundColor(.secondary)
        }
    }

    private func errorView(_ message: String) -> some View {
        VStack(spacing: 20) {
            Image(systemName: "exclamationmark.triangle.fill")
                .font(.system(size: 60))
                .foregroundColor(.orange)
                .padding()

            Text("Initialization Failed")
                .font(.title2)
                .fontWeight(.bold)

            Text(message)
                .font(.body)
                .foregroundColor(.secondary)
                .multilineTextAlignment(.center)
                .padding(.horizontal, 32)

            Button {
                Task {
                    await stringProvider.retryInitialization()
                }
            } label: {
                HStack {
                    Image(systemName: "arrow.clockwise")
                    Text("Retry")
                }
                .font(.headline)
            }
            .buttonStyle(.borderedProminent)
            .padding(.top)
        }
    }

    private var contentView: some View {
        VStack(spacing: 20) {

            // Language Picker
            Picker("Language", selection: $selectedLanguage) {
                ForEach(stringProvider.availableLanguages, id: \.code) { language in
                    Text(language.name).tag(language.code)
                }
            }
            .pickerStyle(.segmented)
            .padding()
            .disabled(stringProvider.isChangingLanguage)
            .onChange(of: selectedLanguage) { _, newLanguage in
                Task {
                    await stringProvider.changeLanguage(to: newLanguage)
                }
            }

            Divider()

            // Auto-updating string views
            VStack(alignment: .leading, spacing: 15) {
                SBText("welcome_message")
                    .font(.title)
                    .fontWeight(.bold)

                SBText("app_description")
                    .font(.body)
                    .foregroundColor(.secondary)
            }
            .padding()

            Spacer()

            // Cache Statistics
            CacheStatsView()

            // Actions
            VStack(spacing: 10) {
                Button("Refresh from Network") {
                    Task {
                        let lang = selectedLanguage.isEmpty ? nil : selectedLanguage
                        _ = await stringProvider.refreshFromNetwork(lang: lang, forceRefresh: true)
                    }
                }
                .buttonStyle(.borderedProminent)

                Button("Clear Cache") {
                    stringProvider.clearCache(clearDatabase: false)
                }
                .buttonStyle(.bordered)
            }
            .padding()
        }
    }

    private var languageChangeOverlay: some View {
        ZStack {
            Color.black.opacity(0.4)
                .ignoresSafeArea()

            VStack(spacing: 16) {
                ProgressView()
                    .scaleEffect(1.5)
                    .tint(.white)

                Text("Changing language...")
                    .foregroundColor(.white)
                    .font(.headline)
            }
            .padding(32)
            .background(Color(.systemBackground))
            .cornerRadius(16)
            .shadow(radius: 20)
        }
    }
}

Async/Await String Retrieval

For cases where you need direct string access without SwiftUI binding:
struct MyView: View {
    @State private var welcomeText = ""

    var body: some View {
        Text(welcomeText)
            .task {
                // Get string with async/await
                welcomeText = await StringProvider.shared.get(
                    "welcome_message",
                    lang: "en"
                )
            }
    }
}

Observable Properties Reference

The StringProvider exposes these @Published properties for reactive UI updates:
PropertyTypeDescription
isReadyBoolSDK successfully initialized
initializationErrorString?Initialization error message
isChangingLanguageBoolLanguage change in progress
languageChangeErrorString?Language change error message
currentLanguageString?Current active language code
availableLanguages[ActiveLanguage]List of available languages

A/B Testing Integration

SDK v1.2.0+ includes built-in A/B testing support. The SDK automatically:
  • Generates or uses a persistent device ID for consistent experiment bucketing
  • Receives experiment assignments from the backend (V2 API)
  • Delivers the correct variant strings to users
  • Integrates with your analytics platform (Firebase, Mixpanel, Amplitude, etc.)

How It Works

  1. Device ID: SDK generates a UUID per installation (or uses your app’s device ID)
  2. X-Device-ID Header: Sent with every API request
  3. Backend Assignment: Server assigns device to experiment variants based on device ID
  4. String Resolution: SDK receives pre-resolved strings for assigned variants
  5. Analytics Tracking: SDK notifies your analytics handler of experiment assignments

Basic A/B Testing Setup (SDK-Generated Device ID)

@main
struct MyApp: App {
    init() {
        // SDK will generate and persist a UUID automatically
        StringProvider.shared.initialize(
            cacheSize: 1000,
            apiToken: "YOUR_TOKEN",
            baseURL: "https://api.stringboot.com",
            autoSync: true
        )
    }

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

With Custom Device ID

Use your app’s existing device ID for consistency:
@main
struct MyApp: App {
    init() {
        // Use your app's existing device ID for consistency
        StringProvider.shared.initialize(
            cacheSize: 1000,
            apiToken: "YOUR_TOKEN",
            baseURL: "https://api.stringboot.com",
            autoSync: true,
            providedDeviceId: "your-app-device-id-67890"
        )
    }
}

With Analytics Integration

Track experiment assignments in your analytics platform:
import StringbootSDK
import FirebaseAnalytics

class MyAnalyticsHandler: StringbootAnalyticsHandler {
    func onExperimentsAssigned(experiments: [String: ExperimentAssignment]) {
        for (key, assignment) in experiments {
            // Firebase Analytics
            Analytics.setUserProperty(
                assignment.variantName,
                forName: "stringboot_exp_\(key)"
            )

            // Mixpanel
            Mixpanel.mainInstance().people.set(
                property: "stringboot_exp_\(key)",
                to: assignment.variantName
            )

            // Amplitude
            Amplitude.instance().setUserProperties([
                "stringboot_exp_\(key)": assignment.variantName
            ])
        }
    }
}

@main
struct MyApp: App {
    let analyticsHandler = MyAnalyticsHandler()

    init() {
        StringProvider.shared.initialize(
            cacheSize: 1000,
            apiToken: "YOUR_TOKEN",
            baseURL: "https://api.stringboot.com",
            autoSync: true,
            providedDeviceId: getYourAppDeviceId(),
            analyticsHandler: analyticsHandler
        )
    }

    private func getYourAppDeviceId() -> String? {
        // Use your existing device identifier
        // return UserDefaults.standard.string(forKey: "app_device_id")

        // Or let SDK generate its own UUID
        return nil
    }
}

Using Firebase Installation ID

import FirebaseInstallations

private func getFirebaseDeviceId() async -> String? {
    do {
        return try await Installations.installations().installationID()
    } catch {
        return nil
    }
}

// In App.init()
Task {
    let deviceId = await getFirebaseDeviceId()
    StringProvider.shared.initialize(
        cacheSize: 1000,
        apiToken: "YOUR_TOKEN",
        baseURL: "https://api.stringboot.com",
        autoSync: true,
        providedDeviceId: deviceId
    )
}

Debugging Experiments

The SDK logs experiment assignments in debug builds:
📦 Catalog response: 22 strings, experiments: 2
📊 Received 2 experiment(s) from server:
   • welcome-message-test → variant-a (experiment: 22ee1616-36c4-457b-8e3e-f5be39823176)
   • pricing-copy-test → variant-b (experiment: 33ff2727-47d5-568e-9a11-8ccf00678287)
   ✓ Saved experiments to persistent storage

A/B Testing Best Practices

// ✅ Use consistent device ID across all SDKs in your app
StringProvider.shared.initialize(
    providedDeviceId: yourAppDeviceId  // Same ID for Stringboot, analytics, etc.
)

// ✅ Set analytics properties as user properties (not events)
Analytics.setUserProperty(variantName, forName: "stringboot_exp_key")

// ✅ Let SDK handle experiment assignment
// Don't try to manually assign users to variants

// ❌ Don't generate new device IDs on each session
// Device ID must persist across app launches for consistent bucketing

// ❌ Don't use session IDs or temporary identifiers
// Use installation-level persistent IDs