---
name: swift-experto
description: >
  Swift moderno con SwiftUI, @Observable (iOS 17+), NavigationStack,
  async/await con TaskGroup, actors para concurrencia segura, SwiftData,
  Keychain y property wrappers. Cargar cuando se implementen vistas SwiftUI,
  concurrencia Swift, modelos de datos SwiftData, o servicios iOS/macOS.
version: "1.0.0"
herramientasPermitidas: [Read, Grep]
exclusiones:
  - "No cargar para diseñar patrones arquitectónicos Swift (TCA, MVVM, DI con protocolos) — para diseño cargar `swift-patrones`."
  - "No cargar para escribir tests Swift (swift-testing, XCTest, mocks con protocolos) — para testing cargar `swift-testing`."
  - "No cargar para errores de compilación Swift o Xcode — para build errors cargar `build-errors-swift`."
  - "No cargar para Jetpack Compose o Android — Swift/SwiftUI es específico de Apple platforms; para Android cargar `kotlin-compose`."
evolvable: true  # default para skill estandar
---
# Swift Experto — SwiftUI, Concurrencia y SwiftData

## Cuándo NO cargar

- La pregunta es sobre diseñar patrones arquitectónicos Swift (TCA, MVVM, dependency injection con protocolos) — cargar `swift-patrones`.
- La tarea es escribir tests Swift con swift-testing o XCTest — cargar `swift-testing`.
- Los errores son de compilación Swift o Xcode (provisioning, signing) — cargar `build-errors-swift`.
- El proyecto es Android con Kotlin/Compose — SwiftUI es específico de Apple platforms; cargar `kotlin-compose`.

## SwiftUI — View Lifecycle y Estructura

```swift
// Estructura recomendada de una Feature
// Features/Facturas/
//   FacturasView.swift
//   FacturasViewModel.swift
//   FacturasRepository.swift
//   Models/Factura.swift

struct FacturasView: View {
    @State private var viewModel = FacturasViewModel()  // @Observable — iOS 17+

    var body: some View {
        NavigationStack {
            content
                .navigationTitle("Facturas")
                .toolbar { toolbarItems }
        }
        .task { await viewModel.cargar() }  // task: se cancela con la vista
    }

    @ViewBuilder
    private var content: some View {
        switch viewModel.estado {
        case .cargando:   ProgressView()
        case .vacio:      ContentUnavailableView("Sin facturas", systemImage: "doc.text")
        case .error(let msg): ErrorView(mensaje: msg)
        case .exito(let facturas): listaFacturas(facturas)
        }
    }

    private func listaFacturas(_ facturas: [Factura]) -> some View {
        List(facturas) { factura in
            NavigationLink(value: factura) {
                FacturaRow(factura: factura)
            }
        }
        .navigationDestination(for: Factura.self) { factura in
            DetalleFacturaView(factura: factura)
        }
    }
}
```

---

## @Observable — iOS 17+ (preferir sobre ObservableObject)

```swift
// @Observable: el sistema de dependencias es automático y granular
// Solo recompone las vistas que leen la propiedad que cambió
@Observable
final class FacturasViewModel {
    var estado: EstadoFacturas = .cargando
    var query: String = ""

    // @Observable NO necesita @Published
    // Las propiedades privadas no rastreadas se marcan con @ObservationIgnored
    @ObservationIgnored private var tarea: Task<Void, Never>?

    var facturasFiltradas: [Factura] {
        guard case .exito(let facturas) = estado else { return [] }
        return query.isEmpty ? facturas : facturas.filter {
            $0.folio.localizedCaseInsensitiveContains(query)
        }
    }

    func cargar() async {
        estado = .cargando
        do {
            let facturas = try await repositorio.obtener()
            estado = .exito(facturas)
        } catch {
            estado = .error(error.localizedDescription)
        }
    }
}

enum EstadoFacturas {
    case cargando
    case vacio
    case exito([Factura])
    case error(String)
}
```

---

## Async/Await con TaskGroup

```swift
// Carga paralela de múltiples recursos
func cargarDashboard() async throws -> Dashboard {
    async let facturas = api.obtenerFacturas()
    async let clientes = api.obtenerClientes()
    async let estadisticas = api.obtenerEstadisticas()

    // Espera todos en paralelo
    return try await Dashboard(
        facturas: facturas,
        clientes: clientes,
        estadisticas: estadisticas
    )
}

// TaskGroup para cantidad dinámica de tareas
func procesarFacturas(_ ids: [String]) async throws -> [FacturaDto] {
    try await withThrowingTaskGroup(of: FacturaDto.self) { group in
        for id in ids {
            group.addTask { try await api.obtenerFactura(id: id) }
        }
        var resultados: [FacturaDto] = []
        for try await dto in group {
            resultados.append(dto)
        }
        return resultados
    }
}
```

---

## Actors — Concurrencia Segura

```swift
// Actor: acceso exclusivo a estado compartido desde múltiples tareas
actor CachFacturas {
    private var cache: [String: Factura] = [:]
    private var timestamps: [String: Date] = [:]
    private let ttl: TimeInterval = 300  // 5 minutos

    func obtener(id: String) -> Factura? {
        guard let ts = timestamps[id], Date().timeIntervalSince(ts) < ttl else {
            return nil
        }
        return cache[id]
    }

    func guardar(_ factura: Factura) {
        cache[factura.id] = factura
        timestamps[factura.id] = Date()
    }

    func invalidar(id: String) {
        cache.removeValue(forKey: id)
        timestamps.removeValue(forKey: id)
    }
}

// @MainActor — garantiza ejecución en hilo principal
@MainActor
final class FacturasViewModel: ObservableObject {
    func actualizarUI() {
        // Siempre en main thread, sin DispatchQueue.main.async
    }
}
```

---

## URLSession Async

```swift
struct FacturasApiClient {
    private let session: URLSession
    private let decoder: JSONDecoder = {
        let d = JSONDecoder()
        d.keyDecodingStrategy = .convertFromSnakeCase
        d.dateDecodingStrategy = .iso8601
        return d
    }()

    func obtenerFacturas(empresaId: String) async throws -> [FacturaDto] {
        let url = URL(string: "\(baseURL)/v1/facturas?empresa_id=\(empresaId)")!
        var request = URLRequest(url: url)
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        request.timeoutInterval = 30

        let (data, response) = try await session.data(for: request)

        guard let http = response as? HTTPURLResponse else {
            throw ApiError.respuestaInvalida
        }
        guard (200..<300).contains(http.statusCode) else {
            throw ApiError.statusCode(http.statusCode)
        }

        return try decoder.decode([FacturaDto].self, from: data)
    }
}
```

---

## SwiftData

```swift
// Modelo
@Model
final class Factura {
    @Attribute(.unique) var id: String
    var folio: String
    var total: Double
    var fechaEmision: Date
    var estatus: String

    @Relationship(deleteRule: .cascade) var items: [ItemFactura] = []

    init(id: String, folio: String, total: Double) {
        self.id = id
        self.folio = folio
        self.total = total
        self.fechaEmision = Date()
        self.estatus = "borrador"
    }
}

// Configuración en App
@main
struct MiApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Factura.self, ItemFactura.self])
    }
}

// Uso en Vista
struct FacturasView: View {
    @Query(sort: \Factura.fechaEmision, order: .reverse) var facturas: [Factura]
    @Environment(\.modelContext) private var ctx

    func eliminar(_ factura: Factura) {
        ctx.delete(factura)
    }
}
```

---

Para la implementación completa del Keychain wrapper (guardar, leer, eliminar)
y property wrappers personalizados (@Clamped, @Trimmed), ver
[recursos/keychain-y-wrappers.md](recursos/keychain-y-wrappers.md).

---

## Anti-patrones

### Force unwrap — NUNCA en producción

```swift
// MAL — crash en runtime
let factura = facturas.first!
let url = URL(string: urlString)!

// BIEN — manejo explícito
guard let factura = facturas.first else { return }
guard let url = URL(string: urlString) else {
    logger.error("URL inválida: \(urlString)")
    return
}
```

### Massive View y ObservableObject con muchas @Published

```swift
// MAL — body con 200+ líneas mezclando lógica y UI
// BIEN — extraer sub-vistas (@ViewBuilder) y mover lógica al ViewModel

// En iOS 17+, migrar de ObservableObject/@Published a @Observable.
// @Observable solo recompone las vistas que leen la propiedad que cambió.
```

---

## Gotchas / Errores comunes no obvios

**`@State` inicializado con un valor que depende de una propiedad del padre no se actualiza cuando el padre cambia**: `@State var total = calcular(items)` se inicializa una vez cuando la vista se crea — si `items` cambia en el padre, `total` no se recalcula. Causa: `@State` es estado local de la vista, no derivado. Fix: usar `@Binding` si el padre necesita controlar el valor, o calcular `total` como una propiedad computada en la vista (sin `@State`) si no necesita ser modificada localmente.

**`Task { await ... }` lanzado en `.onAppear` no se cancela cuando la vista desaparece**: si la vista desaparece antes de que el Task termine, el Task sigue ejecutando aunque la vista ya no exista, potencialmente modificando estado stale. Fix: usar `.task { await ... }` (el modificador de SwiftUI, no `Task { }`) — `.task` cancela automáticamente la tarea cuando la vista desaparece.

**`actor` con métodos que retornan tipos no-`Sendable` causa errores de concurrencia en Swift 6**: si un `actor` tiene un método que retorna `UIImage` y `UIImage` no es `Sendable`, el compilador con Swift 6 concurrency checks habilitado lanza error. Causa: los valores retornados de actores cruzan isolation boundaries. Fix: marcar los tipos de retorno como `Sendable`, o usar `@unchecked Sendable` con justificación, o rediseñar para que el actor retorne solo tipos value (structs, enums).

**`SwiftData` con `@Query` en una vista no se actualiza si el `ModelContext` usado para la mutación es diferente al del `@Environment`**: si se crea un `ModelContext` secundario para inserciones en background y se guarda, la vista con `@Query` del contexto principal puede no reflejar los cambios hasta el próximo ciclo de runloop. Fix: usar el `modelContext` del `@Environment(\.modelContext)` para mutaciones en el hilo principal, o llamar `try context.save()` explícitamente después de las mutaciones en background.

## Checklist Swift

- [ ] Usar @Observable en lugar de ObservableObject (iOS 17+)
- [ ] Sin force unwrap (!) en código de producción
- [ ] Tokens y datos sensibles en Keychain, nunca en UserDefaults
- [ ] .task { } en lugar de .onAppear + Task { } para cargas async
- [ ] Sendable conformance para tipos que cruzan límites de actor
- [ ] CancellationError no atrapado silenciosamente en catches genéricos
