CuppaNavigation

API-driven navigation system that loads navigation structure from a remote manifest, supporting deep linking, search integration, and dynamic routing.

Features

  • API-Driven: Load navigation from remote manifest
  • 5-Layer Fallback: Fresh cache → ETag refresh → Network → Bundled → Stale cache
  • Deep Linking: Universal links and custom URL schemes
  • Search Integration: Built-in searchable navigation
  • Offline Support: Bundled fallback manifest
  • Type-Safe: Full Swift type safety with Codable
  • Caching: Persistent disk cache with ETag support
  • Retry Logic: Exponential backoff for failed requests

Installation

Add to your Package.swift:

dependencies: [
    .package(url: "https://github.com/cuppa-platform/cuppa-ios-v2", from: "1.0.0")
]

.target(
    name: "YourApp",
    dependencies: [
        .product(name: "CuppaNavigation", package: "cuppa-ios-v2")
    ]
)

Quick Start

1. Basic Setup

import SwiftUI
import CuppaNavigation

@main
struct MyApp: App {
    @StateObject private var coordinator = NavigationCoordinator()

    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $coordinator.path) {
                HomeView()
                    .navigationDestination(for: NavigationItem.self) { item in
                        coordinator.view(for: item)
                    }
            }
            .environmentObject(coordinator)
            .task {
                await coordinator.loadManifest()
            }
        }
    }
}

2. Navigation Items

struct HomeView: View {
    @EnvironmentObject var coordinator: NavigationCoordinator

    var body: some View {
        List {
            ForEach(coordinator.sections) { section in
                Section(header: Text(section.title)) {
                    ForEach(section.children ?? []) { item in
                        NavigationLink(value: item) {
                            HStack {
                                Image(systemName: item.icon ?? "doc")
                                    .foregroundColor(Color(hex: item.color ?? "#8B4513"))
                                VStack(alignment: .leading) {
                                    Text(item.title)
                                    if let subtitle = item.subtitle {
                        Text(subtitle)
                                            .font(.caption)
                                            .foregroundColor(.secondary)
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
        .navigationTitle("Home")
    }
}

Manifest Structure

NavigationManifest

The root manifest structure:

public struct NavigationManifest: Codable, Sendable {
    public let version: String
    public let updatedAt: Date
    public let sections: [NavigationItem]
    public let specialRoutes: [SpecialRoute]?
    public let deepLinking: DeepLinkingConfig?
    public let searchCategories: [String: SearchCategory]?
}

NavigationItem

Individual navigation items with metadata:

public struct NavigationItem: Codable, Sendable, Identifiable, Hashable {
    public let id: String
    public let path: String
    public let title: String
    public let subtitle: String?
    public let icon: String?
    public let color: String?
    public let accessLevel: AccessLevel
    public let priority: Int
    public let metadata: NavigationMetadata?
    public let children: [NavigationItem]?
}

Example Manifest

{
  "version": "1.0.0",
  "updated_at": "2025-11-14T00:00:00Z",
  "sections": [
    {
      "id": "home",
      "path": "/home",
      "title": "Home",
      "subtitle": "Landing & Overview",
      "icon": "house.fill",
      "color": "#8B4513",
      "accessLevel": "public",
      "priority": 1,
      "metadata": {
        "searchable": true,
        "keywords": ["home", "landing", "start"],
        "tags": ["navigation", "main"],
        "description": "App home screen"
      },
      "children": [
        {
          "id": "home-profile",
          "path": "/home/profile",
          "title": "Profile",
          "accessLevel": "authenticated"
        }
      ]
    }
  ]
}

Enhanced Manifest Loader

Configuration

public struct ManifestLoaderConfiguration: Sendable {
    public let cacheEnabled: Bool
    public let cacheDuration: TimeInterval
    public let maxRetries: Int
    public let initialRetryDelay: TimeInterval
    public let timeout: TimeInterval

    public static let `default` = ManifestLoaderConfiguration(
        cacheEnabled: true,
        cacheDuration: 3600,        // 1 hour
        maxRetries: 3,
        initialRetryDelay: 1.0,     // 1 second
        timeout: 30.0               // 30 seconds
    )
}

Loading Strategy

The loader follows a 5-layer fallback strategy:

let loader = EnhancedManifestLoader(
    apiURL: URL(string: "https://mycuppa.io/api/navigation/manifest")!,
    configuration: .default
)

// Load with automatic fallback
let manifest = try await loader.load()

Fallback Order:

  1. Fresh Cache: Return cached manifest if not expired
  2. ETag Refresh: Send If-None-Match header to check for updates
  3. Network Fetch: Full network request with retry logic
  4. Bundled Fallback: Load from app bundle if network fails
  5. Stale Cache: Return expired cache as last resort

Caching

// File-based persistent cache
let cache = FileManifestCache()

// Save manifest
try await cache.save(manifest, etag: "abc123")

// Load cached manifest
if let cached = try await cache.load() {
    print("Cached version: \(cached.manifest.version)")
    print("Cached at: \(cached.cachedAt)")
    print("Is expired: \(cached.isExpired(cacheDuration: 3600))")
}

// Clear cache
try await cache.clear()

ETag Support

// Load with ETag for efficient updates
let (manifest, newETag) = try await loader.fetchWithETag(
    currentETag: "previous-etag"
)

if let newETag = newETag {
    print("Manifest updated, new ETag: \(newETag)")
} else {
    print("Manifest unchanged (304 Not Modified)")
}

Deep Linking

Configuration

public struct DeepLinkingConfig: Codable, Sendable {
    public let customSchemes: [String]
    public let universalLinks: UniversalLinkConfig
    public let authPatterns: AuthPatterns?
}

public struct UniversalLinkConfig: Codable, Sendable {
    public let domains: [String]
    public let basePath: String
}

Handling Deep Links

@main
struct MyApp: App {
    @StateObject private var coordinator = NavigationCoordinator()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(coordinator)
                .onOpenURL { url in
                    Task {
                        await coordinator.handleDeepLink(url)
                    }
                }
        }
    }
}

Deep Link Patterns

// Custom URL schemes
mycuppa://home/profile
cuppa://settings/notifications

// Universal links
https://app.mycuppa.io/home/profile
https://mycuppa.app/settings

// Auth callbacks
mycuppa://auth/callback?access_token=xxx&refresh_token=yyy

Search Integration

Searchable Navigation

struct NavigationSearch: View {
    @EnvironmentObject var coordinator: NavigationCoordinator
    @State private var searchText = ""

    var filteredItems: [NavigationItem] {
        coordinator.search(query: searchText)
    }

    var body: some View {
        List(filteredItems) { item in
            NavigationLink(value: item) {
                SearchResultRow(item: item)
            }
        }
        .searchable(text: $searchText, prompt: "Search navigation")
    }
}

Search Metadata

public struct NavigationMetadata: Codable, Sendable {
    public let searchable: Bool
    public let keywords: [String]
    public let tags: [String]
    public let description: String?
}

// Items with searchable metadata appear in search results

Access Control

Access Levels

public enum AccessLevel: String, Codable, Sendable {
    case `public`       // Anyone can access
    case authenticated  // Requires login
    case premium        // Requires premium subscription
    case admin          // Admin users only
}

Filtering by Access

// Filter navigation by user's access level
let userLevel: AccessLevel = .authenticated

let accessibleItems = coordinator.sections.flatMap { section in
    section.children ?? []
}.filter { item in
    item.accessLevel == .public || item.accessLevel == userLevel
}

Special Routes

Configuration

public struct SpecialRoute: Codable, Sendable, Identifiable {
    public let path: String
    public let handler: String
    public let requiresAuth: Bool
    public var id: String { path }
}

Example Special Routes

{
  "specialRoutes": [
    {
      "path": "/profile",
      "handler": "openDrawer",
      "requiresAuth": true
    },
    {
      "path": "/settings",
      "handler": "SettingsCoordinator",
      "requiresAuth": true
    }
  ]
}

Advanced Usage

Custom Manifest Source

// Load from custom endpoint
let customLoader = EnhancedManifestLoader(
    apiURL: URL(string: "https://custom-api.com/manifest")!,
    configuration: ManifestLoaderConfiguration(
        cacheEnabled: true,
        cacheDuration: 7200,  // 2 hours
        maxRetries: 5,
        initialRetryDelay: 2.0,
        timeout: 60.0
    )
)

Offline-First Strategy

// Load from cache first, then refresh in background
Task {
    // Load cached manifest immediately
    if let cached = try? await cache.load() {
        await coordinator.setManifest(cached.manifest)
    }

    // Refresh from network in background
    Task.detached {
        do {
            let fresh = try await loader.load()
            await coordinator.setManifest(fresh)
        } catch {
            print("Background refresh failed: \(error)")
        }
    }
}

Platform-Specific Manifests

// Request platform-specific manifest
let url = URL(string: "https://mycuppa.io/api/navigation/manifest?platform=ios")!
let loader = EnhancedManifestLoader(apiURL: url)

Locale-Specific Navigation

// Request localized manifest
let locale = Locale.current.language.languageCode?.identifier ?? "en"
let url = URL(string: "https://mycuppa.io/api/navigation/manifest?locale=\(locale)")!

Testing

Mock Manifest

let mockManifest = NavigationManifest(
    version: "1.0.0",
    updatedAt: Date(),
    sections: [
        NavigationItem(
            id: "home",
            path: "/home",
            title: "Home",
            subtitle: "Main screen",
            icon: "house.fill",
            color: "#8B4513",
            accessLevel: .public,
            priority: 1,
            metadata: nil,
            children: nil
        )
    ],
    specialRoutes: nil,
    deepLinking: nil,
    searchCategories: nil
)

In-Memory Cache

// Use memory cache for testing
let memoryCache = MemoryManifestCache()

try await memoryCache.save(mockManifest, etag: "test-etag")
let cached = try await memoryCache.load()

Best Practices

  1. Bundle a Fallback: Always include a fallback manifest in your app bundle
  2. Cache Aggressively: Use long cache durations for better offline support
  3. Handle Errors Gracefully: Show cached content when network fails
  4. Monitor ETag: Check server support for ETag to optimize bandwidth
  5. Test Offline: Verify app works without network connectivity
  6. Version Your Manifest: Use semantic versioning for manifest updates
  7. Validate Deep Links: Test all deep link patterns
  8. Search Optimization: Add comprehensive keywords to searchable items
  9. Access Control: Implement proper access level checking
  10. Performance: Load manifest asynchronously on app launch

Performance Optimization

Preload on Launch

@main
struct MyApp: App {
    @StateObject private var coordinator = NavigationCoordinator()

    init() {
        // Preload manifest during app initialization
        Task {
            await coordinator.loadManifest()
        }
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(coordinator)
        }
    }
}

Background Refresh

// Refresh manifest periodically
Task {
    while !Task.isCancelled {
        try? await Task.sleep(nanoseconds: 3600_000_000_000) // 1 hour
        await coordinator.refreshManifest()
    }
}

API Reference

EnhancedManifestLoader

public actor EnhancedManifestLoader {
    public init(
        apiURL: URL,
        configuration: ManifestLoaderConfiguration = .default,
        cache: ManifestCache? = FileManifestCache(),
        bundledManifest: NavigationManifest? = nil
    )

    public func load() async throws -> NavigationManifest
    public func fetchWithETag(_ etag: String?) async throws -> (NavigationManifest, String?)
}

NavigationCoordinator

@MainActor
public final class NavigationCoordinator: ObservableObject {
    @Published public private(set) var sections: [NavigationItem] = []
    @Published public var path = NavigationPath()

    public func loadManifest() async
    public func navigate(to item: NavigationItem)
    public func navigate(to path: String)
    public func search(query: String) -> [NavigationItem]
    public func handleDeepLink(_ url: URL) async
}

Related Modules

Support

Found an issue with this page? Report it on GitHub