Flighty: Visualización de Datos Hecha Correctamente

Por qué Flighty ganó el Apple Design Award 2023: 15 estados inteligentes, Live Activities y visualización de datos inspirada en aeropuertos. Con patrones de implementación SwiftUI.

5 min de lectura 1077 palabras
Flighty: Visualización de Datos Hecha Correctamente screenshot

Flighty: Visualización de Datos Bien Hecha

“Queremos que Flighty funcione tan bien que se sienta casi obvio de tan natural.”

Flighty convierte datos complejos de vuelos en información que se comprende de un vistazo. Ganador del Apple Design Award 2023 en la categoría de Interacción, es una clase magistral en la presentación de datos densos a través de un diseño sereno y claro, inspirado en décadas de señalización aeroportuaria.


Por Qué Flighty Importa

Fundada por Ryan Jones, exempleado de Apple, tras una frustrante demora de vuelo, Flighty se ha convertido en el estándar de referencia para la visualización de datos en iOS.

Logros clave: - Apple Design Award 2023 (Interacción) - Finalista a App del Año para iPhone 2023 - Primera app en aprovechar completamente Live Activities - 15 estados inteligentes según el contexto - Predicciones de retrasos líderes en la industria


Conclusiones Clave

  1. Inspírate en sistemas probados - Los paneles de salidas de los aeropuertos tienen más de 50 años de optimización; Flighty adapta este lenguaje visual para dispositivos móviles
  2. Los estados según el contexto reducen la carga cognitiva - 15 estados inteligentes aseguran que los usuarios vean exactamente lo que necesitan en cada fase del viaje
  3. “Obvio de tan natural” es la meta - Si la app simplemente funciona sin fricción, has tenido éxito
  4. Empaqueta, envuelve y colorea tus datos - Presenta información procesada, no datos crudos; codifica el estado con colores para que los usuarios no tengan que interpretar
  5. Offline-first para conectividad poco confiable - Los vuelos pierden conexión; la app debe funcionar sin internet, especialmente durante el vuelo

Filosofía de Diseño Central

Señalización Aeroportuaria como Lenguaje de Diseño

El diseño de Flighty se construye sobre una analogía del mundo real: los paneles de salidas de los aeropuertos.

AIRPORT DEPARTURE BOARD (50+ years of refinement)
───────────────────────────────────────────────────────────────────
FLIGHT   DESTINATION      GATE    TIME    STATUS
UA 1234  San Francisco    B22     14:30   ON TIME
DL 567   New York JFK     C15     15:00   DELAYED
AA 890   Chicago O'Hare   A08     15:45   BOARDING

One line per flight. Essential info only. Decades of optimization.

FLIGHTY'S TRANSLATION
───────────────────────────────────────────────────────────────────
[Flight card with same information density]
[Color-coded status]
[Time-appropriate details]

Idea clave: “Esos paneles de aeropuerto tienen una línea por vuelo, y ese es un buen principio rector—han tenido 50 años para descifrar qué es lo importante.”

Conciencia del Contexto de Viaje

Flighty entiende que viajar es estresante. El diseño debe reducir la carga cognitiva, no aumentarla.

Principios de diseño: 1. La información más importante visible sin desplazar 2. Revelación progresiva para los detalles 3. Estado codificado por colores (verde = bien, amarillo = precaución, rojo = problema) 4. Los datos se “empaquetan, envuelven y colorean” para que los usuarios no tengan que interpretarlos


Biblioteca de Patrones

1. Los 15 Estados Inteligentes

La innovación distintiva de Flighty: 15 estados según el contexto que muestran exactamente lo que necesitas, exactamente cuando lo necesitas.

Línea temporal de estados:

24 HOURS BEFORE
┌─────────────────────────────────────────┐
│ UA 1234 → SFO                           │
│ Tomorrow at 2:30 PM                     │
│ Confirmation: ABC123                    │
│ Head to Terminal 2                      │
└─────────────────────────────────────────┘

3 HOURS BEFORE
┌─────────────────────────────────────────┐
│ UA 1234 → SFO                           │
│ Gate B22 • Boards 2:00 PM               │
│ Leave for airport by 12:15 PM           │
│ [View full timeline →]                  │
└─────────────────────────────────────────┘

AT GATE
┌─────────────────────────────────────────┐
│ UA 1234 → SFO     [ON TIME]            │
│ Gate B22 • Boarding in 12 min           │
│ Seat 14A • Window                       │
│ [Your plane is here ✓]                  │
└─────────────────────────────────────────┘

WALKING TO PLANE
┌─────────────────────────────────────────┐
│ UA 1234 → SFO     [BOARDING]           │
│ Your seat: 14A (Window)                 │
│ ▓▓▓▓▓▓▓▓░░ 80% boarded                 │
└─────────────────────────────────────────┘

IN FLIGHT (offline)
┌─────────────────────────────────────────┐
│  SFO ──────●───────────── JFK           │
│            │                            │
│     2h 15m remaining                    │
│     Arrive ~5:45 PM local               │
└─────────────────────────────────────────┘

LANDED
┌─────────────────────────────────────────┐
│ ✓ Arrived at JFK                        │
│ Your gate: C15 → Connection gate: D22   │
│ 8 min walk • Terminal map [→]          │
└─────────────────────────────────────────┘

Concepto de implementación:

enum FlightState: CaseIterable {
    case farOut          // > 24 hours
    case dayBefore       // 24 hours
    case headToAirport   // ~3 hours
    case atAirport       // At terminal
    case atGate          // Gate area
    case boarding        // Boarding started
    case onBoard         // Seated
    case taxiing         // Moving to runway
    case inFlight        // Airborne
    case descending      // Approaching
    case landed          // Touched down
    case atGateDest      // Arrived at gate
    case connection      // Connecting flight
    case completed       // Trip done
    case delayed         // Any delay state
}

struct SmartStateEngine {
    func currentState(for flight: Flight, context: TravelContext) -> FlightState {
        let now = Date()
        let departure = flight.scheduledDeparture

        // Contextual factors
        let isAtAirport = context.currentLocation.isNear(flight.departureAirport)
        let isBoarding = flight.boardingStatus == .active
        let isAirborne = flight.status == .inFlight

        switch true {
        case isAirborne:
            return flight.altitude > 10000 ? .inFlight : .taxiing
        case isBoarding && isAtAirport:
            return .boarding
        case isAtAirport && departure.timeIntervalSinceNow < 3600:
            return .atGate
        case isAtAirport:
            return .atAirport
        case departure.timeIntervalSinceNow < 3 * 3600:
            return .headToAirport
        case departure.timeIntervalSinceNow < 24 * 3600:
            return .dayBefore
        default:
            return .farOut
        }
    }

    func displayContent(for state: FlightState, flight: Flight) -> StateContent {
        switch state {
        case .headToAirport:
            return StateContent(
                headline: "\(flight.number)\(flight.destination.code)",
                primary: "Gate \(flight.gate) • Boards \(flight.boardingTime.formatted())",
                secondary: "Leave for airport by \(flight.recommendedDeparture.formatted())",
                action: "View full timeline"
            )
        case .inFlight:
            return StateContent(
                headline: flight.routeVisualization,
                primary: "\(flight.remainingTime.formatted()) remaining",
                secondary: "Arrive ~\(flight.estimatedArrival.formatted())",
                action: nil
            )
        // ... other states
        }
    }
}

2. Live Activities y Dynamic Island

Flighty fue la primera app en aprovechar completamente las Live Activities de iOS 16, estableciendo el estándar para esta funcionalidad.

Estados de Dynamic Island:

COMPACT (Mínimo)
╭──────────────────────────────────╮
│ [^] B22  │  *  │  2:30 -> 1h 45m │
╰──────────────────────────────────╯
  Gate      Estado    Hora/Duración

EXPANDED (Pulsación prolongada)
╭────────────────────────────────────────╮
│  UA 1234 a San Francisco               │
│  ───────────────────────────────────   │
│  Gate B22        Embarque 2:00 PM      │
│  Asiento 14A     A tiempo ✓            │
│                                        │
│  [Abrir Flighty]    [Compartir ETA]    │
╰────────────────────────────────────────╯

IN-FLIGHT (Funciona sin conexión)
╭──────────────────────────────────╮
│  SFO ●════════○──── JFK          │
│         2h 15m                   │
╰──────────────────────────────────╯

Patrón de implementación en SwiftUI:

import ActivityKit
import WidgetKit

struct FlightActivityAttributes: ActivityAttributes {
    public typealias ContentState = FlightStatus

    let flightNumber: String
    let origin: Airport
    let destination: Airport
    let gate: String
    let seat: String

    struct FlightStatus: Codable, Hashable {
        let departureTime: Date
        let arrivalTime: Date
        let status: Status
        let boardingStatus: BoardingStatus
        let progress: Double  // 0.0 a 1.0 para en vuelo
        let phase: FlightPhase

        enum Status: String, Codable { case onTime, delayed, cancelled }
        enum BoardingStatus: String, Codable { case notStarted, boarding, closed }
        enum FlightPhase: String, Codable { case preBoarding, boarding, taxiing, inFlight, landed }
    }
}

struct FlightLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: FlightActivityAttributes.self) { context in
            // Vista de pantalla de bloqueo
            LockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                // Vista expandida
                DynamicIslandExpandedRegion(.leading) {
                    VStack(alignment: .leading) {
                        Text(context.attributes.flightNumber)
                            .font(.headline)
                        Text("to \(context.attributes.destination.city)")
                            .font(.caption)
                    }
                }
                DynamicIslandExpandedRegion(.trailing) {
                    VStack(alignment: .trailing) {
                        Text("Gate \(context.attributes.gate)")
                        Text(context.state.status.displayText)
                            .foregroundStyle(context.state.status.color)
                    }
                }
                DynamicIslandExpandedRegion(.bottom) {
                    if context.state.phase == .inFlight {
                        FlightProgressBar(progress: context.state.progress)
                    } else {
                        Text("Boards \(context.state.boardingTime.formatted())")
                    }
                }
            } compactLeading: {
                // Lado izquierdo compacto
                Text(context.attributes.gate)
                    .font(.caption)
            } compactTrailing: {
                // Lado derecho compacto
                Text(context.state.departureTime, style: .timer)
            } minimal: {
                // Mínimo (cuando hay múltiples actividades)
                Image(systemName: "airplane")
            }
        }
    }
}

3. Visualización del Progreso de Vuelo

Flighty muestra el progreso del vuelo a través de múltiples metáforas visuales.

Línea de ruta con indicador de avión:

PROGRESO LINEAL (Principal)
───────────────────────────────────────────────────────────────────
SFO ════════════════●════════════════════════════════════ JFK
    [Despegó hace 45m]    ↑ Estás aquí             [2h 15m restantes]

PROGRESO CIRCULAR (Compacto)
        ╭───────╮
       /    ●    \
      │     |     │    ← El arco de progreso se llena en sentido horario
      │     |     │
       \    ↓    /     2h 15m restantes
        ╰───────╯

MAPA DE CALOR (Estadísticas de por vida)
[Mapa con líneas de ruta de grosor variable]
Línea gruesa = ruta viajada frecuentemente
Línea delgada = viajada una sola vez

Implementación en SwiftUI:

struct FlightProgressView: View {
    let progress: Double  // 0.0 a 1.0
    let origin: Airport
    let destination: Airport
    let remainingTime: TimeInterval

    var body: some View {
        GeometryReader { geometry in
            ZStack {
                // Línea de ruta
                Path { path in
                    let startX: CGFloat = 40
                    let endX = geometry.size.width - 40
                    let y = geometry.size.height / 2

                    path.move(to: CGPoint(x: startX, y: y))
                    path.addLine(to: CGPoint(x: endX, y: y))
                }
                .stroke(Color.secondary.opacity(0.3), lineWidth: 3)

                // Línea de progreso
                Path { path in
                    let startX: CGFloat = 40
                    let endX = geometry.size.width - 40
                    let progressX = startX + (endX - startX) * progress
                    let y = geometry.size.height / 2

                    path.move(to: CGPoint(x: startX, y: y))
                    path.addLine(to: CGPoint(x: progressX, y: y))
                }
                .stroke(Color.accentColor, lineWidth: 3)

                // Origin marker
                Circle()
                    .fill(Color.secondary)
                    .frame(width: 8, height: 8)
                    .position(x: 40, y: geometry.size.height / 2)

                // Plane icon at current position
                Image(systemName: "airplane")
                    .foregroundStyle(Color.accentColor)
                    .position(
                        x: 40 + (geometry.size.width - 80) * progress,
                        y: geometry.size.height / 2
                    )

                // Destination marker
                Circle()
                    .fill(Color.secondary)
                    .frame(width: 8, height: 8)
                    .position(x: geometry.size.width - 40, y: geometry.size.height / 2)

                // Labels
                HStack {
                    Text(origin.code)
                        .font(.caption.bold())
                    Spacer()
                    Text(destination.code)
                        .font(.caption.bold())
                }
                .padding(.horizontal, 20)
            }
        }
        .frame(height: 60)
    }
}

4. Asistente de Conexión

Al aterrizar con un vuelo de conexión, Flighty muestra ambas puertas de embarque junto con tu ubicación en tiempo real.

CONNECTION VIEW
┌─────────────────────────────────────────┐
│ Connection: 45 minutes                  │
│                                         │
│ ┌─────────────────────────────────────┐ │
│ │                                     │ │
│ │   [Terminal Map]                    │ │
│ │                                     │ │
│ │   [*] You -> -> -> -> -> [>] D22     │ │
│ │   C15            8 min walk         │ │
│ │                                     │ │
│ └─────────────────────────────────────┘ │
│                                         │
│ [!] Tight connection - start moving     │
└─────────────────────────────────────────┘

Implementación:

struct ConnectionAssistantView: View {
    let arrival: Flight
    let departure: Flight
    let userLocation: CLLocationCoordinate2D?

    var connectionTime: TimeInterval {
        departure.scheduledDeparture.timeIntervalSince(arrival.estimatedArrival)
    }

    var walkingTime: TimeInterval {
        // Calculate based on gate distance and typical walking speed
        TerminalMap.walkingTime(from: arrival.arrivalGate, to: departure.gate)
    }

    var isTight: Bool {
        connectionTime < walkingTime + 15 * 60  // Less than 15 min buffer
    }

    var body: some View {
        VStack(spacing: 16) {
            // Header
            HStack {
                Text("Connection")
                    .font(.headline)
                Spacer()
                Text(connectionTime.formatted())
                    .font(.title2.bold())
                    .foregroundStyle(isTight ? .orange : .primary)
            }

            // Map with route
            TerminalMapView(
                from: arrival.arrivalGate,
                to: departure.gate,
                userLocation: userLocation
            )
            .frame(height: 200)
            .clipShape(RoundedRectangle(cornerRadius: 12))

            // Gate info
            HStack {
                GateLabel(gate: arrival.arrivalGate, label: "Arrival")
                Spacer()
                Text("\(Int(walkingTime / 60)) min walk")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Spacer()
                GateLabel(gate: departure.gate, label: "Departure")
            }

            // Warning if tight
            if isTight {
                HStack {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .foregroundStyle(.orange)
                    Text("Tight connection - start moving")
                        .font(.callout)
                }
                .padding()
                .background(Color.orange.opacity(0.1))
                .clipShape(RoundedRectangle(cornerRadius: 8))
            }
        }
        .padding()
    }
}

5. Pasaporte y estadísticas de por vida

Flighty convierte el historial de viajes en visualizaciones hermosas y compartibles.

PASSPORT PAGE
┌─────────────────────────────────────────┐
│           [^] FLIGHTY PASSPORT          │
│                                         │
│  [Custom artwork with travel themes]    │
│                                         │
│  ───────────────────────────────────    │
│  2024                                   │
│                                         │
│  47 flights                             │
│  89,234 miles                           │
│  23 airports                            │
│  12 countries                           │
│                                         │
│  Most visited: SFO (23 times)           │
│  Longest flight: SFO → SIN (8,447 mi)  │
│  ───────────────────────────────────    │
│                                         │
│  [Share] [View Details]                 │
└─────────────────────────────────────────┘

Sistema de diseño visual

Paleta de colores

extension Color {
    // Status colors (airport-inspired)
    static let flightOnTime = Color(hex: "#10B981")    // Green
    static let flightDelayed = Color(hex: "#F59E0B")   // Amber
    static let flightCancelled = Color(hex: "#EF4444") // Red
    static let flightBoarding = Color(hex: "#3B82F6")  // Blue

    // UI colors
    static let flightPrimary = Color(hex: "#1F2937")
    static let flightSecondary = Color(hex: "#6B7280")
    static let flightBackground = Color(hex: "#F9FAFB")
    static let flightCard = Color(hex: "#FFFFFF")

    // Accent
    static let flightAccent = Color(hex: "#6366F1")    // Indigo
}

Tipografía

struct FlightyTypography {
    // Flight numbers and gates (need to stand out)
    static let flightNumber = Font.system(size: 20, weight: .bold, design: .monospaced)
    static let gate = Font.system(size: 24, weight: .bold, design: .rounded)

    // Time displays
    static let time = Font.system(size: 18, weight: .medium, design: .monospaced)
    static let countdown = Font.system(size: 32, weight: .bold, design: .monospaced)

    // Status and labels
    static let status = Font.system(size: 14, weight: .semibold)
    static let label = Font.system(size: 12, weight: .medium)
    static let body = Font.system(size: 16, weight: .regular)
}

Filosofía de animación

Movimiento con propósito

Las animaciones de Flighty cumplen una función, no son decorativas.

// Status change animation
struct StatusBadge: View {
    let status: FlightStatus

    var body: some View {
        Text(status.displayText)
            .font(.caption.bold())
            .padding(.horizontal, 8)
            .padding(.vertical, 4)
            .background(status.color)
            .foregroundStyle(.white)
            .clipShape(Capsule())
            .contentTransition(.numericText())  // Smooth number changes
            .animation(.spring(response: 0.3), value: status)
    }
}

// Progress bar animation
struct AnimatedProgress: View {
    let progress: Double

    var body: some View {
        GeometryReader { geo in
            Rectangle()
                .fill(Color.accentColor)
                .frame(width: geo.size.width * progress)
                .animation(.linear(duration: 60), value: progress)  // Slow, continuous
        }
    }
}

Lecciones para nuestro trabajo

1. Las analogías del mundo real guían el diseño

La señalización aeroportuaria cuenta con más de 50 años de optimización. Aprovecha sistemas ya probados.

2. Los estados contextuales reducen la carga cognitiva

15 estados inteligentes significan que los usuarios nunca ven información irrelevante.

3. Lo aburrido y obvio es el objetivo

Si la app “simplemente funciona”, has tenido éxito. La fricción es fracaso.

4. Empaqueta, envuelve y colorea tus datos

No muestres datos crudos. Presenta información procesada. Codifica el estado con colores.

5. Offline-first para móvil

Los vuelos se quedan sin conexión. La app debe funcionar sin conectividad.


Preguntas frecuentes

¿Cómo funcionan los 15 estados inteligentes de Flighty?

Flighty monitorea el estado de tu vuelo, tu ubicación y la hora para mostrar automáticamente la información más relevante. Los estados van desde “muy anticipado” (más de 24 horas antes) pasando por “dirigirse al aeropuerto”, “en la puerta”, “embarcando”, “en vuelo” y “aterrizado”. Cada estado muestra diferentes detalles: números de confirmación con anticipación, información de la puerta cuando es relevante, tiempo de vuelo restante mientras estás en el aire e indicaciones de conexión tras el aterrizaje.

¿Qué hace especial la implementación de Live Activities de Flighty?

Flighty fue la primera app en aprovechar completamente las Live Activities y la Dynamic Island de iOS 16. La implementación incluye vistas compactas que muestran la puerta y la cuenta regresiva, vistas expandidas con todos los detalles del vuelo y una visualización del progreso en vuelo compatible con el modo offline que funciona sin conectividad. El diseño trata las Live Activities como una interfaz principal, no como algo secundario.

¿Cómo maneja Flighty los escenarios sin conexión?

Flighty descarga los datos del vuelo y los almacena localmente antes de la salida. El progreso en vuelo continúa basándose en los horarios programados y el rendimiento conocido de la aeronave. La app precalcula estimaciones de llegada, tiempos de conexión e información de puertas. Cuando la conectividad regresa, los datos se sincronizan y actualizan con cualquier cambio. Este enfoque offline-first es esencial ya que los vuelos pasan horas sin internet.

¿Por qué Flighty usa la señalización aeroportuaria como inspiración de diseño?

Los paneles de salidas de los aeropuertos se han perfeccionado durante más de 50 años para comunicar información de vuelos rápidamente a viajeros estresados en entornos concurridos. Utilizan una línea por vuelo, estados codificados por color y solo la información esencial. Flighty adapta este lenguaje visual probado para dispositivos móviles, empleando los mismos principios de densidad, claridad e indicación de estado que los aeropuertos han optimizado durante décadas.

¿Cómo calcula el asistente de conexiones las conexiones ajustadas?

El asistente de conexiones compara tu hora de llegada con la siguiente salida y luego calcula el tiempo de caminata según la distancia entre puertas dentro de la terminal. Tiene en cuenta velocidades típicas de caminata y la distribución de la terminal. Si el margen baja de 15 minutos, la app lo marca como conexión ajustada y muestra un mapa en vivo con tu ubicación actual, la puerta de destino y la ruta a pie.