Flighty: Visualização de Dados Feita da Forma Certa

Como o Flighty ganhou o Apple Design Award 2023: 15 estados inteligentes, Live Activities e visualização de dados inspirada em aeroportos. Com padrões de implementação em SwiftUI.

4 min de leitura 1016 palavras
Flighty: Visualização de Dados Feita da Forma Certa screenshot

Flighty: Visualização de Dados Bem Feita

“Queremos que o Flighty funcione tão bem que pareça quase obviamente simples.”

O Flighty transforma dados complexos de voos em informações fáceis de visualizar. Vencedor do Apple Design Award 2023 na categoria Interação, é uma aula magistral em apresentar dados densos através de design calmo e claro, inspirado em décadas de sinalização aeroportuária.


Por Que o Flighty Importa

Fundado pelo ex-funcionário da Apple Ryan Jones após um atraso de voo frustrante, o Flighty se tornou o padrão ouro para visualização de dados no iOS.

Principais conquistas: - Apple Design Award 2023 (Interação) - Finalista do App do Ano para iPhone 2023 - Primeiro app a aproveitar totalmente as Live Activities - 15 estados inteligentes baseados em contexto - Previsões de atraso líderes do setor


Principais Conclusões

  1. Aprenda com sistemas comprovados - Painéis de embarque de aeroportos têm mais de 50 anos de otimização; o Flighty adapta essa linguagem visual para dispositivos móveis
  2. Estados baseados em contexto reduzem a carga cognitiva - 15 estados inteligentes garantem que os usuários vejam exatamente o que precisam em cada fase da viagem
  3. “Obviamente simples” é o objetivo - Se o app simplesmente funciona sem atrito, você teve sucesso
  4. Empacote, organize e colora seus dados - Apresente insights, não dados brutos; codifique por cores o status para que os usuários não precisem interpretar
  5. Offline-first para conectividade instável - Voos ficam offline; o app deve funcionar sem conexão, especialmente durante o voo

Filosofia Central de Design

Sinalização Aeroportuária como Linguagem de Design

O design do Flighty é construído sobre uma analogia do mundo real: painéis de embarque de aeroportos.

PAINEL DE EMBARQUE DO AEROPORTO (50+ anos de refinamento)
───────────────────────────────────────────────────────────────────
VOO      DESTINO          PORTÃO  HORÁRIO STATUS
UA 1234  San Francisco    B22     14:30   NO HORÁRIO
DL 567   New York JFK     C15     15:00   ATRASADO
AA 890   Chicago O'Hare   A08     15:45   EMBARQUE

Uma linha por voo. Apenas informações essenciais. Décadas de otimização.

TRADUÇÃO DO FLIGHTY
───────────────────────────────────────────────────────────────────
[Cartão de voo com mesma densidade de informação]
[Status codificado por cores]
[Detalhes apropriados para o momento]

Insight principal: “Aqueles painéis de aeroporto têm uma linha por voo, e esse é um bom princípio norteador—eles tiveram 50 anos para descobrir o que é importante.”

Consciência do Contexto de Viagem

O Flighty entende que viajar é estressante. O design deve reduzir a carga cognitiva, não aumentá-la.

Princípios de design: 1. Informações mais importantes acima da dobra 2. Revelação progressiva para detalhes 3. Status codificado por cores (verde = bom, amarelo = atenção, vermelho = problema) 4. Dados são “empacotados, organizados e coloridos” para que os usuários não precisem interpretar


Biblioteca de Padrões

1. Os 15 Estados Inteligentes

A inovação característica do Flighty: 15 estados baseados em contexto que mostram exatamente o que você precisa, exatamente quando você precisa.

Linha do tempo dos estados:

24 HORAS ANTES
┌─────────────────────────────────────────┐
│ UA 1234 → SFO                           │
│ Amanhã às 14:30                         │
│ Confirmação: ABC123                     │
│ Vá para o Terminal 2                    │
└─────────────────────────────────────────┘

3 HORAS ANTES
┌─────────────────────────────────────────┐
│ UA 1234 → SFO                           │
│ Portão B22 • Embarque 14:00             │
│ Saia para o aeroporto às 12:15          │
│ [Ver cronograma completo →]             │
└─────────────────────────────────────────┘

NO PORTÃO
┌─────────────────────────────────────────┐
│ UA 1234 → SFO     [NO HORÁRIO]         │
│ Portão B22 • Embarque em 12 min         │
│ Assento 14A • Janela                    │
│ [Seu avião está aqui ✓]                 │
└─────────────────────────────────────────┘

CAMINHANDO PARA O AVIÃO
┌─────────────────────────────────────────┐
│ UA 1234 → SFO     [EMBARCANDO]         │
│ Seu assento: 14A (Janela)               │
│ ▓▓▓▓▓▓▓▓░░ 80% embarcado               │
└─────────────────────────────────────────┘

EM VOO (offline)
┌─────────────────────────────────────────┐
│  SFO ──────●───────────── JFK           │
│            │                            │
│     2h 15m restantes                    │
│     Chegada ~17:45 horário local        │
└─────────────────────────────────────────┘

POUSADO
┌─────────────────────────────────────────┐
│ ✓ Chegou ao JFK                         │
│ Seu portão: C15 → Portão conexão: D22   │
│ 8 min caminhando • Mapa do terminal [→] │
└─────────────────────────────────────────┘

Conceito de implementação:

enum FlightState: CaseIterable {
    case farOut          // > 24 horas
    case dayBefore       // 24 horas
    case headToAirport   // ~3 horas
    case atAirport       // No terminal
    case atGate          // Área do portão
    case boarding        // Embarque iniciado
    case onBoard         // Sentado
    case taxiing         // Indo para pista
    case inFlight        // No ar
    case descending      // Aproximando
    case landed          // Pousou
    case atGateDest      // Chegou ao portão
    case connection      // Voo de conexão
    case completed       // Viagem concluída
    case delayed         // Qualquer estado de atraso
}

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

        // Fatores contextuais
        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: "Portão \(flight.gate) • Embarque \(flight.boardingTime.formatted())",
                secondary: "Saia para o aeroporto às \(flight.recommendedDeparture.formatted())",
                action: "Ver cronograma completo"
            )
        case .inFlight:
            return StateContent(
                headline: flight.routeVisualization,
                primary: "\(flight.remainingTime.formatted()) restantes",
                secondary: "Chegada ~\(flight.estimatedArrival.formatted())",
                action: nil
            )
        // ... outros estados
        }
    }
}

2. Live Activities e Dynamic Island

O Flighty foi o primeiro app a aproveitar totalmente as Live Activities do iOS 16, estabelecendo o padrão para o recurso.

Estados da Dynamic Island:

COMPACTO (Mínimo)
╭──────────────────────────────────╮
│ [^] B22  │  *  │  14:30 -> 1h 45m│
╰──────────────────────────────────╯
  Portão    Status   Horário/Duração

EXPANDIDO (Pressão longa)
╭────────────────────────────────────────╮
│  UA 1234 para San Francisco            │
│  ───────────────────────────────────   │
│  Portão B22      Embarque 14:00        │
│  Assento 14A     No Horário ✓          │
│                                        │
│  [Abrir Flighty]    [Compartilhar ETA] │
╰────────────────────────────────────────╯

EM VOO (Funciona offline)
╭──────────────────────────────────╮
│  SFO ●════════○──── JFK          │
│         2h 15m                   │
╰──────────────────────────────────╯

Padrão de implementação 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 em voo
        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
            // Visualização da Tela de Bloqueio
            LockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                // Visualização expandida
                DynamicIslandExpandedRegion(.leading) {
                    VStack(alignment: .leading) {
                        Text(context.attributes.flightNumber)
                            .font(.headline)
                        Text("para \(context.attributes.destination.city)")
                            .font(.caption)
                    }
                }
                DynamicIslandExpandedRegion(.trailing) {
                    VStack(alignment: .trailing) {
                        Text("Portão \(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("Embarque \(context.state.boardingTime.formatted())")
                    }
                }
            } compactLeading: {
                // Lado esquerdo compacto
                Text(context.attributes.gate)
                    .font(.caption)
            } compactTrailing: {
                // Lado direito compacto
                Text(context.state.departureTime, style: .timer)
            } minimal: {
                // Mínimo (quando há múltiplas atividades)
                Image(systemName: "airplane")
            }
        }
    }
}

3. Visualização do Progresso do Voo

O Flighty mostra o progresso do voo através de múltiplas metáforas visuais.

Linha de rota com indicador de avião:

PROGRESSO LINEAR (Principal)
───────────────────────────────────────────────────────────────────
SFO ════════════════●════════════════════════════════════ JFK
    [Decolou há 45m]      ↑ Você está aqui      [2h 15m restantes]

PROGRESSO CIRCULAR (Compacto)
        ╭───────╮
       /    ●    \
      │     |     │    ← Arco de progresso preenche em sentido horário
      │     |     │
       \    ↓    /     2h 15m restantes
        ╰───────╯

MAPA DE CALOR (Estatísticas de Vida)
[Mapa com linhas de rota de espessuras variadas]
Linha grossa = rota frequentemente viajada
Linha fina = viajou uma vez

Implementação 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 {
                // Linha da rota
                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)

                // Linha de progresso
                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)

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

                // Ícone do avião na posição atual
                Image(systemName: "airplane")
                    .foregroundStyle(Color.accentColor)
                    .position(
                        x: 40 + (geometry.size.width - 80) * progress,
                        y: geometry.size.height / 2
                    )

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

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

4. Assistente de Conexão

Ao pousar com um voo de conexão, o Flighty mostra ambos os portões com sua localização ao vivo.

VISUALIZAÇÃO DE CONEXÃO
┌─────────────────────────────────────────┐
│ Conexão: 45 minutos                     │
│                                         │
│ ┌─────────────────────────────────────┐ │
│ │                                     │ │
│ │   [Mapa do Terminal]                │ │
│ │                                     │ │
│ │   [*] Você -> -> -> -> -> [>] D22    │ │
│ │   C15            8 min caminhando   │ │
│ │                                     │ │
│ └─────────────────────────────────────┘ │
│                                         │
│ [!] Conexão apertada - comece a andar   │
└─────────────────────────────────────────┘

Implementação:

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

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

    var walkingTime: TimeInterval {
        // Calcula baseado na distância do portão e velocidade típica de caminhada
        TerminalMap.walkingTime(from: arrival.arrivalGate, to: departure.gate)
    }

    var isTight: Bool {
        connectionTime < walkingTime + 15 * 60  // Menos de 15 min de margem
    }

    var body: some View {
        VStack(spacing: 16) {
            // Cabeçalho
            HStack {
                Text("Conexão")
                    .font(.headline)
                Spacer()
                Text(connectionTime.formatted())
                    .font(.title2.bold())
                    .foregroundStyle(isTight ? .orange : .primary)
            }

            // Mapa com rota
            TerminalMapView(
                from: arrival.arrivalGate,
                to: departure.gate,
                userLocation: userLocation
            )
            .frame(height: 200)
            .clipShape(RoundedRectangle(cornerRadius: 12))

            // Informações do portão
            HStack {
                GateLabel(gate: arrival.arrivalGate, label: "Chegada")
                Spacer()
                Text("\(Int(walkingTime / 60)) min caminhando")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Spacer()
                GateLabel(gate: departure.gate, label: "Partida")
            }

            // Aviso se apertado
            if isTight {
                HStack {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .foregroundStyle(.orange)
                    Text("Conexão apertada - comece a andar")
                        .font(.callout)
                }
                .padding()
                .background(Color.orange.opacity(0.1))
                .clipShape(RoundedRectangle(cornerRadius: 8))
            }
        }
        .padding()
    }
}

5. Passaporte e Estatísticas de Vida

O Flighty transforma o histórico de viagens em visualizações bonitas e compartilháveis.

PÁGINA DO PASSAPORTE
┌─────────────────────────────────────────┐
│           [^] PASSAPORTE FLIGHTY        │
│                                         │
│  [Arte personalizada com temas de viagem]│
│                                         │
│  ───────────────────────────────────    │
│  2024                                   │
│                                         │
│  47 voos                                │
│  143.650 km                             │
│  23 aeroportos                          │
│  12 países                              │
│                                         │
│  Mais visitado: SFO (23 vezes)          │
│  Voo mais longo: SFO → SIN (13.595 km)  │
│  ───────────────────────────────────    │
│                                         │
│  [Compartilhar] [Ver Detalhes]          │
└─────────────────────────────────────────┘

Sistema de Design Visual

Paleta de Cores

extension Color {
    // Cores de status (inspiradas em aeroportos)
    static let flightOnTime = Color(hex: "#10B981")    // Verde
    static let flightDelayed = Color(hex: "#F59E0B")   // Âmbar
    static let flightCancelled = Color(hex: "#EF4444") // Vermelho
    static let flightBoarding = Color(hex: "#3B82F6")  // Azul

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

    // Destaque
    static let flightAccent = Color(hex: "#6366F1")    // Índigo
}

Tipografia

struct FlightyTypography {
    // Números de voo e portões (precisam se destacar)
    static let flightNumber = Font.system(size: 20, weight: .bold, design: .monospaced)
    static let gate = Font.system(size: 24, weight: .bold, design: .rounded)

    // Exibições de tempo
    static let time = Font.system(size: 18, weight: .medium, design: .monospaced)
    static let countdown = Font.system(size: 32, weight: .bold, design: .monospaced)

    // Status e rótulos
    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)
}

Filosofia de Animação

Movimento com Propósito

As animações do Flighty servem à função, não à decoração.

// Animação de mudança de status
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())  // Mudanças de números suaves
            .animation(.spring(response: 0.3), value: status)
    }
}

// Animação da barra de progresso
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)  // Lenta, contínua
        }
    }
}

Lições para Nosso Trabalho

1. Analogias do Mundo Real Guiam o Design

A sinalização aeroportuária tem mais de 50 anos de otimização. Aprenda com sistemas comprovados.

2. Estados Baseados em Contexto Reduzem a Carga Cognitiva

15 estados inteligentes significam que os usuários nunca veem informações irrelevantes.

3. Obviamente Simples É o Objetivo

Se o app “simplesmente funciona”, você teve sucesso. Atrito é fracasso.

4. Empacote, Organize, Colora Seus Dados

Não mostre dados brutos. Apresente insights. Codifique por cores o status.

5. Offline-First para Mobile

Voos ficam offline. O app deve funcionar sem conexão.


Perguntas Frequentes

Como funcionam os 15 estados inteligentes do Flighty?

O Flighty monitora o status do seu voo, localização e horário para mostrar automaticamente as informações mais relevantes. Os estados vão desde “muito antecipado” (24+ horas antes) passando por “ir para o aeroporto”, “no portão”, “embarcando”, “em voo” e “pousado”. Cada estado apresenta detalhes diferentes: números de confirmação no início, informações do portão quando relevantes, tempo de voo restante enquanto no ar, e orientações de conexão após pousar.

O que torna a implementação de Live Activities do Flighty especial?

O Flighty foi o primeiro app a aproveitar totalmente as Live Activities e Dynamic Island do iOS 16. A implementação inclui visualizações compactas mostrando portão e contagem regressiva, visualizações expandidas com detalhes completos do voo, e uma visualização de progresso em voo que funciona offline sem conectividade. O design trata as Live Activities como uma interface principal, não como um complemento.

Como o Flighty lida com cenários offline?

O Flighty baixa dados do voo e os armazena localmente antes da partida. O progresso em voo continua baseado no horário programado e no desempenho conhecido da aeronave. O app pré-calcula estimativas de chegada, tempos de conexão e informações do portão. Quando a conectividade retorna, os dados são sincronizados e atualizados com quaisquer mudanças. Essa abordagem offline-first é essencial já que os voos passam horas sem internet.

Por que o Flighty usa sinalização aeroportuária como inspiração de design?

Os painéis de embarque dos aeroportos foram refinados por mais de 50 anos para comunicar informações de voo rapidamente para viajantes estressados em ambientes movimentados. Eles usam uma linha por voo, status codificado por cores e apenas informações essenciais. O Flighty adapta essa linguagem visual comprovada para dispositivos móveis, usando os mesmos princípios de densidade, clareza e indicação de status que os aeroportos otimizaram por décadas.

Como o assistente de conexão calcula conexões apertadas?

O assistente de conexão compara seu horário de chegada com a próxima partida, então calcula o tempo de caminhada baseado na distância do portão dentro do terminal. Ele considera velocidades típicas de caminhada e layouts do terminal. Se a margem cair abaixo de 15 minutos, o app sinaliza como uma conexão apertada e mostra um mapa ao vivo com sua localização atual, portão de destino e rota de caminhada.