
Offline Queue
- 2 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates an offline operation queue that persists mutations to disk, retries with exponential backoff on reconnect, and resolves conflicts for offline-first apps.
About
Generates a production offline queue that persists API mutations when offline and retries with exponential backoff plus conflict resolution when connectivity returns. A developer uses it to build offline-first behavior with queued, syncing mutations.
- Persists queued mutations to disk
- Exponential-backoff retry on reconnect with conflict resolution
Offline Queue by the numbers
- 2 all-time installs (skills.sh)
- Ranked #3,765 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill offline-queueAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates an offline operation queue that persists mutations to disk, retries with exponential backoff on reconnect, and resolves conflicts for offline-first apps.
Files
Offline Queue Generator
Generate a production offline operation queue that persists API requests/mutations when offline, stores them to disk, and retries with exponential backoff when connectivity returns. Essential for apps that need offline-first behavior.
When This Skill Activates
Use this skill when the user:
- Asks to "add offline queue" or "offline support"
- Wants to "queue requests" when there is no network
- Mentions "offline first" architecture or design
- Asks about "retry when online" or "retry on reconnect"
- Wants "pending operations" that sync later
- Mentions "offline mutations" or "queue API calls"
Pre-Generation Checks
1. Project Context Detection
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Check deployment target (iOS 16+ / macOS 13+)
- [ ] Check for @Observable support (iOS 17+ / macOS 14+)
- [ ] Identify source file locations
2. Conflict Detection
Search for existing networking/offline code:
Glob: **/*OfflineQueue*.swift, **/*OfflineOperation*.swift, **/*NetworkMonitor*.swift, **/*RetryPolicy*.swift
Grep: "NWPathMonitor" or "OfflineQueue" or "pendingOperations" or "offlineQueue"If existing offline handling found:
- Ask if user wants to replace or extend it
- If extending, adapt generated code to existing patterns
3. Framework Availability
Check for Network framework availability (required for NWPathMonitor). Available on iOS 12+ / macOS 10.14+, so effectively always available for our iOS 16+ / macOS 13+ targets.
Configuration Questions
Ask user via AskUserQuestion:
1. Operation types?
- API calls only (JSON requests/responses)
- File uploads only (multipart data)
- Both API calls and file uploads
2. Persistence strategy?
- SwiftData (iOS 17+ / macOS 14+) — structured queries, migration support
- File-based (JSON files in app support) — simpler, wider compatibility — recommended
3. Retry strategy?
- Exponential backoff with jitter — recommended (prevents thundering herd)
- Linear backoff (fixed interval between retries)
- Immediate (retry as soon as connectivity returns, no delay)
4. Conflict resolution?
- Server wins (discard client changes on conflict)
- Client wins (overwrite server data on conflict)
- Manual merge (surface conflicts to the user for resolution)
Generation Process
Step 1: Read Templates
Read patterns.md for architecture guidance and conflict resolution strategies. Read templates.md for production Swift code.
Step 2: Create Core Files
Generate these files: 1. OfflineOperation.swift — Codable model for queued operations 2. OfflineQueueManager.swift — Actor managing enqueue, dequeue, process, retry 3. QueuePersistence.swift — Protocol + file-based implementation for saving operations 4. NetworkMonitor.swift — @Observable wrapper around NWPathMonitor
Step 3: Create Policy Files
5. RetryPolicy.swift — Configurable backoff strategy with jitter
Step 4: Create UI Files
6. OfflineQueueDashboardView.swift — Debug view showing queue state and manual controls 7. OfflineQueueModifier.swift — ViewModifier showing "Offline" banner when disconnected
Step 5: Determine File Location
Check project structure:
- If
Sources/exists →Sources/OfflineQueue/ - If
App/exists →App/OfflineQueue/ - Otherwise →
OfflineQueue/
Output Format
After generation, provide:
Files Created
OfflineQueue/
├── OfflineOperation.swift # Codable operation model
├── OfflineQueueManager.swift # Actor-based queue manager
├── QueuePersistence.swift # Protocol + file-based persistence
├── NetworkMonitor.swift # NWPathMonitor wrapper
├── RetryPolicy.swift # Exponential backoff with jitter
├── OfflineQueueDashboardView.swift # Debug dashboard view
└── OfflineQueueModifier.swift # Offline banner modifierIntegration with Networking Layer
Enqueue an operation when offline:
// In your networking layer or repository
func createPost(_ post: Post) async throws {
guard networkMonitor.isConnected else {
let operation = OfflineOperation(
endpoint: "/api/posts",
httpMethod: .post,
body: try JSONEncoder().encode(post),
headers: ["Content-Type": "application/json"]
)
await queueManager.enqueue(operation)
return
}
// Normal online request
try await apiClient.post("/api/posts", body: post)
}Transparent offline support with a wrapper:
func performOrQueue<T: Codable>(
endpoint: String,
method: HTTPMethod,
body: T
) async throws {
let data = try JSONEncoder().encode(body)
if networkMonitor.isConnected {
try await apiClient.request(endpoint: endpoint, method: method, body: data)
} else {
let operation = OfflineOperation(
endpoint: endpoint,
httpMethod: method,
body: data
)
await queueManager.enqueue(operation)
}
}Show offline banner in your app:
struct ContentView: View {
var body: some View {
NavigationStack {
FeedView()
}
.offlineQueueBanner() // Shows "Offline — changes will sync" when disconnected
}
}Add dashboard for debugging:
#if DEBUG
NavigationLink("Offline Queue") {
OfflineQueueDashboardView()
}
#endifTesting
@Test
func operationEnqueuedWhenOffline() async throws {
let persistence = MockQueuePersistence()
let monitor = MockNetworkMonitor(isConnected: false)
let manager = OfflineQueueManager(persistence: persistence, monitor: monitor)
let operation = OfflineOperation(
endpoint: "/api/posts",
httpMethod: .post,
body: Data("{\"title\":\"Hello\"}".utf8)
)
await manager.enqueue(operation)
let pending = await persistence.loadAll()
#expect(pending.count == 1)
#expect(pending.first?.endpoint == "/api/posts")
}
@Test
func operationsProcessedOnReconnect() async throws {
let persistence = MockQueuePersistence()
let monitor = MockNetworkMonitor(isConnected: false)
let executor = MockOperationExecutor()
let manager = OfflineQueueManager(
persistence: persistence,
monitor: monitor,
executor: executor
)
let operation = OfflineOperation(
endpoint: "/api/posts",
httpMethod: .post,
body: Data("{\"title\":\"Hello\"}".utf8)
)
await manager.enqueue(operation)
// Simulate coming back online
monitor.simulateConnectivityChange(isConnected: true)
// Wait for processing
try await Task.sleep(for: .milliseconds(200))
#expect(executor.executedOperations.count == 1)
let remaining = await persistence.loadAll()
#expect(remaining.isEmpty)
}
@Test
func exponentialBackoffCalculation() {
let policy = RetryPolicy(
maxRetries: 5,
baseDelay: 1.0,
maxDelay: 60.0,
multiplier: 2.0
)
#expect(policy.delay(forAttempt: 0) >= 1.0)
#expect(policy.delay(forAttempt: 1) >= 2.0)
#expect(policy.delay(forAttempt: 2) >= 4.0)
#expect(policy.delay(forAttempt: 5) <= 60.0) // Capped at maxDelay
}Common Patterns
Enqueue Operation
let operation = OfflineOperation(
endpoint: "/api/comments",
httpMethod: .post,
body: try JSONEncoder().encode(comment),
headers: ["Authorization": "Bearer \(token)"]
)
await queueManager.enqueue(operation)Process Queue on Connectivity
// Automatic — OfflineQueueManager observes NetworkMonitor
// and calls processQueue() when connectivity returns.
// No manual intervention needed.Handle Conflict Resolution
// Server returns 409 Conflict
switch conflictStrategy {
case .serverWins:
// Discard local operation, fetch server state
await queueManager.markCompleted(operation)
case .clientWins:
// Retry with force flag
operation.headers["X-Force-Overwrite"] = "true"
await queueManager.retry(operation)
case .manualMerge:
// Surface to user
await queueManager.markConflict(operation, serverData: responseData)
}Gotchas
Operation Ordering and Dependencies
Operations may have dependencies (e.g., "create parent" must succeed before "create child"). The queue processes in FIFO order by default. For explicit dependencies, use the dependsOn field to chain operations, and the queue manager will skip dependent operations until their prerequisites complete.
Idempotency Keys
Every queued operation gets a UUID-based idempotency key. The server must check this key to avoid duplicate processing if the client retries an operation that actually succeeded but the response was lost. Without idempotency keys, a retry could create duplicate records.
Stale Data After Long Offline Periods
If the device is offline for hours or days, queued operations may reference data that has changed server-side. Consider adding a TTL to operations (e.g., 24 hours) and discarding expired operations with a user notification rather than blindly replaying stale mutations.
Background URLSession for Large Uploads
For file uploads, use a background URLSession configuration so uploads continue even when the app is suspended. The standard queue manager handles JSON API calls; for large uploads, delegate to a background transfer service.
Queue Size Limits
Set a maximum queue size (e.g., 500 operations or 50 MB) to prevent unbounded growth. When the limit is reached, notify the user that offline storage is full and suggest connecting to sync pending changes.
References
- templates.md — All production Swift templates
- patterns.md — Offline-first architecture, conflict resolution, idempotency
- Related:
generators/networking-layer— Base networking layer to wrap with offline support - Related:
generators/http-cache— Cache GET responses for offline reading
Offline Queue Patterns & Strategies
Offline-First vs Offline-Tolerant
Two fundamentally different approaches to offline support:
| Aspect | Offline-Tolerant | Offline-First |
|---|---|---|
| Default state | Online; offline is an edge case | Offline; online is a sync opportunity |
| Reads | Always from server | Always from local store |
| Writes | Fail or queue when offline | Always write locally, sync later |
| Complexity | Lower — queue and retry | Higher — full local data layer + sync |
| User experience | "Retry when online" banners | App works seamlessly, syncs in background |
| Best for | Form submissions, mutations | Note apps, todo apps, field apps |
This generator produces an offline-tolerant queue. For full offline-first architecture, combine this with a local persistence layer (SwiftData, Core Data, or SQLite) that serves as the source of truth.
When to Use Each
Offline-tolerant (this generator):
- User submits a form while in a tunnel
- API call fails due to transient connectivity
- File upload interrupted by network switch
- The app primarily reads from the server
Offline-first (requires additional architecture):
- Note-taking or todo apps that must work in airplane mode
- Field service apps used in areas with poor connectivity
- Collaborative apps where users work independently then sync
- Apps where read and write must both work offline
Idempotency
Why Every Queued Operation Needs a Unique Key
Without idempotency keys, this failure scenario creates duplicates:
1. Client sends POST /api/orders (create order)
2. Server processes request, creates order #1234
3. Network drops before response reaches client
4. Client thinks it failed, retries POST /api/orders
5. Server creates ANOTHER order #1235 — duplicate!With idempotency keys:
1. Client sends POST /api/orders with Idempotency-Key: abc-123
2. Server processes request, stores key abc-123 → order #1234
3. Network drops before response reaches client
4. Client retries POST /api/orders with same Idempotency-Key: abc-123
5. Server sees key abc-123 already used → returns order #1234 (no duplicate)Implementation
struct OfflineOperation: Codable {
let idempotencyKey: String // UUID generated at enqueue time
init(endpoint: String, ...) {
// Key is created ONCE when the operation is first enqueued
// and persists across all retry attempts
self.idempotencyKey = UUID().uuidString
}
}Server-Side Deduplication
The server must: 1. Store idempotency keys with their results (e.g., in Redis with TTL) 2. On receiving a request, check if the key exists 3. If key exists, return the stored result without re-processing 4. If key is new, process the request and store the key + result
// Client sends the key as a header
func execute(_ operation: OfflineOperation) async throws {
var request = URLRequest(url: baseURL.appending(path: operation.endpoint))
request.httpMethod = operation.httpMethod.rawValue
request.httpBody = operation.body
request.setValue(operation.idempotencyKey, forHTTPHeaderField: "Idempotency-Key")
for (key, value) in operation.headers {
request.setValue(value, forHTTPHeaderField: key)
}
let (data, response) = try await session.data(for: request)
// Handle response...
}Key Lifetime
- Keys should be stored server-side for at least 24 hours
- Client must use the same key across all retries of the same logical operation
- A new logical operation (even to the same endpoint) gets a new key
Conflict Resolution Strategies
When the client replays a queued operation and the server state has changed, conflicts arise. Four main strategies:
Last-Write-Wins (LWW)
The most recent write overwrites all previous writes, regardless of source.
// Server compares timestamps
// Whichever write has the later timestamp wins
// Simple but can lose data silentlyPros: Simple, no user intervention Cons: Silently loses data, timestamp ordering issues across devices
Server-Wins
When a conflict is detected (HTTP 409), the client discards its local changes and fetches the current server state.
func resolveServerWins(_ operation: OfflineOperation, serverData: Data) async {
// Discard the local operation
await queueManager.markCompleted(operation)
// Fetch fresh server state and update local UI
let currentState = try? JSONDecoder().decode(ServerModel.self, from: serverData)
await updateLocalState(currentState)
}Pros: Server is always the source of truth, simple Cons: User loses offline changes, frustrating UX
Client-Wins
The client forces its changes onto the server, overwriting whatever changed.
func resolveClientWins(_ operation: OfflineOperation) async {
// Retry with a force-overwrite header
var retryOp = operation
retryOp.headers["X-Force-Overwrite"] = "true"
retryOp.headers["If-Match"] = "*" // Override any ETag check
await queueManager.retry(retryOp)
}Pros: User changes are always preserved Cons: Can overwrite important server-side changes from other users
Manual Merge (Three-Way Merge)
Surface the conflict to the user, showing both the local and server versions, and let the user decide.
/// A conflict requiring user resolution.
struct ConflictResolution: Identifiable {
let id: UUID
let operation: OfflineOperation
let localData: Data
let serverData: Data
let description: String
}
/// View for resolving a single conflict.
struct ConflictResolutionView: View {
let conflict: ConflictResolution
let onResolve: (ConflictChoice) -> Void
enum ConflictChoice {
case keepLocal
case keepServer
case mergeManually(Data)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Conflict Detected")
.font(.headline)
Text(conflict.description)
.foregroundStyle(.secondary)
HStack(spacing: 16) {
VStack(alignment: .leading) {
Text("Your Version").font(.subheadline.bold())
// Display local data
}
Divider()
VStack(alignment: .leading) {
Text("Server Version").font(.subheadline.bold())
// Display server data
}
}
HStack {
Button("Keep Mine") { onResolve(.keepLocal) }
.buttonStyle(.bordered)
Button("Keep Server") { onResolve(.keepServer) }
.buttonStyle(.bordered)
}
}
.padding()
}
}Pros: Most accurate, no data loss Cons: Requires user interaction, complex to implement, poor UX if frequent
Choosing a Strategy
| App Type | Recommended Strategy |
|---|---|
| Social media (posts, comments) | Server-wins — other users' context matters |
| Note-taking / personal data | Client-wins — user's changes are sacred |
| Collaborative editing | Manual merge — both versions matter |
| E-commerce (orders) | Idempotency — conflicts shouldn't happen |
| Analytics / logging | Last-write-wins — order doesn't matter |
Operation Dependencies
The Problem
Some operations must execute in a specific order:
1. POST /api/folders (create parent folder)
2. POST /api/documents (create document in that folder)
If #2 executes before #1 → 404: folder not foundSolution: Dependency Chain
let createFolder = OfflineOperation(
id: UUID(),
endpoint: "/api/folders",
httpMethod: .post,
body: folderData
)
let createDocument = OfflineOperation(
endpoint: "/api/documents",
httpMethod: .post,
body: documentData,
dependsOn: createFolder.id // Won't execute until folder is created
)
await queueManager.enqueue(createFolder)
await queueManager.enqueue(createDocument)Queue Manager Dependency Resolution
private func nextOperationToProcess() -> OfflineOperation? {
let completedIDs = Set(queue.filter { $0.status == .completed }.map(\.id))
return queue.first { op in
guard op.status == .pending else { return false }
// Check if dependency is satisfied
if let dependency = op.dependsOn {
let dependencyCompleted = completedIDs.contains(dependency)
let dependencyGone = !queue.contains(where: { $0.id == dependency })
return dependencyCompleted || dependencyGone
}
return true
}
}Cascading Failures
When a parent operation fails permanently, all dependent operations should also fail:
private func cascadeFailure(for operationID: UUID) {
let dependents = queue.filter { $0.dependsOn == operationID }
for var dependent in dependents {
dependent.status = .failed
dependent.lastError = "Dependency failed"
updateOperation(dependent)
// Recursively cascade
cascadeFailure(for: dependent.id)
}
}Background Processing
BGTaskScheduler for Queue Processing
Process the offline queue even when the app is in the background:
import BackgroundTasks
/// Register background task at app launch.
func registerBackgroundTasks() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.app.offlinequeue.process",
using: nil
) { task in
handleOfflineQueueProcessing(task: task as! BGProcessingTask)
}
}
/// Schedule background processing.
func scheduleOfflineQueueProcessing() {
let request = BGProcessingTaskRequest(identifier: "com.app.offlinequeue.process")
request.requiresNetworkConnectivity = true
request.requiresExternalPower = false
request.earliestBeginDate = Date(timeIntervalSinceNow: 60) // 1 minute from now
try? BGTaskScheduler.shared.submit(request)
}
/// Handle background task execution.
func handleOfflineQueueProcessing(task: BGProcessingTask) {
let processingTask = Task {
await queueManager.processQueue()
}
task.expirationHandler = {
processingTask.cancel()
}
Task {
_ = await processingTask.result
task.setTaskCompleted(success: true)
// Schedule next run if there are still pending operations
if await queueManager.pendingCount > 0 {
scheduleOfflineQueueProcessing()
}
}
}Add to Info.plist:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.app.offlinequeue.process</string>
</array>Background URLSession for Large Uploads
For file uploads that should continue when the app is suspended:
/// Background upload session for large file operations.
final class BackgroundUploadManager: NSObject, URLSessionDelegate, URLSessionTaskDelegate, Sendable {
static let shared = BackgroundUploadManager()
private lazy var session: URLSession = {
let config = URLSessionConfiguration.background(
withIdentifier: "com.app.offlinequeue.uploads"
)
config.isDiscretionary = false
config.sessionSendsLaunchEvents = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
func uploadFile(at localURL: URL, to remoteURL: URL) {
var request = URLRequest(url: remoteURL)
request.httpMethod = "POST"
session.uploadTask(with: request, fromFile: localURL).resume()
}
// URLSessionTaskDelegate
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
if let error {
// Handle upload failure — re-enqueue if needed
print("Background upload failed: \(error.localizedDescription)")
} else {
// Upload succeeded
print("Background upload completed")
}
}
}Queue Size Management
Why Limits Matter
Without limits, a device offline for days could accumulate thousands of operations, consuming storage and creating a "thundering herd" when connectivity returns.
Maximum Queue Size
actor OfflineQueueManager {
private let maxQueueSize = 500
private let maxQueueBytes = 50 * 1024 * 1024 // 50 MB
func enqueue(_ operation: OfflineOperation) async throws {
guard queue.count < maxQueueSize else {
throw OfflineQueueError.queueFull(
message: "Queue has \(queue.count) operations. Connect to sync pending changes."
)
}
let currentSize = await persistence.totalSize()
guard currentSize < maxQueueBytes else {
throw OfflineQueueError.storageFull(
message: "Queue storage exceeds \(maxQueueBytes / 1024 / 1024) MB."
)
}
queue.append(operation)
await persistence.save(operation)
}
}
enum OfflineQueueError: Error, LocalizedError {
case queueFull(message: String)
case storageFull(message: String)
var errorDescription: String? {
switch self {
case .queueFull(let message): return message
case .storageFull(let message): return message
}
}
}TTL for Stale Operations
Operations older than a threshold should be discarded:
func pruneStale(olderThan ttl: TimeInterval = 24 * 3600) async {
let cutoff = Date().addingTimeInterval(-ttl)
let stale = queue.filter { $0.createdAt < cutoff }
for operation in stale {
queue.removeAll { $0.id == operation.id }
await persistence.delete(operation.id)
}
if !stale.isEmpty {
// Notify user that stale operations were discarded
NotificationCenter.default.post(
name: .offlineQueuePruned,
object: nil,
userInfo: ["count": stale.count]
)
}
}
extension Notification.Name {
static let offlineQueuePruned = Notification.Name("offlineQueuePruned")
}Pruning Strategy
| Strategy | When | Action |
|---|---|---|
| TTL expiration | On app launch, periodically | Remove operations older than 24h |
| Size-based | Before enqueue | Reject if queue > 500 items or > 50 MB |
| Priority-based | When queue is full | Remove lowest-priority pending operations first |
| Tag-based | On user action | Clear all operations with a specific tag |
Testing Offline Scenarios
Mock NetworkMonitor
/// Testable network monitor that doesn't use NWPathMonitor.
@Observable
final class MockNetworkMonitor: @unchecked Sendable {
var isConnected: Bool
var connectionType: ConnectionType
var isExpensive: Bool
var isConstrained: Bool
init(
isConnected: Bool = true,
connectionType: ConnectionType = .wifi,
isExpensive: Bool = false,
isConstrained: Bool = false
) {
self.isConnected = isConnected
self.connectionType = connectionType
self.isExpensive = isExpensive
self.isConstrained = isConstrained
}
/// Simulate a connectivity change for testing.
func simulateConnectivityChange(isConnected: Bool) {
self.isConnected = isConnected
self.connectionType = isConnected ? .wifi : .none
}
}Mock Persistence
/// In-memory persistence for unit tests.
actor MockQueuePersistence: QueuePersisting {
private var operations: [UUID: OfflineOperation] = [:]
func loadAll() async -> [OfflineOperation] {
Array(operations.values).sorted { $0.createdAt < $1.createdAt }
}
func save(_ operation: OfflineOperation) async {
operations[operation.id] = operation
}
func delete(_ id: UUID) async {
operations.removeValue(forKey: id)
}
func deleteAll() async {
operations.removeAll()
}
}Mock Operation Executor
/// Mock executor that records executed operations for assertions.
actor MockOperationExecutor: OfflineOperationExecuting {
var executedOperations: [OfflineOperation] = []
var resultToReturn: OfflineExecutionResult = .success
var shouldThrow = false
func execute(_ operation: OfflineOperation) async throws -> OfflineExecutionResult {
executedOperations.append(operation)
if shouldThrow {
throw URLError(.notConnectedToInternet)
}
return resultToReturn
}
}Network Link Conditioner
For manual testing on device or simulator:
1. On device: Settings > Developer > Network Link Conditioner
- "100% Loss" — simulates complete offline
- "Very Bad Network" — simulates intermittent connectivity
- "Edge" — simulates slow cellular
2. On Mac (for simulator): Install "Network Link Conditioner" from Additional Tools for Xcode
- System Settings > Network Link Conditioner
- Create custom profiles for specific test scenarios
3. In code (UI tests):
// Toggle airplane mode via launch arguments
let app = XCUIApplication()
app.launchArguments.append("--simulate-offline")
// In your app:
#if DEBUG
if CommandLine.arguments.contains("--simulate-offline") {
// Use MockNetworkMonitor with isConnected = false
}
#endifIntegration Test Pattern
@Test
func fullOfflineOnlineRoundTrip() async throws {
// Setup
let persistence = MockQueuePersistence()
let monitor = MockNetworkMonitor(isConnected: false)
let executor = MockOperationExecutor()
let manager = OfflineQueueManager(
persistence: persistence,
monitor: monitor,
executor: executor
)
await manager.start()
// 1. Enqueue while offline
let op1 = OfflineOperation(endpoint: "/api/posts", httpMethod: .post, body: Data("{}".utf8))
let op2 = OfflineOperation(endpoint: "/api/comments", httpMethod: .post, body: Data("{}".utf8))
await manager.enqueue(op1)
await manager.enqueue(op2)
// Verify queued
#expect(await manager.pendingCount == 2)
#expect(await executor.executedOperations.isEmpty)
// 2. Come back online
monitor.simulateConnectivityChange(isConnected: true)
await manager.processQueue()
// 3. Verify processed
#expect(await executor.executedOperations.count == 2)
#expect(await manager.pendingCount == 0)
// 4. Verify persistence cleaned up
let remaining = await persistence.loadAll()
#expect(remaining.isEmpty)
}
@Test
func retryWithBackoffOnFailure() async throws {
let persistence = MockQueuePersistence()
let monitor = MockNetworkMonitor(isConnected: true)
let executor = MockOperationExecutor()
executor.resultToReturn = .retryable(error: URLError(.timedOut))
let policy = RetryPolicy(maxRetries: 2, baseDelay: 0.1, maxDelay: 0.5, jitterEnabled: false)
let manager = OfflineQueueManager(
persistence: persistence,
monitor: monitor,
executor: executor,
retryPolicy: policy
)
let operation = OfflineOperation(
endpoint: "/api/posts",
httpMethod: .post,
maxRetries: 2
)
await manager.enqueue(operation)
// Wait for retries to exhaust
try await Task.sleep(for: .seconds(2))
// Should have attempted multiple times then marked as failed
let operations = await persistence.loadAll()
#expect(operations.first?.status == .failed)
}Anti-Patterns to Avoid
Don't Queue GET Requests
// ❌ Don't queue reads — cache them instead
if !networkMonitor.isConnected {
queueManager.enqueue(OfflineOperation(endpoint: "/api/feed", httpMethod: .get))
}
// ✅ Use HTTP cache or local store for reads
if !networkMonitor.isConnected {
return try localStore.loadCachedFeed()
}Don't Ignore Operation Size
// ❌ Queue a 100 MB video upload as a normal operation
let operation = OfflineOperation(
endpoint: "/api/uploads",
httpMethod: .post,
body: videoData // 100 MB in memory!
)
// ✅ Save large files to disk, queue a reference
let fileURL = try saveToDisk(videoData)
let operation = OfflineOperation(
endpoint: "/api/uploads",
httpMethod: .post,
body: try JSONEncoder().encode(["fileRef": fileURL.path])
)
// Use BackgroundUploadManager for the actual transferDon't Retry Without Idempotency
// ❌ Retry a payment without idempotency key
// Could charge the user twice!
try await apiClient.post("/api/payments", body: paymentData)
// ✅ Always include idempotency key for mutations
var request = URLRequest(url: paymentsURL)
request.setValue(operation.idempotencyKey, forHTTPHeaderField: "Idempotency-Key")Don't Process Queue Immediately on Every Connectivity Change
// ❌ Wifi drops and reconnects rapidly → queue processes 10 times in 30 seconds
pathMonitor.pathUpdateHandler = { path in
if path.status == .satisfied {
Task { await queueManager.processQueue() }
}
}
// ✅ Debounce connectivity changes
pathMonitor.pathUpdateHandler = { [weak self] path in
self?.connectivityDebounceTask?.cancel()
self?.connectivityDebounceTask = Task {
try await Task.sleep(for: .seconds(2)) // Wait for stable connection
guard !Task.isCancelled else { return }
if path.status == .satisfied {
await self?.queueManager.processQueue()
}
}
}Don't Store Auth Tokens in Queued Operations
// ❌ Token may expire by the time operation executes
let operation = OfflineOperation(
endpoint: "/api/posts",
headers: ["Authorization": "Bearer \(currentToken)"] // Stale in 1 hour
)
// ✅ Inject fresh token at execution time
func execute(_ operation: OfflineOperation) async throws -> OfflineExecutionResult {
var request = buildRequest(from: operation)
// Add fresh auth token at execution time
let token = try await authManager.validToken()
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
// ...
}Offline Queue Code Templates
Production-ready Swift templates for an offline operation queue. All code targets iOS 16+ / macOS 13+ (iOS 17+ / macOS 14+ for @Observable) and uses modern Swift concurrency with the Network framework.
OfflineOperation.swift
import Foundation
/// The HTTP method for an offline operation.
enum OfflineHTTPMethod: String, Codable, Sendable {
case get = "GET"
case post = "POST"
case put = "PUT"
case patch = "PATCH"
case delete = "DELETE"
}
/// The current status of an offline operation in the queue.
enum OfflineOperationStatus: String, Codable, Sendable {
case pending
case inProgress
case completed
case failed
case conflict
}
/// The priority level for queue processing order.
enum OfflineOperationPriority: Int, Codable, Sendable, Comparable {
case low = 0
case normal = 1
case high = 2
case critical = 3
static func < (lhs: Self, rhs: Self) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
/// A single offline operation that can be persisted and replayed.
///
/// Each operation captures everything needed to replay an API request:
/// endpoint, method, body, headers, and metadata for retry logic.
///
/// Usage:
/// ```swift
/// let operation = OfflineOperation(
/// endpoint: "/api/posts",
/// httpMethod: .post,
/// body: try JSONEncoder().encode(post),
/// headers: ["Content-Type": "application/json"]
/// )
/// ```
struct OfflineOperation: Codable, Identifiable, Sendable {
/// Unique identifier for this operation.
let id: UUID
/// The API endpoint path (e.g., "/api/posts").
let endpoint: String
/// The HTTP method for this request.
let httpMethod: OfflineHTTPMethod
/// The request body data, if any.
let body: Data?
/// HTTP headers to include with the request.
var headers: [String: String]
/// When this operation was created.
let createdAt: Date
/// Number of retry attempts so far.
var retryCount: Int
/// Maximum number of retries before marking as failed.
let maxRetries: Int
/// Unique key for server-side deduplication.
///
/// The server should use this key to detect and reject duplicate
/// requests if the client retries an operation that actually succeeded
/// but whose response was lost.
let idempotencyKey: String
/// Current processing status.
var status: OfflineOperationStatus
/// Processing priority. Higher priority operations are processed first.
let priority: OfflineOperationPriority
/// Optional ID of an operation that must complete before this one.
///
/// Use for ordered operations like "create parent before child".
let dependsOn: UUID?
/// Optional tag for grouping related operations (e.g., "sync-posts").
let tag: String?
/// Timestamp of the last retry attempt, if any.
var lastAttemptAt: Date?
/// Error message from the most recent failed attempt.
var lastError: String?
/// Server response data when a conflict (409) occurs.
var conflictData: Data?
init(
id: UUID = UUID(),
endpoint: String,
httpMethod: OfflineHTTPMethod,
body: Data? = nil,
headers: [String: String] = [:],
createdAt: Date = Date(),
retryCount: Int = 0,
maxRetries: Int = 5,
idempotencyKey: String = UUID().uuidString,
status: OfflineOperationStatus = .pending,
priority: OfflineOperationPriority = .normal,
dependsOn: UUID? = nil,
tag: String? = nil
) {
self.id = id
self.endpoint = endpoint
self.httpMethod = httpMethod
self.body = body
self.headers = headers
self.createdAt = createdAt
self.retryCount = retryCount
self.maxRetries = maxRetries
self.idempotencyKey = idempotencyKey
self.status = status
self.priority = priority
self.dependsOn = dependsOn
self.tag = tag
}
}OfflineQueueManager.swift
import Foundation
/// Protocol for executing offline operations against the server.
///
/// Implement this protocol to connect the queue to your networking layer.
protocol OfflineOperationExecuting: Sendable {
/// Execute a single operation. Throws on failure.
func execute(_ operation: OfflineOperation) async throws -> OfflineExecutionResult
}
/// The result of executing an offline operation.
enum OfflineExecutionResult: Sendable {
/// Operation succeeded.
case success
/// Server returned a conflict (409). Includes server data for resolution.
case conflict(serverData: Data)
/// Operation failed but can be retried.
case retryable(error: Error)
/// Operation failed permanently (e.g., 400 Bad Request). Do not retry.
case permanent(error: Error)
}
/// Actor-based manager for the offline operation queue.
///
/// Enqueues operations when offline, persists them to disk,
/// and processes them with exponential backoff when connectivity returns.
///
/// Usage:
/// ```swift
/// let manager = OfflineQueueManager(
/// persistence: FileQueuePersistence(),
/// monitor: NetworkMonitor.shared,
/// executor: APIOperationExecutor()
/// )
/// await manager.enqueue(operation)
/// ```
actor OfflineQueueManager {
private let persistence: any QueuePersisting
private let monitor: NetworkMonitor
private let executor: any OfflineOperationExecuting
private let retryPolicy: RetryPolicy
private var queue: [OfflineOperation] = []
private var isProcessing = false
private var connectivityTask: Task<Void, Never>?
/// Number of pending operations in the queue.
var pendingCount: Int {
queue.filter { $0.status == .pending }.count
}
/// Number of failed operations in the queue.
var failedCount: Int {
queue.filter { $0.status == .failed }.count
}
/// Number of operations with conflicts awaiting resolution.
var conflictCount: Int {
queue.filter { $0.status == .conflict }.count
}
/// All operations currently in the queue.
var allOperations: [OfflineOperation] {
queue
}
init(
persistence: any QueuePersisting,
monitor: NetworkMonitor,
executor: any OfflineOperationExecuting,
retryPolicy: RetryPolicy = RetryPolicy()
) {
self.persistence = persistence
self.monitor = monitor
self.executor = executor
self.retryPolicy = retryPolicy
}
/// Start the queue manager: load persisted operations and observe connectivity.
func start() async {
queue = await persistence.loadAll()
observeConnectivity()
// If already online and there are pending operations, process them
if monitor.isConnected && !queue.isEmpty {
await processQueue()
}
}
/// Add an operation to the queue and persist it.
func enqueue(_ operation: OfflineOperation) async {
queue.append(operation)
sortQueue()
await persistence.save(operation)
// If online, try processing immediately
if monitor.isConnected && !isProcessing {
await processQueue()
}
}
/// Process all pending operations in order.
///
/// Respects priority ordering and operation dependencies.
/// Uses exponential backoff between retries.
func processQueue() async {
guard !isProcessing else { return }
isProcessing = true
defer { isProcessing = false }
while let operation = nextOperationToProcess() {
guard monitor.isConnected else { break }
var mutableOp = operation
mutableOp.status = .inProgress
mutableOp.lastAttemptAt = Date()
updateOperation(mutableOp)
do {
let result = try await executor.execute(mutableOp)
switch result {
case .success:
await markCompleted(mutableOp)
case .conflict(let serverData):
mutableOp.status = .conflict
mutableOp.conflictData = serverData
updateOperation(mutableOp)
await persistence.save(mutableOp)
case .retryable(let error):
await handleRetryableFailure(&mutableOp, error: error)
case .permanent(let error):
mutableOp.status = .failed
mutableOp.lastError = error.localizedDescription
updateOperation(mutableOp)
await persistence.save(mutableOp)
}
} catch {
await handleRetryableFailure(&mutableOp, error: error)
}
}
}
/// Mark an operation as completed and remove it from the queue.
func markCompleted(_ operation: OfflineOperation) async {
queue.removeAll { $0.id == operation.id }
await persistence.delete(operation.id)
}
/// Retry a specific failed or conflicted operation.
func retry(_ operation: OfflineOperation) async {
guard let index = queue.firstIndex(where: { $0.id == operation.id }) else { return }
queue[index].status = .pending
queue[index].retryCount = 0
queue[index].lastError = nil
queue[index].conflictData = nil
await persistence.save(queue[index])
if monitor.isConnected && !isProcessing {
await processQueue()
}
}
/// Remove all failed operations from the queue.
func clearFailed() async {
let failedIDs = queue.filter { $0.status == .failed }.map(\.id)
queue.removeAll { $0.status == .failed }
for id in failedIDs {
await persistence.delete(id)
}
}
/// Remove all operations from the queue.
func clearAll() async {
let allIDs = queue.map(\.id)
queue.removeAll()
for id in allIDs {
await persistence.delete(id)
}
}
/// Remove operations older than the given TTL.
func pruneStale(olderThan ttl: TimeInterval = 24 * 3600) async {
let cutoff = Date().addingTimeInterval(-ttl)
let staleIDs = queue.filter { $0.createdAt < cutoff }.map(\.id)
queue.removeAll { $0.createdAt < cutoff }
for id in staleIDs {
await persistence.delete(id)
}
}
// MARK: - Private
private func observeConnectivity() {
connectivityTask?.cancel()
connectivityTask = Task { [weak self] in
// Poll for connectivity changes
var wasConnected = false
while !Task.isCancelled {
guard let self else { return }
let isConnected = await MainActor.run { self.monitor.isConnected }
if isConnected && !wasConnected {
// Just came online — process the queue
await self.processQueue()
}
wasConnected = isConnected
try? await Task.sleep(for: .seconds(2))
}
}
}
private func nextOperationToProcess() -> OfflineOperation? {
// Find next pending operation whose dependencies are met
let completedIDs = Set(queue.filter { $0.status == .completed }.map(\.id))
return queue.first { op in
guard op.status == .pending else { return false }
if let dependency = op.dependsOn {
// Dependency must be completed (not just in the queue)
return completedIDs.contains(dependency) ||
!queue.contains(where: { $0.id == dependency })
}
return true
}
}
private func handleRetryableFailure(_ operation: inout OfflineOperation, error: Error) async {
operation.retryCount += 1
operation.lastError = error.localizedDescription
if operation.retryCount >= operation.maxRetries {
operation.status = .failed
} else {
operation.status = .pending
// Apply backoff delay before next attempt
let delay = retryPolicy.delay(forAttempt: operation.retryCount)
try? await Task.sleep(for: .seconds(delay))
}
updateOperation(operation)
await persistence.save(operation)
}
private func updateOperation(_ operation: OfflineOperation) {
if let index = queue.firstIndex(where: { $0.id == operation.id }) {
queue[index] = operation
}
}
private func sortQueue() {
queue.sort { a, b in
if a.priority != b.priority {
return a.priority > b.priority // Higher priority first
}
return a.createdAt < b.createdAt // FIFO within same priority
}
}
}QueuePersistence.swift
import Foundation
/// Protocol for persisting offline operations to durable storage.
///
/// Implementations must be sendable for use with actor-based queue manager.
protocol QueuePersisting: Sendable {
/// Load all persisted operations.
func loadAll() async -> [OfflineOperation]
/// Save or update a single operation.
func save(_ operation: OfflineOperation) async
/// Delete an operation by ID.
func delete(_ id: UUID) async
/// Delete all persisted operations.
func deleteAll() async
}
/// File-based persistence using JSON files in Application Support.
///
/// Each operation is stored as a separate JSON file, named by its UUID.
/// This approach avoids corruption from concurrent writes and makes
/// individual operation management straightforward.
///
/// Storage location:
/// ```
/// ApplicationSupport/
/// └── OfflineQueue/
/// ├── 550e8400-e29b-41d4-a716-446655440000.json
/// ├── 6ba7b810-9dad-11d1-80b4-00c04fd430c8.json
/// └── ...
/// ```
final class FileQueuePersistence: QueuePersisting, @unchecked Sendable {
private let directory: URL
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let fileManager = FileManager.default
private let ioQueue = DispatchQueue(label: "com.app.offlinequeue.persistence", qos: .utility)
init(directoryName: String = "OfflineQueue") {
self.directory = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent(directoryName, isDirectory: true)
// Create directory if it doesn't exist
try? FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
decoder.dateDecodingStrategy = .iso8601
}
func loadAll() async -> [OfflineOperation] {
await withCheckedContinuation { continuation in
ioQueue.async { [self] in
guard let files = try? fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil,
options: .skipsHiddenFiles
) else {
continuation.resume(returning: [])
return
}
let operations = files
.filter { $0.pathExtension == "json" }
.compactMap { url -> OfflineOperation? in
guard let data = try? Data(contentsOf: url) else { return nil }
return try? decoder.decode(OfflineOperation.self, from: data)
}
.sorted { a, b in
if a.priority != b.priority {
return a.priority > b.priority
}
return a.createdAt < b.createdAt
}
continuation.resume(returning: operations)
}
}
}
func save(_ operation: OfflineOperation) async {
await withCheckedContinuation { continuation in
ioQueue.async { [self] in
let fileURL = directory.appendingPathComponent("\(operation.id.uuidString).json")
if let data = try? encoder.encode(operation) {
try? data.write(to: fileURL, options: .atomic)
}
continuation.resume()
}
}
}
func delete(_ id: UUID) async {
await withCheckedContinuation { continuation in
ioQueue.async { [self] in
let fileURL = directory.appendingPathComponent("\(id.uuidString).json")
try? fileManager.removeItem(at: fileURL)
continuation.resume()
}
}
}
func deleteAll() async {
await withCheckedContinuation { continuation in
ioQueue.async { [self] in
guard let files = try? fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil,
options: .skipsHiddenFiles
) else {
continuation.resume()
return
}
for file in files where file.pathExtension == "json" {
try? fileManager.removeItem(at: file)
}
continuation.resume()
}
}
}
}NetworkMonitor.swift
import Foundation
import Network
/// The type of network connection currently available.
enum ConnectionType: String, Sendable {
case wifi
case cellular
case wiredEthernet
case other
case none
}
/// @Observable wrapper around NWPathMonitor for connectivity tracking.
///
/// Publishes `isConnected` and `connectionType` that SwiftUI views
/// and the queue manager can observe for reactivity.
///
/// Usage:
/// ```swift
/// let monitor = NetworkMonitor.shared
/// monitor.start()
///
/// if monitor.isConnected {
/// // Online
/// }
/// ```
@Observable
final class NetworkMonitor: @unchecked Sendable {
static let shared = NetworkMonitor()
/// Whether the device currently has network connectivity.
private(set) var isConnected: Bool = true
/// The type of the current network connection.
private(set) var connectionType: ConnectionType = .other
/// Whether the connection is considered expensive (cellular, hotspot).
private(set) var isExpensive: Bool = false
/// Whether the connection is constrained (Low Data Mode).
private(set) var isConstrained: Bool = false
private let pathMonitor: NWPathMonitor
private let monitorQueue = DispatchQueue(label: "com.app.networkmonitor", qos: .utility)
init() {
pathMonitor = NWPathMonitor()
}
/// Start monitoring network connectivity.
///
/// Call this once at app launch, typically in the App struct's init.
func start() {
pathMonitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor [weak self] in
guard let self else { return }
self.isConnected = path.status == .satisfied
self.isExpensive = path.isExpensive
self.isConstrained = path.isConstrained
self.connectionType = self.resolveConnectionType(path)
}
}
pathMonitor.start(queue: monitorQueue)
}
/// Stop monitoring. Call when no longer needed.
func stop() {
pathMonitor.cancel()
}
// MARK: - Private
private func resolveConnectionType(_ path: NWPath) -> ConnectionType {
if path.usesInterfaceType(.wifi) {
return .wifi
} else if path.usesInterfaceType(.cellular) {
return .cellular
} else if path.usesInterfaceType(.wiredEthernet) {
return .wiredEthernet
} else if path.status == .satisfied {
return .other
} else {
return .none
}
}
}
// For testing: allow injection via SwiftUI Environment
private struct NetworkMonitorKey: EnvironmentKey {
static let defaultValue: NetworkMonitor = .shared
}
extension EnvironmentValues {
var networkMonitor: NetworkMonitor {
get { self[NetworkMonitorKey.self] }
set { self[NetworkMonitorKey.self] = newValue }
}
}RetryPolicy.swift
import Foundation
/// Configurable retry policy with exponential backoff and jitter.
///
/// Calculates the delay before the next retry attempt based on
/// the attempt number, a base delay, a multiplier, and optional
/// random jitter to prevent thundering herd problems.
///
/// Default configuration:
/// - Attempt 0: ~1s
/// - Attempt 1: ~2s
/// - Attempt 2: ~4s
/// - Attempt 3: ~8s
/// - Attempt 4: ~16s (capped at maxDelay)
///
/// Usage:
/// ```swift
/// let policy = RetryPolicy(maxRetries: 5, baseDelay: 1.0, maxDelay: 60.0)
/// let delay = policy.delay(forAttempt: 2) // ~4 seconds + jitter
/// ```
struct RetryPolicy: Sendable {
/// Maximum number of retry attempts before giving up.
let maxRetries: Int
/// Base delay in seconds for the first retry.
let baseDelay: TimeInterval
/// Maximum delay in seconds (cap for exponential growth).
let maxDelay: TimeInterval
/// Multiplier applied per retry attempt (2.0 = double each time).
let multiplier: Double
/// Whether to add random jitter to prevent thundering herd.
let jitterEnabled: Bool
init(
maxRetries: Int = 5,
baseDelay: TimeInterval = 1.0,
maxDelay: TimeInterval = 60.0,
multiplier: Double = 2.0,
jitterEnabled: Bool = true
) {
self.maxRetries = maxRetries
self.baseDelay = baseDelay
self.maxDelay = maxDelay
self.multiplier = multiplier
self.jitterEnabled = jitterEnabled
}
/// Calculate the delay for the given retry attempt number.
///
/// - Parameter attempt: The retry attempt number (0-based).
/// - Returns: The delay in seconds before the next retry.
func delay(forAttempt attempt: Int) -> TimeInterval {
// Exponential: baseDelay * multiplier^attempt
let exponentialDelay = baseDelay * pow(multiplier, Double(attempt))
let cappedDelay = min(exponentialDelay, maxDelay)
if jitterEnabled {
// Full jitter: random value between 0 and cappedDelay
// This distributes retries evenly and prevents thundering herd
let jitter = Double.random(in: 0...1)
return cappedDelay * jitter + (cappedDelay * 0.5) // Between 50%-150% of delay
}
return cappedDelay
}
/// Whether the given attempt number has exceeded the maximum retries.
func shouldGiveUp(attempt: Int) -> Bool {
attempt >= maxRetries
}
// MARK: - Preset Configurations
/// Aggressive retry: short delays, many attempts.
static let aggressive = RetryPolicy(
maxRetries: 10,
baseDelay: 0.5,
maxDelay: 30.0,
multiplier: 1.5
)
/// Conservative retry: longer delays, fewer attempts.
static let conservative = RetryPolicy(
maxRetries: 3,
baseDelay: 5.0,
maxDelay: 120.0,
multiplier: 3.0
)
/// Linear retry: fixed interval between attempts.
static func linear(interval: TimeInterval = 5.0, maxRetries: Int = 5) -> RetryPolicy {
RetryPolicy(
maxRetries: maxRetries,
baseDelay: interval,
maxDelay: interval,
multiplier: 1.0,
jitterEnabled: false
)
}
/// Immediate retry: no delay (useful for quick transient failures).
static let immediate = RetryPolicy(
maxRetries: 3,
baseDelay: 0.1,
maxDelay: 0.1,
multiplier: 1.0,
jitterEnabled: false
)
}OfflineQueueDashboardView.swift
import SwiftUI
/// Debug dashboard showing the state of the offline operation queue.
///
/// Displays counts of pending, in-progress, failed, and conflicted operations.
/// Provides manual retry and clear controls for debugging.
///
/// Usage:
/// ```swift
/// #if DEBUG
/// NavigationLink("Offline Queue") {
/// OfflineQueueDashboardView()
/// }
/// #endif
/// ```
struct OfflineQueueDashboardView: View {
let queueManager: OfflineQueueManager
@Environment(\.networkMonitor) private var networkMonitor
@State private var operations: [OfflineOperation] = []
@State private var pendingCount = 0
@State private var failedCount = 0
@State private var conflictCount = 0
@State private var isRefreshing = false
var body: some View {
List {
statusSection
countsSection
actionsSection
operationsSection
}
.navigationTitle("Offline Queue")
.task {
await refreshState()
}
.refreshable {
await refreshState()
}
}
// MARK: - Sections
@ViewBuilder
private var statusSection: some View {
Section("Connectivity") {
HStack {
Image(systemName: networkMonitor.isConnected ? "wifi" : "wifi.slash")
.foregroundStyle(networkMonitor.isConnected ? .green : .red)
Text(networkMonitor.isConnected ? "Online" : "Offline")
Spacer()
Text(networkMonitor.connectionType.rawValue.capitalized)
.foregroundStyle(.secondary)
}
if networkMonitor.isExpensive {
Label("Expensive connection (cellular/hotspot)", systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
.font(.caption)
}
if networkMonitor.isConstrained {
Label("Low Data Mode active", systemImage: "arrow.down.circle")
.foregroundStyle(.orange)
.font(.caption)
}
}
}
@ViewBuilder
private var countsSection: some View {
Section("Queue Summary") {
LabeledContent("Pending", value: "\(pendingCount)")
LabeledContent("Failed", value: "\(failedCount)")
LabeledContent("Conflicts", value: "\(conflictCount)")
LabeledContent("Total", value: "\(operations.count)")
}
}
@ViewBuilder
private var actionsSection: some View {
Section("Actions") {
Button {
Task {
await queueManager.processQueue()
await refreshState()
}
} label: {
Label("Retry All Pending", systemImage: "arrow.clockwise")
}
.disabled(!networkMonitor.isConnected || pendingCount == 0)
Button {
Task {
await queueManager.clearFailed()
await refreshState()
}
} label: {
Label("Clear Failed", systemImage: "trash")
}
.disabled(failedCount == 0)
.foregroundStyle(.red)
Button {
Task {
await queueManager.pruneStale()
await refreshState()
}
} label: {
Label("Prune Stale (>24h)", systemImage: "clock.badge.xmark")
}
Button(role: .destructive) {
Task {
await queueManager.clearAll()
await refreshState()
}
} label: {
Label("Clear All", systemImage: "trash.fill")
}
}
}
@ViewBuilder
private var operationsSection: some View {
if !operations.isEmpty {
Section("Operations") {
ForEach(operations) { operation in
OperationRowView(operation: operation) {
Task {
await queueManager.retry(operation)
await refreshState()
}
}
}
}
}
}
// MARK: - Private
private func refreshState() async {
operations = await queueManager.allOperations
pendingCount = await queueManager.pendingCount
failedCount = await queueManager.failedCount
conflictCount = await queueManager.conflictCount
}
}
/// Row view for a single offline operation in the dashboard.
private struct OperationRowView: View {
let operation: OfflineOperation
let onRetry: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
statusIcon
Text(operation.httpMethod.rawValue)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
Text(operation.endpoint)
.font(.subheadline)
.lineLimit(1)
}
HStack {
Text(operation.createdAt, style: .relative)
.font(.caption2)
.foregroundStyle(.secondary)
if operation.retryCount > 0 {
Text("Retries: \(operation.retryCount)/\(operation.maxRetries)")
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
if operation.status == .failed || operation.status == .conflict {
Button("Retry", action: onRetry)
.buttonStyle(.bordered)
.controlSize(.mini)
}
}
if let error = operation.lastError {
Text(error)
.font(.caption2)
.foregroundStyle(.red)
.lineLimit(2)
}
}
.padding(.vertical, 2)
}
@ViewBuilder
private var statusIcon: some View {
switch operation.status {
case .pending:
Image(systemName: "clock")
.foregroundStyle(.orange)
case .inProgress:
ProgressView()
.controlSize(.mini)
case .completed:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
case .failed:
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.red)
case .conflict:
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
}
}
}OfflineQueueModifier.swift
import SwiftUI
/// ViewModifier that displays a subtle "Offline" banner when the device
/// loses connectivity and there are pending operations to sync.
///
/// Usage:
/// ```swift
/// ContentView()
/// .offlineQueueBanner()
/// ```
struct OfflineQueueBannerModifier: ViewModifier {
@Environment(\.networkMonitor) private var networkMonitor
func body(content: Content) -> some View {
content
.safeAreaInset(edge: .bottom) {
if !networkMonitor.isConnected {
offlineBanner
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.3), value: networkMonitor.isConnected)
}
@ViewBuilder
private var offlineBanner: some View {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.font(.subheadline)
Text("Offline — changes will sync when connected")
.font(.subheadline)
}
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.orange.gradient)
)
.padding(.horizontal)
.padding(.bottom, 4)
}
}
extension View {
/// Adds a subtle offline banner at the bottom of the view when
/// the device has no network connectivity.
func offlineQueueBanner() -> some View {
modifier(OfflineQueueBannerModifier())
}
}