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:
- Fresh Cache: Return cached manifest if not expired
- ETag Refresh: Send If-None-Match header to check for updates
- Network Fetch: Full network request with retry logic
- Bundled Fallback: Load from app bundle if network fails
- 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
- Bundle a Fallback: Always include a fallback manifest in your app bundle
- Cache Aggressively: Use long cache durations for better offline support
- Handle Errors Gracefully: Show cached content when network fails
- Monitor ETag: Check server support for ETag to optimize bandwidth
- Test Offline: Verify app works without network connectivity
- Version Your Manifest: Use semantic versioning for manifest updates
- Validate Deep Links: Test all deep link patterns
- Search Optimization: Add comprehensive keywords to searchable items
- Access Control: Implement proper access level checking
- 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
- CuppaCore - Core utilities and plugin system
- CuppaUI - SwiftUI components
- CuppaSearch - Search functionality